diff --git a/Sources/stringray/Commands/Command+Extensions.swift b/Sources/stringray/Commands/Command+Extensions.swift deleted file mode 100644 index a3b8d6c..0000000 --- a/Sources/stringray/Commands/Command+Extensions.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Command+Extensions.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-04. -// - -import Foundation -import Utility -import CommandRegistry - -extension Command { - func write(to url: Foundation.URL, table: StringsTable) throws { - for (languageId, languageEntries) in table.entries where !languageEntries.isEmpty { - let fileURL = try url.stringsURL(tableName: table.name, locale: languageId) - guard let outputStream = OutputStream(url: fileURL, append: false) else { continue } - outputStream.open() - var firstEntry = true - for entry in languageEntries { - if !firstEntry { - outputStream.write(string: "\n") - } - firstEntry = false - outputStream.write(string: "\(entry)\n") - } - outputStream.close() - } - - for (languageId, languageEntries) in table.dictEntries where !languageEntries.isEmpty { - let fileURL = try url.stringsDictURL(tableName: table.name, locale: languageId) - let encoder = PropertyListEncoder() - encoder.outputFormat = .xml - let data = try encoder.encode(languageEntries) - try data.write(to: fileURL, options: [.atomic]) - } - } -} - -private extension OutputStream { - func write(string: String) { - let encodedDataArray = [UInt8](string.utf8) - write(encodedDataArray, maxLength: encodedDataArray.count) - } -} diff --git a/Sources/stringray/Commands/CopyCommand.swift b/Sources/stringray/Commands/CopyCommand.swift index fd0a81d..2c46355 100644 --- a/Sources/stringray/Commands/CopyCommand.swift +++ b/Sources/stringray/Commands/CopyCommand.swift @@ -54,6 +54,6 @@ struct CopyCommand: Command { let filteredTable = fromTable.withKeys(matching: matching) toTable.addEntries(from: filteredTable) - try write(to: to.resourceDirectory, table: toTable) + try loader.write(to: to.resourceDirectory, table: toTable) } } diff --git a/Sources/stringray/Commands/LintCommand.swift b/Sources/stringray/Commands/LintCommand.swift index d345750..302bb8f 100644 --- a/Sources/stringray/Commands/LintCommand.swift +++ b/Sources/stringray/Commands/LintCommand.swift @@ -159,10 +159,20 @@ struct LintCommand: Command { var loader = StringsTableLoader() loader.options = [.lineNumbers] let linter = Linter(reporter: reporter, config: config) + var allError = Linter.Error([]) + try inputs.forEach { print("Linting: \($0.tableName)") let table = try loader.load(url: $0.resourceURL, name: $0.tableName, base: $0.locale) - try linter.report(on: table, url: $0.resourceURL) + do { + try linter.report(on: table, url: $0.resourceURL) + try loader.writeCache(table: table, baseURL: $0.resourceURL) + } catch let error as Linter.Error { + allError.violations.append(contentsOf: error.violations) + } + } + if !allError.violations.isEmpty { + throw allError } } } diff --git a/Sources/stringray/Commands/MoveCommand.swift b/Sources/stringray/Commands/MoveCommand.swift index 23fb359..7119996 100644 --- a/Sources/stringray/Commands/MoveCommand.swift +++ b/Sources/stringray/Commands/MoveCommand.swift @@ -55,7 +55,7 @@ struct MoveCommand: Command { let filteredTable = fromTable.withKeys(matching: matching) toTable.addEntries(from: filteredTable) fromTable.removeEntries(from: filteredTable) - try write(to: to.resourceDirectory, table: toTable) - try write(to: from.resourceDirectory, table: fromTable) + try loader.write(to: to.resourceDirectory, table: toTable) + try loader.write(to: from.resourceDirectory, table: fromTable) } } diff --git a/Sources/stringray/Commands/RenameCommand.swift b/Sources/stringray/Commands/RenameCommand.swift index 25c4499..164bb0a 100644 --- a/Sources/stringray/Commands/RenameCommand.swift +++ b/Sources/stringray/Commands/RenameCommand.swift @@ -49,8 +49,9 @@ struct RenameCommand: Command { } private func rename(url: Foundation.URL, matching: [Match], replacements replacementStrings: [String]) throws { - var table = try StringsTableLoader().load(url: url) + let loader = StringsTableLoader() + var table = try loader.load(url: url) table.replace(matches: matching, replacements: replacementStrings) - try write(to: url.resourceDirectory, table: table) + try loader.write(to: url.resourceDirectory, table: table) } } diff --git a/Sources/stringray/Commands/SortCommand.swift b/Sources/stringray/Commands/SortCommand.swift index 0b58770..42899b6 100644 --- a/Sources/stringray/Commands/SortCommand.swift +++ b/Sources/stringray/Commands/SortCommand.swift @@ -35,8 +35,9 @@ struct SortCommand: Command { } private func sort(url: Foundation.URL) throws { - var table = try StringsTableLoader().load(url: url) + let loader = StringsTableLoader() + var table = try loader.load(url: url) table.sort() - try write(to: url.resourceDirectory, table: table) + try loader.write(to: url.resourceDirectory, table: table) } } diff --git a/Sources/stringray/Lint Rules/Linter.swift b/Sources/stringray/Lint Rules/Linter.swift index cec7f10..99688d8 100644 --- a/Sources/stringray/Lint Rules/Linter.swift +++ b/Sources/stringray/Lint Rules/Linter.swift @@ -50,8 +50,17 @@ struct Linter { MissingPlaceholderLintRule() ] - private enum LintError: Swift.Error { - case violations + struct Error: LocalizedError { + var violations: [LintRuleViolation] + init(_ violations: [LintRuleViolation]) { + self.violations = violations + } + + var errorDescription: String? { + let errorCount = violations.filter { $0.severity == .error }.count + let warningCount = violations.filter { $0.severity == .warning }.count + return "Encountered \(errorCount) errors and \(warningCount) warnings." + } } let rules: [LintRule] @@ -89,7 +98,7 @@ struct Linter { var outputStream = LinterOutputStream(fileHandle: FileHandle.standardOutput) reporter.generateReport(for: violations, to: &outputStream) if !violations.isEmpty { - throw LintError.violations + throw Linter.Error(violations) } } } diff --git a/Sources/stringray/Strings Table/Cache/CachedStringsTable.swift b/Sources/stringray/Strings Table/Cache/CachedStringsTable.swift new file mode 100644 index 0000000..1aa8c85 --- /dev/null +++ b/Sources/stringray/Strings Table/Cache/CachedStringsTable.swift @@ -0,0 +1,57 @@ +// +// CachedStringsTable.swift +// stringray +// +// Created by Geoffrey Foster on 2018-12-12. +// + +import Foundation + +struct CachedStringsTable: Codable { + let stringsTable: StringsTable + let cacheKeys: [String: Date] + + enum LocalizationType { + case strings + case stringsdict + } + + init(stringsTable: StringsTable, cacheKeys: [String: Date]) { + self.stringsTable = stringsTable + self.cacheKeys = cacheKeys + } + + func strings(for locale: Locale) -> OrderedSet? { + return stringsTable.entries[locale] + } + + func stringsDict(for locale: Locale) -> [String: StringsTable.DictEntry]? { + return stringsTable.dictEntries[locale] + } + + func isCacheValid(for locale: Locale, type: LocalizationType, base: URL) -> Bool { + do { + let fileURL: URL + switch type { + case .strings: + fileURL = try base.stringsURL(tableName: stringsTable.name, locale: locale) + case .stringsdict: + fileURL = try base.stringsDictURL(tableName: stringsTable.name, locale: locale) + } + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let modificationDate = attributes[.modificationDate] as? Date else { return false } + return modificationDate == cacheKeys[CachedStringsTable.cacheKey(for: locale, type: type)] + } catch { + return false + } + } + + static func cacheKey(for locale: Locale, type: LocalizationType) -> String { + switch type { + case .strings: + return "\(locale.identifier).strings" + case .stringsdict: + return "\(locale.identifier).stringsdict" + } + } +} diff --git a/Sources/stringray/Strings Table/StringsTable.swift b/Sources/stringray/Strings Table/StringsTable.swift index 18c9e23..72f46b1 100644 --- a/Sources/stringray/Strings Table/StringsTable.swift +++ b/Sources/stringray/Strings Table/StringsTable.swift @@ -8,7 +8,7 @@ import Foundation -struct StringsTable: Codable { +struct StringsTable: Codable { typealias EntriesType = [Locale: OrderedSet] typealias DictEntriesType = [Locale: [String: DictEntry]] diff --git a/Sources/stringray/Strings Table/StringsTableLoader.swift b/Sources/stringray/Strings Table/StringsTableLoader.swift index 766e3e9..cdfdeb1 100644 --- a/Sources/stringray/Strings Table/StringsTableLoader.swift +++ b/Sources/stringray/Strings Table/StringsTableLoader.swift @@ -24,6 +24,7 @@ struct StringsTableLoader { public init(rawValue: UInt) { self.rawValue = rawValue } public static let lineNumbers = Options(rawValue: 1 << 0) + public static let ignoreCached = Options(rawValue: 1 << 1) } var options: Options = [] @@ -40,16 +41,26 @@ struct StringsTableLoader { var entries: StringsTable.EntriesType = [:] var dictEntries: StringsTable.DictEntriesType = [:] + var cached: CachedStringsTable? + if !options.contains(.ignoreCached), let url = cacheURL(for: name), let data = try? Data(contentsOf: url) { + let decoder = PropertyListDecoder() + cached = try? decoder.decode(CachedStringsTable.self, from: data) + } + try url.lprojURLs.forEach { guard let locale = $0.locale else { return } let stringsTableURL = $0.appendingPathComponent(name).appendingPathExtension("strings") - if let reachable = try? stringsTableURL.checkResourceIsReachable(), reachable == true { + if let cached = cached, cached.isCacheValid(for: locale, type: .strings, base: url), let cachedStrings = cached.strings(for: locale) { + entries[locale] = cachedStrings + } else if let reachable = try? stringsTableURL.checkResourceIsReachable(), reachable == true { entries[locale] = try load(from: stringsTableURL, options: options) } let stringsDictTableURL = $0.appendingPathComponent(name).appendingPathExtension("stringsdict") - if let reachable = try? stringsDictTableURL.checkResourceIsReachable(), reachable == true { + if let cached = cached, cached.isCacheValid(for: locale, type: .stringsdict, base: url), let cachedStringsDict = cached.stringsDict(for: locale) { + dictEntries[locale] = cachedStringsDict + } else if let reachable = try? stringsDictTableURL.checkResourceIsReachable(), reachable == true { dictEntries[locale] = try load(from: stringsDictTableURL) } } @@ -57,6 +68,68 @@ struct StringsTableLoader { return StringsTable(name: name, base: base, entries: entries, dictEntries: dictEntries) } + func write(to url: Foundation.URL, table: StringsTable) throws { + for (languageId, languageEntries) in table.entries where !languageEntries.isEmpty { + let fileURL = try url.stringsURL(tableName: table.name, locale: languageId) + guard let outputStream = OutputStream(url: fileURL, append: false) else { continue } + outputStream.open() + var firstEntry = true + for entry in languageEntries { + if !firstEntry { + outputStream.write(string: "\n") + } + firstEntry = false + outputStream.write(string: "\(entry)\n") + } + outputStream.close() + } + + for (languageId, languageEntries) in table.dictEntries where !languageEntries.isEmpty { + let fileURL = try url.stringsDictURL(tableName: table.name, locale: languageId) + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(languageEntries) + try data.write(to: fileURL, options: [.atomic]) + } + } + + func writeCache(table: StringsTable, baseURL: URL) throws { + var cacheKeys: [String: Date] = [:] + + for (languageId, languageEntries) in table.entries where !languageEntries.isEmpty { + let fileURL = try baseURL.stringsURL(tableName: table.name, locale: languageId) + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let modificationDate = attributes[.modificationDate] as? Date else { continue } + cacheKeys[CachedStringsTable.cacheKey(for: languageId, type: .strings)] = modificationDate + } + + for (languageId, languageEntries) in table.dictEntries where !languageEntries.isEmpty { + let fileURL = try baseURL.stringsDictURL(tableName: table.name, locale: languageId) + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let modificationDate = attributes[.modificationDate] as? Date else { continue } + cacheKeys[CachedStringsTable.cacheKey(for: languageId, type: .stringsdict)] = modificationDate + } + + let cachedTable = CachedStringsTable(stringsTable: table, cacheKeys: cacheKeys) + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let cachedData = try encoder.encode(cachedTable) + guard let url = cacheURL(for: table.name) else { return } + try cachedData.write(to: url, options: [.atomic]) + } + + private func cacheURL(for tableName: String) -> Foundation.URL? { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "net.g-Off.stringray" + let filePath = "\(bundleIdentifier)/\(tableName).localization" + guard let cacheURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: URL(fileURLWithPath: filePath), create: true) + else { + return nil + } + let fileURL = URL(fileURLWithPath: filePath, relativeTo: cacheURL) + try! FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + return URL(fileURLWithPath: filePath, relativeTo: cacheURL) + } + private func load(from url: URL, options: Options) throws -> OrderedSet { func lineNumber(scanLocation: Int, newlineLocations: [Int]) -> Int { var lastIndex = 0 @@ -123,3 +196,10 @@ struct StringsTableLoader { return try decoder.decode([String: StringsTable.DictEntry].self, from: data) } } + +private extension OutputStream { + func write(string: String) { + let encodedDataArray = [UInt8](string.utf8) + write(encodedDataArray, maxLength: encodedDataArray.count) + } +}