From 8ef94b2cee3e96e8f2f503900f45a92a4e66a30d Mon Sep 17 00:00:00 2001 From: Georgios Chatoutsidis Date: Mon, 27 Apr 2026 10:14:57 +0300 Subject: [PATCH 1/2] #17 Fix translation progress calculations and update related tests for accuracy Co-authored-by: Copilot --- Sources/XCSAnalytics/XCSAnalytics.swift | 221 ++++++++++++++++-------- Tests/XCSKitTests/ANL_010.swift | 2 +- Tests/XCSKitTests/ANL_011.swift | 2 +- Tests/XCSKitTests/ANL_012.swift | 48 +++++ Tests/XCSKitTests/ANL_013.swift | 103 +++++++++++ Tests/XCSKitTests/NFR_006.swift | 6 +- docs/SRS.md | 4 + 7 files changed, 310 insertions(+), 76 deletions(-) create mode 100644 Tests/XCSKitTests/ANL_012.swift create mode 100644 Tests/XCSKitTests/ANL_013.swift diff --git a/Sources/XCSAnalytics/XCSAnalytics.swift b/Sources/XCSAnalytics/XCSAnalytics.swift index 45cb662..e20775e 100644 --- a/Sources/XCSAnalytics/XCSAnalytics.swift +++ b/Sources/XCSAnalytics/XCSAnalytics.swift @@ -41,13 +41,15 @@ public struct LanguageProgress: Equatable, Sendable { public let missingLeafCount: Int public let untranslatedLeafCount: Int public let needsReviewLeafCount: Int + private let sourceKeyCount: Int + private let translatedKeyCount: Int public var progress: Double { - guard sourceLeafCount > 0 else { + guard sourceKeyCount > 0 else { return 0 } - return Double(translatedLeafCount) / Double(sourceLeafCount) + return Double(translatedKeyCount) / Double(sourceKeyCount) } public init( @@ -56,7 +58,9 @@ public struct LanguageProgress: Equatable, Sendable { translatedLeafCount: Int, missingLeafCount: Int, untranslatedLeafCount: Int, - needsReviewLeafCount: Int + needsReviewLeafCount: Int, + sourceKeyCount: Int? = nil, + translatedKeyCount: Int? = nil ) { self.locale = locale self.sourceLeafCount = sourceLeafCount @@ -64,6 +68,8 @@ public struct LanguageProgress: Equatable, Sendable { self.missingLeafCount = missingLeafCount self.untranslatedLeafCount = untranslatedLeafCount self.needsReviewLeafCount = needsReviewLeafCount + self.sourceKeyCount = sourceKeyCount ?? sourceLeafCount + self.translatedKeyCount = translatedKeyCount ?? translatedLeafCount } } @@ -77,14 +83,16 @@ public struct CatalogStatistics: Equatable, Sendable { public let missingLeafCount: Int public let untranslatedLeafCount: Int public let languageProgress: [LanguageProgress] + private let progressKeyCount: Int + private let translatedProgressCount: Int public var overallCoverage: Double { - let possibleLeafCount = totalSourceLeafCount * targetLanguages.count - guard possibleLeafCount > 0 else { + let possibleTranslationCount = progressKeyCount * targetLanguages.count + guard possibleTranslationCount > 0 else { return 0 } - return Double(translatedLeafCount) / Double(possibleLeafCount) + return Double(translatedProgressCount) / Double(possibleTranslationCount) } public init( @@ -96,7 +104,9 @@ public struct CatalogStatistics: Equatable, Sendable { translatedLeafCount: Int, missingLeafCount: Int, untranslatedLeafCount: Int, - languageProgress: [LanguageProgress] + languageProgress: [LanguageProgress], + progressKeyCount: Int? = nil, + translatedProgressCount: Int? = nil ) { self.sourceLanguage = sourceLanguage self.keyCount = keyCount @@ -107,6 +117,8 @@ public struct CatalogStatistics: Equatable, Sendable { self.missingLeafCount = missingLeafCount self.untranslatedLeafCount = untranslatedLeafCount self.languageProgress = languageProgress + self.progressKeyCount = progressKeyCount ?? totalSourceLeafCount + self.translatedProgressCount = translatedProgressCount ?? translatedLeafCount } } @@ -194,11 +206,7 @@ public enum XCSAnalytics { } for entry in catalog.strings.values { - guard let sourceLocalization = catalog.sourceLocalization(for: entry) else { - continue - } - - let sourceLeaves = leafMap(for: sourceLocalization) + let sourceLeaves = resolvedSourceLeafMap(for: entry, sourceLanguage: catalog.sourceLanguage) guard !sourceLeaves.isEmpty else { continue } @@ -208,24 +216,15 @@ public enum XCSAnalytics { continue } - let targetLeaves = leafMap(for: entry.localizations?[locale]) - accumulator.sourceLeafCount += sourceLeaves.count - - for (path, _) in sourceLeaves { - guard let targetLeaf = targetLeaves[path] else { - accumulator.missingLeafCount += 1 - accumulator.untranslatedLeafCount += 1 - continue - } - - if targetLeaf.state == "translated" { - accumulator.translatedLeafCount += 1 - } else { - accumulator.untranslatedLeafCount += 1 - if targetLeaf.state == "needs_review" { - accumulator.needsReviewLeafCount += 1 - } - } + let coverage = localeCoverage(for: entry, locale: locale, sourceLeaves: sourceLeaves) + accumulator.sourceLeafCount += coverage.sourceLeafCount + accumulator.translatedLeafCount += coverage.translatedLeafCount + accumulator.missingLeafCount += coverage.missingLeafCount + accumulator.untranslatedLeafCount += coverage.untranslatedLeafCount + accumulator.needsReviewLeafCount += coverage.needsReviewLeafCount + accumulator.sourceKeyCount += 1 + if coverage.isComplete { + accumulator.translatedKeyCount += 1 } } } @@ -235,35 +234,29 @@ public enum XCSAnalytics { public static func statistics(in catalog: Catalog) -> CatalogStatistics { let locales = targetLanguages(in: catalog) - let totalSourceLeafCount = sourceLeafMap(in: catalog).values.reduce(0) { $0 + $1.count } + var totalSourceLeafCount = 0 var translatedLeafCount = 0 var missingLeafCount = 0 var untranslatedLeafCount = 0 + var progressKeyCount = 0 + var translatedProgressCount = 0 for entry in catalog.strings.values { - guard let sourceLocalization = catalog.sourceLocalization(for: entry) else { - continue - } - - let sourceLeaves = leafMap(for: sourceLocalization) + let sourceLeaves = resolvedSourceLeafMap(for: entry, sourceLanguage: catalog.sourceLanguage) guard !sourceLeaves.isEmpty else { continue } - for locale in locales { - let targetLeaves = leafMap(for: entry.localizations?[locale]) - for (path, _) in sourceLeaves { - guard let targetLeaf = targetLeaves[path] else { - missingLeafCount += 1 - untranslatedLeafCount += 1 - continue - } + totalSourceLeafCount += sourceLeaves.count + progressKeyCount += 1 - if targetLeaf.state == "translated" { - translatedLeafCount += 1 - } else { - untranslatedLeafCount += 1 - } + for locale in locales { + let coverage = localeCoverage(for: entry, locale: locale, sourceLeaves: sourceLeaves) + translatedLeafCount += coverage.translatedLeafCount + missingLeafCount += coverage.missingLeafCount + untranslatedLeafCount += coverage.untranslatedLeafCount + if coverage.isComplete { + translatedProgressCount += 1 } } } @@ -277,7 +270,9 @@ public enum XCSAnalytics { translatedLeafCount: translatedLeafCount, missingLeafCount: missingLeafCount, untranslatedLeafCount: untranslatedLeafCount, - languageProgress: languageProgress(in: catalog) + languageProgress: languageProgress(in: catalog), + progressKeyCount: progressKeyCount, + translatedProgressCount: translatedProgressCount ) } @@ -286,6 +281,15 @@ public enum XCSAnalytics { let stringUnit: StringUnit } + private struct LeafCoverage { + let sourceLeafCount: Int + let translatedLeafCount: Int + let missingLeafCount: Int + let untranslatedLeafCount: Int + let needsReviewLeafCount: Int + let isComplete: Bool + } + private final class LanguageProgressAccumulator { let locale: String var sourceLeafCount: Int = 0 @@ -293,6 +297,8 @@ public enum XCSAnalytics { var missingLeafCount: Int = 0 var untranslatedLeafCount: Int = 0 var needsReviewLeafCount: Int = 0 + var sourceKeyCount: Int = 0 + var translatedKeyCount: Int = 0 init(locale: String) { self.locale = locale @@ -305,7 +311,9 @@ public enum XCSAnalytics { translatedLeafCount: translatedLeafCount, missingLeafCount: missingLeafCount, untranslatedLeafCount: untranslatedLeafCount, - needsReviewLeafCount: needsReviewLeafCount + needsReviewLeafCount: needsReviewLeafCount, + sourceKeyCount: sourceKeyCount, + translatedKeyCount: translatedKeyCount ) } } @@ -313,11 +321,7 @@ public enum XCSAnalytics { private static func sourceLeafMap(in catalog: Catalog) -> [String: [[VariationSelection]: StringUnit]] { var map: [String: [[VariationSelection]: StringUnit]] = [:] for entry in catalog.strings.values { - guard let sourceLocalization = catalog.sourceLocalization(for: entry) else { - continue - } - - let leaves = leafMap(for: sourceLocalization) + let leaves = resolvedSourceLeafMap(for: entry, sourceLanguage: catalog.sourceLanguage) guard !leaves.isEmpty else { continue } @@ -339,6 +343,46 @@ public enum XCSAnalytics { return map } + private static func leafMap(for leaves: [Leaf]) -> [[VariationSelection]: StringUnit] { + var map: [[VariationSelection]: StringUnit] = [:] + for leaf in leaves { + map[leaf.path] = leaf.stringUnit + } + return map + } + + private static func resolvedSourceLeafMap( + for entry: Entry, + sourceLanguage: String + ) -> [[VariationSelection]: StringUnit] { + leafMap(for: resolvedSourceLeaves(for: entry, sourceLanguage: sourceLanguage)) + } + + private static func resolvedSourceLeaves(for entry: Entry, sourceLanguage: String) -> [Leaf] { + guard entry.shouldTranslate != false else { + return [] + } + + guard let localizations = entry.localizations else { + return fallbackSourceLeaves(for: entry) + } + + guard let sourceLocalization = localizations[sourceLanguage] else { + return fallbackSourceLeaves(for: entry) + } + + return collectLeaves(from: sourceLocalization) + } + + private static func fallbackSourceLeaves(for entry: Entry) -> [Leaf] { + [ + Leaf( + path: [], + stringUnit: StringUnit(state: "translated", value: entry.key) + ) + ] + } + private static func collectLeaves(from localization: Localization) -> [Leaf] { var leaves: [Leaf] = [] if let stringUnit = localization.stringUnit { @@ -377,17 +421,55 @@ public enum XCSAnalytics { } } + private static func localeCoverage( + for entry: Entry, + locale: String, + sourceLeaves: [[VariationSelection]: StringUnit] + ) -> LeafCoverage { + let targetLeaves = leafMap(for: entry.localizations?[locale]) + let entryIsStale = entry.extractionState == .stale + var translatedLeafCount = 0 + var missingLeafCount = 0 + var untranslatedLeafCount = 0 + var needsReviewLeafCount = 0 + var isComplete = !entryIsStale + + for (path, _) in sourceLeaves { + guard let targetLeaf = targetLeaves[path] else { + missingLeafCount += 1 + untranslatedLeafCount += 1 + isComplete = false + continue + } + + if !entryIsStale, targetLeaf.state == "translated" { + translatedLeafCount += 1 + } else { + untranslatedLeafCount += 1 + isComplete = false + if !entryIsStale, targetLeaf.state == "needs_review" { + needsReviewLeafCount += 1 + } + } + } + + return LeafCoverage( + sourceLeafCount: sourceLeaves.count, + translatedLeafCount: translatedLeafCount, + missingLeafCount: missingLeafCount, + untranslatedLeafCount: untranslatedLeafCount, + needsReviewLeafCount: needsReviewLeafCount, + isComplete: isComplete + ) + } + private static func keyCoverage( for entry: Entry, key: String, locale: String?, catalog: Catalog ) -> KeyCoverageSummary? { - guard let sourceLocalization = catalog.sourceLocalization(for: entry) else { - return nil - } - - let sourceLeaves = leafMap(for: sourceLocalization) + let sourceLeaves = resolvedSourceLeafMap(for: entry, sourceLanguage: catalog.sourceLanguage) guard !sourceLeaves.isEmpty else { return nil } @@ -396,21 +478,18 @@ public enum XCSAnalytics { var missingLeafCount = 0 if let locale { - let targetLeaves = leafMap(for: entry.localizations?[locale]) + let coverage = localeCoverage(for: entry, locale: locale, sourceLeaves: sourceLeaves) + translatedLeafCount = coverage.translatedLeafCount + missingLeafCount = coverage.missingLeafCount + } else { + let locales = targetLanguages(in: catalog) for (path, _) in sourceLeaves { - guard let targetLeaf = targetLeaves[path] else { + var translated = false + guard entry.extractionState != .stale else { missingLeafCount += 1 continue } - if targetLeaf.state == "translated" { - translatedLeafCount += 1 - } - } - } else { - let locales = targetLanguages(in: catalog) - for (path, _) in sourceLeaves { - var translated = false for targetLocale in locales { guard let targetLeaf = leafMap(for: entry.localizations?[targetLocale])[path] else { continue diff --git a/Tests/XCSKitTests/ANL_010.swift b/Tests/XCSKitTests/ANL_010.swift index 3c75289..2d0b9d6 100644 --- a/Tests/XCSKitTests/ANL_010.swift +++ b/Tests/XCSKitTests/ANL_010.swift @@ -16,7 +16,7 @@ struct ANL_010 { #expect(fr.missingLeafCount == 1) #expect(fr.untranslatedLeafCount == 2) #expect(fr.needsReviewLeafCount == 0) - #expect(fr.progress == 0.5) + #expect(fr.progress == 1.0 / 3.0) let ja = try #require(progress.first { $0.locale == "ja" }) #expect(ja.sourceLeafCount == 4) diff --git a/Tests/XCSKitTests/ANL_011.swift b/Tests/XCSKitTests/ANL_011.swift index 33cc8ee..49ddf94 100644 --- a/Tests/XCSKitTests/ANL_011.swift +++ b/Tests/XCSKitTests/ANL_011.swift @@ -17,6 +17,6 @@ struct ANL_011 { #expect(statistics.missingLeafCount == 7) #expect(statistics.untranslatedLeafCount == 10) #expect(statistics.languageProgress.map(\.locale) == ["de", "fr", "ja"]) - #expect(statistics.overallCoverage == 1.0 / 6.0) + #expect(statistics.overallCoverage == 1.0 / 9.0) } } diff --git a/Tests/XCSKitTests/ANL_012.swift b/Tests/XCSKitTests/ANL_012.swift new file mode 100644 index 0000000..02e4975 --- /dev/null +++ b/Tests/XCSKitTests/ANL_012.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +import XCSAnalytics +import XCSParser + +/// ANL-012: Analytics progress SHALL align with Xcode's sample-catalog per-language progress, including implicit source keys. +struct ANL_012 { + @Test func analyticsMatchesSampleCatalogLanguageProgress() async throws { + let catalog = try XCSParser.parse(fileURL: fixtureURL) + let progress = XCSAnalytics.languageProgress(in: catalog) + + #expect(progress.map(\.locale) == ["ar", "el", "fr"]) + + let arabic = try #require(progress.first { $0.locale == "ar" }) + #expect(roundedPercent(arabic.progress) == 13) + + let french = try #require(progress.first { $0.locale == "fr" }) + #expect(roundedPercent(french.progress) == 25) + + let greek = try #require(progress.first { $0.locale == "el" }) + #expect(roundedPercent(greek.progress) == 27) + + let addCoverage = try #require(XCSAnalytics.keyCoverage(forKey: "Add", locale: "el", in: catalog)) + #expect(addCoverage.sourceLeafCount == 1) + #expect(addCoverage.translatedLeafCount == 1) + #expect(addCoverage.missingLeafCount == 0) + #expect(addCoverage.untranslatedLeafCount == 0) + + let daysCoverage = try #require(XCSAnalytics.keyCoverage(forKey: "Days", locale: "fr", in: catalog)) + #expect(daysCoverage.sourceLeafCount == 1) + #expect(daysCoverage.translatedLeafCount == 0) + #expect(daysCoverage.missingLeafCount == 1) + #expect(daysCoverage.untranslatedLeafCount == 1) + } + + private func roundedPercent(_ value: Double) -> Int { + Int((value * 100).rounded()) + } + + private var fixtureURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("docs") + .appendingPathComponent("example.xcstrings") + } +} diff --git a/Tests/XCSKitTests/ANL_013.swift b/Tests/XCSKitTests/ANL_013.swift new file mode 100644 index 0000000..b6f6414 --- /dev/null +++ b/Tests/XCSKitTests/ANL_013.swift @@ -0,0 +1,103 @@ +import Testing +import XCSAnalytics +import XCSCore + +/// ANL-013: Progress analytics SHALL exclude opted-out entries, synthesize missing source leaves, and treat partially translated variation keys as incomplete. +struct ANL_013 { + @Test func analyticsUsesKeyCompletionForProgress() async throws { + let catalog = Catalog( + sourceLanguage: "en", + strings: [ + "Greeting": Entry( + key: "Greeting", + comment: nil, + isCommentAutoGenerated: nil, + shouldTranslate: true, + extractionState: nil, + localizations: [ + "fr": Localization( + stringUnit: StringUnit(state: "translated", value: "Bonjour"), + variations: nil + ) + ] + ), + "Files": Entry( + key: "Files", + comment: nil, + isCommentAutoGenerated: nil, + shouldTranslate: true, + extractionState: nil, + localizations: [ + "en": Localization( + stringUnit: nil, + variations: [ + "plural": VariationBranch(selectors: [ + "one": VariationNode( + stringUnit: StringUnit(state: "translated", value: "%lld file"), + variations: nil + ), + "other": VariationNode( + stringUnit: StringUnit(state: "translated", value: "%lld files"), + variations: nil + ) + ]) + ] + ), + "fr": Localization( + stringUnit: nil, + variations: [ + "plural": VariationBranch(selectors: [ + "one": VariationNode( + stringUnit: StringUnit(state: "translated", value: "%lld fichier"), + variations: nil + ), + "other": VariationNode( + stringUnit: StringUnit(state: "new", value: "%lld fichiers"), + variations: nil + ) + ]) + ] + ) + ] + ), + "Skip": Entry( + key: "Skip", + comment: nil, + isCommentAutoGenerated: nil, + shouldTranslate: false, + extractionState: nil, + localizations: [ + "en": Localization( + stringUnit: StringUnit(state: "translated", value: "Skip"), + variations: nil + ), + "fr": Localization( + stringUnit: StringUnit(state: "translated", value: "Ignorer"), + variations: nil + ) + ] + ) + ], + version: "1.0" + ) + + let progress = try #require(XCSAnalytics.languageProgress(in: catalog).first { $0.locale == "fr" }) + #expect(progress.progress == 0.5) + + let greetingCoverage = try #require(XCSAnalytics.keyCoverage(forKey: "Greeting", locale: "fr", in: catalog)) + #expect(greetingCoverage.sourceLeafCount == 1) + #expect(greetingCoverage.translatedLeafCount == 1) + #expect(greetingCoverage.missingLeafCount == 0) + #expect(greetingCoverage.untranslatedLeafCount == 0) + + let filesCoverage = try #require(XCSAnalytics.keyCoverage(forKey: "Files", locale: "fr", in: catalog)) + #expect(filesCoverage.sourceLeafCount == 2) + #expect(filesCoverage.translatedLeafCount == 1) + #expect(filesCoverage.missingLeafCount == 0) + #expect(filesCoverage.untranslatedLeafCount == 1) + + let statistics = XCSAnalytics.statistics(in: catalog) + #expect(statistics.overallCoverage == 0.5) + #expect(statistics.targetLanguages == ["fr"]) + } +} diff --git a/Tests/XCSKitTests/NFR_006.swift b/Tests/XCSKitTests/NFR_006.swift index 4b7de73..bda0c8f 100644 --- a/Tests/XCSKitTests/NFR_006.swift +++ b/Tests/XCSKitTests/NFR_006.swift @@ -1,7 +1,7 @@ import Foundation import Testing -/// NFR-006: The package SHALL include automated tests covering 100% of committed functional and validation requirements in this document at the requirement level. +/// NFR-006: The package SHALL include automated tests covering 100% of committed functional, validation, and analytics requirements in this document at the requirement level. struct NFR_006 { @Test func requirementLevelTestsMatchTheDocumentedMappingTable() async throws { @@ -14,7 +14,7 @@ struct NFR_006 { let fileManager = FileManager.default for line in srsText.split(separator: "\n") { - guard line.hasPrefix("| FR-") || line.hasPrefix("| VAL-") || line.hasPrefix("| TRN-") || line.hasPrefix("| WRT-") || line.hasPrefix("| VR-") else { + guard line.hasPrefix("| FR-") || line.hasPrefix("| VAL-") || line.hasPrefix("| TRN-") || line.hasPrefix("| WRT-") || line.hasPrefix("| ANL-") || line.hasPrefix("| VR-") else { continue } @@ -33,4 +33,4 @@ struct NFR_006 { } } -} \ No newline at end of file +} diff --git a/docs/SRS.md b/docs/SRS.md index 976d4b3..f9a78dc 100644 --- a/docs/SRS.md +++ b/docs/SRS.md @@ -196,6 +196,8 @@ This SRS is based on the repository package metadata and the sample catalog in [ - ANL-009: The package SHALL provide a key-coverage analytics API for a given source key and optional locale using source-leaf coverage across direct and variant leaves. - ANL-010: The package SHALL provide a language-progress analytics API that reports translated, missing, and untranslated leaf counts for each target locale. - ANL-011: The package SHALL provide an overall statistics API that returns typed summary data for keys, locales, stale keys, leaf counts, and translation progress. +- ANL-012: The package SHALL align sample-catalog language-progress percentages with Xcode's per-language progress, including synthesized direct source leaves for entries that do not contain an explicit source-language localization. +- ANL-013: The package SHALL compute progress ratios from completed translatable keys, excluding entries whose `shouldTranslate` value is explicitly `false` and treating partially translated variation keys as incomplete while still reporting leaf-level counts. ## 8. Validation and Error Requirements @@ -356,6 +358,8 @@ The initial fixture inventory SHALL include: | ANL-009 | Return key coverage | ANL_009 | key coverage summary reports translated and missing leaf counts | Done | | ANL-010 | Return language progress | ANL_010 | per-locale progress reports translated and missing leaf counts | Done | | ANL-011 | Return overall statistics | ANL_011 | overall statistics aggregate key, locale, and leaf totals | Done | +| ANL-012 | Match Xcode sample-catalog progress percentages | ANL_012 | sample catalog percentages match Xcode and implicit source fallback | Done | +| ANL-013 | Compute progress from completed translatable keys | ANL_013 | opted-out entries are excluded and partial variation keys incomplete | Done | | VR-001 | Invalid JSON failure | VR_001 | malformed JSON returns parse error | Done | | VR-002 | Missing `sourceLanguage` failure | VR_002 | missing root field returns parse error | Done | | VR-003 | Missing `strings` failure | VR_003 | missing strings field returns parse error | Done | From 5685e2f567df347b6e3e172398dd8b3bc0e69d21 Mon Sep 17 00:00:00 2001 From: Georgios Chatoutsidis Date: Mon, 27 Apr 2026 10:25:15 +0300 Subject: [PATCH 2/2] Add module guides and API documentation for XCSKit components --- README.md | 8 ++ docs/modules/README.md | 71 ++++++++++++++ docs/modules/XCSAnalytics.md | 98 +++++++++++++++++++ docs/modules/XCSCore.md | 116 +++++++++++++++++++++++ docs/modules/XCSKit.md | 39 ++++++++ docs/modules/XCSParser.md | 72 ++++++++++++++ docs/modules/XCSTranslator.md | 171 ++++++++++++++++++++++++++++++++++ docs/modules/XCSValidator.md | 70 ++++++++++++++ docs/modules/XCSWriter.md | 77 +++++++++++++++ 9 files changed, 722 insertions(+) create mode 100644 docs/modules/README.md create mode 100644 docs/modules/XCSAnalytics.md create mode 100644 docs/modules/XCSCore.md create mode 100644 docs/modules/XCSKit.md create mode 100644 docs/modules/XCSParser.md create mode 100644 docs/modules/XCSTranslator.md create mode 100644 docs/modules/XCSValidator.md create mode 100644 docs/modules/XCSWriter.md diff --git a/README.md b/README.md index 343beff..e0bd6de 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,14 @@ Continuous integration: - Software requirements specification: [docs/SRS.md](docs/SRS.md) - Sample catalog fixture: [docs/example.xcstrings](docs/example.xcstrings) +- Module guide index: [docs/modules/README.md](docs/modules/README.md) +- `XCSCore` API guide: [docs/modules/XCSCore.md](docs/modules/XCSCore.md) +- `XCSParser` API guide: [docs/modules/XCSParser.md](docs/modules/XCSParser.md) +- `XCSValidator` API guide: [docs/modules/XCSValidator.md](docs/modules/XCSValidator.md) +- `XCSTranslator` API guide: [docs/modules/XCSTranslator.md](docs/modules/XCSTranslator.md) +- `XCSWriter` API guide: [docs/modules/XCSWriter.md](docs/modules/XCSWriter.md) +- `XCSAnalytics` API guide: [docs/modules/XCSAnalytics.md](docs/modules/XCSAnalytics.md) +- `XCSKit` umbrella guide: [docs/modules/XCSKit.md](docs/modules/XCSKit.md) ## Status diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 0000000..fa4966e --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,71 @@ +# Module Guides + +XCSKit ships as a set of focused library products built on a shared `.xcstrings` model. Use this guide as the entry point for the module-level API documentation. + +```mermaid +flowchart LR + XCSCore --> XCSParser + XCSCore --> XCSValidator + XCSCore --> XCSTranslator + XCSCore --> XCSWriter + XCSCore --> XCSAnalytics + XCSKit --> XCSCore + XCSKit --> XCSParser + XCSKit --> XCSValidator + XCSKit --> XCSTranslator + XCSKit --> XCSWriter + XCSKit --> XCSAnalytics +``` + +## Which Module Should You Import? + +| Module | Use it when you need... | +|-----------------|--------------------------------------------------------------------------------------------------| +| `XCSCore` | shared models such as `Catalog`, `Entry`, `Localization`, `VariationSelection`, or `StringPatch` | +| `XCSParser` | to parse `.xcstrings` data or files into typed models | +| `XCSValidator` | read-only semantic findings for missing localizations, placeholder mismatches, and review states | +| `XCSTranslator` | provider-driven translation patch generation | +| `XCSWriter` | full serialization or targeted patch application back to JSON | +| `XCSAnalytics` | coverage, progress, stale-key, and statistics reporting | +| `XCSKit` | one umbrella import that re-exports the full package surface | + +## Guides + +- [XCSCore](./XCSCore.md) +- [XCSParser](./XCSParser.md) +- [XCSValidator](./XCSValidator.md) +- [XCSTranslator](./XCSTranslator.md) +- [XCSWriter](./XCSWriter.md) +- [XCSAnalytics](./XCSAnalytics.md) +- [XCSKit](./XCSKit.md) + +## Typical Workflow + +```swift +import Foundation +import XCSParser +import XCSValidator +import XCSTranslator +import XCSWriter +import XCSAnalytics + +let data = try Data(contentsOf: catalogURL) +let catalog = try XCSParser.parse(data: data) + +let findings = XCSValidator.validate(catalog) +let stats = XCSAnalytics.statistics(in: catalog) + +let result = try await XCSTranslator.translate( + catalog, + targetLocales: ["fr"], + provider: MyTranslationProvider() +) + +let updatedData = try XCSWriter.apply(patches: result.patches, to: data) +try XCSWriter.write(updatedData, to: outputURL) +``` + +## Related Documents + +- [Software Requirements Specification](../SRS.md) +- [Sample Catalog Fixture](../example.xcstrings) \ No newline at end of file diff --git a/docs/modules/XCSAnalytics.md b/docs/modules/XCSAnalytics.md new file mode 100644 index 0000000..641152d --- /dev/null +++ b/docs/modules/XCSAnalytics.md @@ -0,0 +1,98 @@ +# XCSAnalytics + +`XCSAnalytics` provides read-only reporting helpers for catalogs. It computes key lists, locale lists, stale entries, key coverage, per-language progress, and overall statistics from the shared `Catalog` model. + +## Summary Types + +### `KeyCoverageSummary` + +Fields: + +- `key` +- `locale` +- `sourceLeafCount` +- `translatedLeafCount` +- `missingLeafCount` +- `untranslatedLeafCount` + +Computed property: + +- `coverage`: translated leaf ratio for that key and locale scope + +### `LanguageProgress` + +Fields: + +- `locale` +- `sourceLeafCount` +- `translatedLeafCount` +- `missingLeafCount` +- `untranslatedLeafCount` +- `needsReviewLeafCount` + +Computed property: + +- `progress`: completed-key ratio for that locale + +Important detail: the leaf counts remain leaf-based, but `progress` is computed from completed translatable keys, not raw translated-leaf totals. + +### `CatalogStatistics` + +Fields: + +- `sourceLanguage` +- `keyCount` +- `targetLanguages` +- `staleKeyCount` +- `totalSourceLeafCount` +- `translatedLeafCount` +- `missingLeafCount` +- `untranslatedLeafCount` +- `languageProgress` + +Computed property: + +- `overallCoverage`: completed key-locale ratio across the catalog + +## Public API + +```swift +public static func keys(in catalog: Catalog) -> [String] +public static func sourceLanguage(in catalog: Catalog) -> String +public static func targetLanguages(in catalog: Catalog) -> [String] +public static func localizations(forKey key: String, in catalog: Catalog) -> [String: Localization] +public static func translations(forKey key: String, in catalog: Catalog) -> [String: Localization] +public static func localization(forKey key: String, locale: String, in catalog: Catalog) -> Localization? +public static func sourceLocalization(forKey key: String, in catalog: Catalog) -> Localization? +public static func untranslatedKeys(in catalog: Catalog, locale: String) -> [String] +public static func staleKeys(in catalog: Catalog) -> [String] +public static func keyCoverage(forKey key: String, in catalog: Catalog) -> KeyCoverageSummary? +public static func keyCoverage(forKey key: String, locale: String, in catalog: Catalog) -> KeyCoverageSummary? +public static func languageProgress(in catalog: Catalog) -> [LanguageProgress] +public static func statistics(in catalog: Catalog) -> CatalogStatistics +``` + +## Basic Usage + +```swift +import XCSAnalytics + +let keys = XCSAnalytics.keys(in: catalog) +let targetLocales = XCSAnalytics.targetLanguages(in: catalog) +let stale = XCSAnalytics.staleKeys(in: catalog) +let frenchGaps = XCSAnalytics.untranslatedKeys(in: catalog, locale: "fr") + +let coverage = XCSAnalytics.keyCoverage(forKey: "Hello", locale: "fr", in: catalog) +let progress = XCSAnalytics.languageProgress(in: catalog) +let stats = XCSAnalytics.statistics(in: catalog) +``` + +## Behavior Notes + +- Analytics synthesize a direct source leaf from `entry.key` when an entry has no explicit source-language localization. +- Entries with `shouldTranslate == false` are excluded from analytics coverage and progress denominators. +- Variant trees are flattened into leaf paths so plural and device leaves participate in coverage and progress. +- Stale entries are reported by `staleKeys(in:)` and treated as incomplete for progress calculations. +- `targetLanguages(in:)` excludes the source locale and returns a sorted list. + +Use `XCSAnalytics` when you want reporting only. If you need to change the catalog, use [XCSTranslator](./XCSTranslator.md) and [XCSWriter](./XCSWriter.md). \ No newline at end of file diff --git a/docs/modules/XCSCore.md b/docs/modules/XCSCore.md new file mode 100644 index 0000000..37a8eb7 --- /dev/null +++ b/docs/modules/XCSCore.md @@ -0,0 +1,116 @@ +# XCSCore + +`XCSCore` defines the shared data model used by every other module in the package. If you need to inspect, construct, serialize, validate, translate, patch, or analyze a catalog, you are working with these types. + +## Main Types + +### `Catalog` + +Represents a full `.xcstrings` document. + +Key properties: + +- `sourceLanguage`: the source locale code such as `en` +- `strings`: top-level entries keyed by source string +- `version`: optional format version from the source file + +Convenience helpers: + +- `entry(for:)` +- `localization(forKey:locale:)` +- `sourceLocalization(for:)` + +### `Entry` + +Represents one item inside the `strings` dictionary. + +Key properties: + +- `key`: source string key +- `comment`: optional translator or UI comment +- `isCommentAutoGenerated`: optional metadata flag +- `shouldTranslate`: optional opt-out flag +- `extractionState`: optional lifecycle metadata such as `stale` +- `localizations`: optional locale map + +Important detail: when decoding, `key` is derived from the dictionary key path if the JSON entry body does not repeat it. + +### `ExtractionState` + +Forward-compatible raw-string wrapper for entry extraction metadata. + +- Known convenience case: `ExtractionState.stale` +- Unknown future values are still preserved through `rawValue` + +### `Localization` + +Represents one locale's content for an entry. + +- `stringUnit`: optional direct leaf value +- `variations`: optional variation tree keyed by dimension name + +Lookup helpers: + +- `variantNode(forDimension:selector:)` +- `variantStringUnit(forDimension:selector:)` + +### `StringUnit` + +Leaf value with two fields: + +- `state`: translation state such as `translated`, `new`, or `needs_review` +- `value`: localized string content + +### `VariationBranch` and `VariationNode` + +These represent nested variant trees. + +- `VariationBranch` maps selector names to `VariationNode` +- `VariationNode` can contain either a `stringUnit`, nested `variations`, or both + +The model is generic and does not hardcode only `plural` or `device`. Any variation dimension name can be stored. + +### `VariationSelection` + +Describes one step in a variant path. + +```swift +let step = VariationSelection(dimension: "plural", selector: "one") +``` + +These values are used by translator patch output, writer patch input, and analytics leaf matching. + +### `StringPatch` + +Represents one targeted update to one direct or variant leaf. + +Fields: + +- `entryKey` +- `locale` +- `path`: ordered array of `VariationSelection` +- `stringUnit`: replacement leaf value + +## Common Usage + +```swift +import XCSCore + +let catalog = Catalog(sourceLanguage: "en", strings: [:], version: "1.0") + +if let entry = catalog.entry(for: "Add") { + let greek = catalog.localization(forKey: entry.key, locale: "el") + let value = greek?.stringUnit?.value +} +``` + +## When To Import `XCSCore` Directly + +Import `XCSCore` when you are: + +- building in-memory catalogs in tests or tools +- consuming models from `XCSParser` +- implementing an `XCSTranslationProvider` +- constructing `StringPatch` values for `XCSWriter` + +If you only need parsing, validation, translation, writing, or analytics APIs, you can usually import the higher-level module instead. \ No newline at end of file diff --git a/docs/modules/XCSKit.md b/docs/modules/XCSKit.md new file mode 100644 index 0000000..2408ab6 --- /dev/null +++ b/docs/modules/XCSKit.md @@ -0,0 +1,39 @@ +# XCSKit + +`XCSKit` is the umbrella product for the package. It does not define its own API surface; instead, it re-exports the individual modules so downstream clients can import a single module name. + +## What It Re-Exports + +Importing `XCSKit` makes the public APIs of these modules available: + +- `XCSCore` +- `XCSParser` +- `XCSValidator` +- `XCSTranslator` +- `XCSWriter` +- `XCSAnalytics` + +This is implemented with `@_exported import` statements in the umbrella target. + +## Basic Usage + +```swift +import Foundation +import XCSKit + +let data = try Data(contentsOf: catalogURL) +let catalog = try XCSParser.parse(data: data) + +let findings = XCSValidator.validate(catalog) +let stats = XCSAnalytics.statistics(in: catalog) +``` + +## When To Use `XCSKit` + +Use the umbrella import when: + +- you want convenience over minimal imports +- you are building an app or CLI that uses several package layers together +- you do not need strict compile-time separation between parser, validator, translator, writer, and analytics concerns + +Prefer importing individual modules when you want tighter dependency boundaries or a smaller visible API surface in each file. \ No newline at end of file diff --git a/docs/modules/XCSParser.md b/docs/modules/XCSParser.md new file mode 100644 index 0000000..968ef56 --- /dev/null +++ b/docs/modules/XCSParser.md @@ -0,0 +1,72 @@ +# XCSParser + +`XCSParser` converts raw `.xcstrings` JSON into the shared `XCSCore` model. It performs structural decoding and reports deterministic typed errors for malformed input. + +## Public API + +### Parse Entry Points + +```swift +public static func parse(fileURL: URL) throws -> Catalog +public static func parse(data: Data) throws -> Catalog +``` + +### Exported Model Aliases + +`XCSParser` exposes the shared model types as type aliases so parse-only consumers can stay inside one import: + +- `XCSParser.Catalog` +- `XCSParser.Entry` +- `XCSParser.ExtractionState` +- `XCSParser.Localization` +- `XCSParser.StringUnit` +- `XCSParser.VariationBranch` +- `XCSParser.VariationNode` + +### `ParseError` + +Possible parse failures: + +- `fileReadFailed(path:)`: the requested file could not be read +- `invalidJSON`: the input was not valid JSON +- `missingRequiredField(_)`: a required field such as `sourceLanguage` or `strings` was absent +- `invalidFieldType(path:expected:actual:)`: a recognized field had the wrong JSON type + +## Basic Usage + +```swift +import Foundation +import XCSParser + +let data = try Data(contentsOf: catalogURL) +let catalog = try XCSParser.parse(data: data) + +let addEntry = catalog.entry(for: "Add") +let sourceLanguage = catalog.sourceLanguage +``` + +Parsing from disk: + +```swift +let catalog = try XCSParser.parse(fileURL: catalogURL) +``` + +## Error Handling + +```swift +do { + let catalog = try XCSParser.parse(fileURL: catalogURL) + print(catalog.sourceLanguage) +} catch let error as XCSParser.ParseError { + print(error) +} +``` + +## Behavior Notes + +- Parsed models are the same shared `XCSCore` types used by validator, translator, writer, and analytics layers. +- Placeholder tokens and localized values are preserved exactly as encoded. +- Unknown object fields are ignored instead of causing parse failure. +- Structural validation is strict for known fields and known containers. + +If you need semantic checks after parsing, use [XCSValidator](./XCSValidator.md) on the returned `Catalog`. \ No newline at end of file diff --git a/docs/modules/XCSTranslator.md b/docs/modules/XCSTranslator.md new file mode 100644 index 0000000..ca7e210 --- /dev/null +++ b/docs/modules/XCSTranslator.md @@ -0,0 +1,171 @@ +# XCSTranslator + +`XCSTranslator` generates `StringPatch` values by delegating actual translation work to a caller-supplied provider. It does not write files directly. + +## Public API + +### Provider Protocol + +```swift +public protocol XCSTranslationProvider: Sendable { + func translate(_ request: XCSTranslationRequest) async throws -> XCSTranslationResponse +} +``` + +### Request and Response Types + +`XCSTranslationRequest` contains: + +- `entryKey` +- `sourceLocale` +- `targetLocale` +- `sourceValue` +- `path` + +`XCSTranslationResponse` contains: + +- `value` +- `state` with default `translated` + +### Leaf Selection + +`XCSTranslationLeafSelection` controls which source leaves are eligible: + +- `.all`: every direct and variant leaf +- `.exactPath([VariationSelection])`: one exact direct or variant path +- `.branchScope(dimension:prefix:)`: every leaf under one variation dimension, optionally below a nested prefix + +Convenience constructors: + +```swift +XCSTranslationLeafSelection.exact(path) +XCSTranslationLeafSelection.branch(dimension: "plural") +``` + +### Overwrite Policy + +`XCSTranslationOverwritePolicy` controls whether existing target leaves are skipped or replaced: + +- `.fillMissingOnly` +- `.overwriteExisting` + +### Result Type + +`XCSTranslationResult` is a simple wrapper around the generated `patches: [StringPatch]`. + +### Translation Entry Points + +`XCSTranslator` exposes three async entry points: + +```swift +public static func translate( + _ catalog: Catalog, + targetLocales: [String], + leafSelection: XCSTranslationLeafSelection = .all, + overwritePolicy: XCSTranslationOverwritePolicy = .fillMissingOnly, + provider: Provider +) async throws -> XCSTranslationResult +``` + +```swift +public static func translate( + _ catalog: Catalog, + entryKey: String, + targetLocales: [String], + leafSelection: XCSTranslationLeafSelection = .all, + overwritePolicy: XCSTranslationOverwritePolicy = .fillMissingOnly, + provider: Provider +) async throws -> XCSTranslationResult +``` + +```swift +public static func translate( + _ catalog: Catalog, + entryKeys: [String], + targetLocales: [String], + leafSelection: XCSTranslationLeafSelection = .all, + overwritePolicy: XCSTranslationOverwritePolicy = .fillMissingOnly, + provider: Provider +) async throws -> XCSTranslationResult +``` + +## Basic Usage + +```swift +import XCSTranslator + +struct MyTranslationProvider: XCSTranslationProvider { + func translate(_ request: XCSTranslationRequest) async throws -> XCSTranslationResponse { + XCSTranslationResponse(value: "[\(request.targetLocale)] \(request.sourceValue)") + } +} + +let result = try await XCSTranslator.translate( + catalog, + targetLocales: ["fr", "de"], + provider: MyTranslationProvider() +) + +let patches = result.patches +``` + +Single-entry translation: + +```swift +let result = try await XCSTranslator.translate( + catalog, + entryKey: "Add", + targetLocales: ["fr"], + provider: MyTranslationProvider() +) +``` + +Batch translation: + +```swift +let result = try await XCSTranslator.translate( + catalog, + entryKeys: ["Add", "Save"], + targetLocales: ["fr"], + provider: MyTranslationProvider() +) +``` + +## Leaf-Scoped Translation + +Translate exactly one leaf: + +```swift +let result = try await XCSTranslator.translate( + catalog, + entryKey: "%lld items", + targetLocales: ["fr"], + leafSelection: .exact([ + VariationSelection(dimension: "plural", selector: "one") + ]), + provider: MyTranslationProvider() +) +``` + +Translate a full variation branch: + +```swift +let result = try await XCSTranslator.translate( + catalog, + entryKey: "Tap to see", + targetLocales: ["fr"], + leafSelection: .branch(dimension: "device"), + provider: MyTranslationProvider() +) +``` + +Use `.exact([])` if you want to translate only the direct `stringUnit` leaf for an entry. + +## Behavior Notes + +- Target locales are normalized by removing duplicates and excluding the source locale. +- Batch entry keys are deduplicated and filtered to keys that exist in the catalog. +- Entries with `shouldTranslate == false` are skipped. +- If an entry has no explicit source localization, the translator falls back to the entry key as a direct source leaf. +- Variant leaf paths are preserved exactly in generated `StringPatch` values. +- The translator only returns patches; pair it with [XCSWriter](./XCSWriter.md) to persist them. \ No newline at end of file diff --git a/docs/modules/XCSValidator.md b/docs/modules/XCSValidator.md new file mode 100644 index 0000000..2963c61 --- /dev/null +++ b/docs/modules/XCSValidator.md @@ -0,0 +1,70 @@ +# XCSValidator + +`XCSValidator` performs read-only semantic analysis on a parsed catalog and returns structured findings. It never mutates the `Catalog` it receives. + +## Public API + +### `ValidationSeverity` + +- `info` +- `warning` +- `error` + +### `ValidationCategory` + +- `missingLocalizations` +- `missingSourceLocalization` +- `incompleteLocalization` +- `untranslatedValue` +- `needsReview` +- `placeholderMismatch` + +### `ValidationFinding` + +Each finding includes: + +- `severity` +- `category` +- `message` +- `path` +- `entryKey` +- `locale` + +### Validation Entry Point + +```swift +public static func validate(_ catalog: Catalog) -> [ValidationFinding] +``` + +## Basic Usage + +```swift +import XCSParser +import XCSValidator + +let catalog = try XCSParser.parse(fileURL: catalogURL) +let findings = XCSValidator.validate(catalog) + +for finding in findings { + print("\(finding.severity.rawValue): \(finding.path): \(finding.message)") +} +``` + +## What The Validator Checks + +Current validation covers: + +- entries with no localizations at all +- entries missing a source-language localization +- localizations that contain neither a direct leaf nor any variation leaf +- non-source leaves marked `needs_review` +- non-source leaves that exist but are not `translated` +- placeholder mismatches between source leaves and matching target leaves + +## Behavior Notes + +- Findings are sorted deterministically by path, category, and locale. +- Placeholder checking applies to direct leaves and matching variant leaves. +- The validator does not infer fixes, rewrite the catalog, or call external services. + +If you want to act on translation gaps after validation, pair this module with [XCSTranslator](./XCSTranslator.md) and [XCSWriter](./XCSWriter.md). \ No newline at end of file diff --git a/docs/modules/XCSWriter.md b/docs/modules/XCSWriter.md new file mode 100644 index 0000000..d52d9af --- /dev/null +++ b/docs/modules/XCSWriter.md @@ -0,0 +1,77 @@ +# XCSWriter + +`XCSWriter` handles the persistence side of the package. It can serialize a typed `Catalog`, apply targeted `StringPatch` updates to existing JSON bytes, and write the result to disk. + +## Public API + +### `XCSWriterError` + +- `invalidJSON` +- `invalidCatalogShape(path:)` +- `writeFailed(path:)` + +### Serialization + +```swift +public static func serialize( + _ catalog: Catalog, + prettyPrinted: Bool = true, + sortedKeys: Bool = true +) throws -> Data +``` + +### Patch Application + +```swift +public static func apply( + patches: [StringPatch], + to originalData: Data, + prettyPrinted: Bool = true, + sortedKeys: Bool = true +) throws -> Data +``` + +### File Writing + +```swift +public static func write(_ data: Data, to fileURL: URL) throws +public static func write( + _ catalog: Catalog, + to fileURL: URL, + prettyPrinted: Bool = true, + sortedKeys: Bool = true +) throws +``` + +## Basic Usage + +Serialize a typed catalog: + +```swift +import XCSWriter + +let data = try XCSWriter.serialize(catalog) +try XCSWriter.write(data, to: outputURL) +``` + +Apply patches returned by the translator: + +```swift +let updatedData = try XCSWriter.apply(patches: result.patches, to: originalData) +try XCSWriter.write(updatedData, to: outputURL) +``` + +Write a typed catalog directly: + +```swift +try XCSWriter.write(catalog, to: outputURL) +``` + +## Behavior Notes + +- `prettyPrinted` and `sortedKeys` default to `true` for stable, readable output. +- Patch application preserves existing sibling fields and only updates the targeted path. +- Missing entry, localization, and variation containers are synthesized automatically during patch application. +- `apply` expects the original data to be valid JSON with a catalog-like root object containing `strings`. + +If you are starting from a parsed catalog and want to persist translation updates, the common pairing is [XCSTranslator](./XCSTranslator.md) plus `XCSWriter.apply`. \ No newline at end of file