diff --git a/XCStringsEditor/ContentView.swift b/XCStringsEditor/ContentView.swift index d798f58..decaf8c 100644 --- a/XCStringsEditor/ContentView.swift +++ b/XCStringsEditor/ContentView.swift @@ -12,7 +12,7 @@ import OSLog fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ContentView") struct ActivityIndicatorModifier: ViewModifier { - @Binding var isPresented: Bool + var isPresented: Bool func body(content: Content) -> some View { ZStack { @@ -39,7 +39,7 @@ struct ContentView: View { @State private var nextEditingItem: LocalizeItem? @FocusState private var focusedField: Field? @State private var showConfirmClose: Bool = false - + @State private var collapsedNodes: Set = [] // init() { // #if DEBUG @@ -49,7 +49,7 @@ struct ContentView: View { var body: some View { @Bindable var appModel = appModel - + NavigationStack { Table(selection: $appModel.selected, sortOrder: $appModel.sortOrder) { // Key @@ -57,46 +57,14 @@ struct ContentView: View { keyColumnView(item: item) } - // Source - TableColumn("Default Localization (\(appModel.baseLanguage.code))") { item in + TableColumn("Default Localization (\(appModel.documents.first?.baseLanguage.code ?? ""))") { item in sourceColumnView(item: item) } - + // Translation TableColumn(appModel.currentLanguage.localizedName) { item in - ZStack { - Text(verbatim: item.translation ?? item.sourceString) - .foregroundStyle(item.translation == nil ? .secondary.opacity(0.5) : (item.needsWork ? Color.orange : .primary)) - .opacity(isEditing && item.id == appModel.editingID ? 0.0 : 1.0) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(nil) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .allowsHitTesting(item.children == nil) - .onTapGesture { - onTapTranslation(item: item) - } - - if isEditing && appModel.editingID == item.id { - // Editing TextField - TextField(item.sourceString, text: $translation, axis: .vertical) - .lineLimit(nil) - .focused($focusedField, equals: .translation) - .onSubmit { - focusedField = .table - } - .onAppear { - logger.debug("textfield appear") - - self.translation = item.translation ?? "" - DispatchQueue.main.async { - focusedField = .translation - } - } - } - } + currentColumnView(item: item) } // Reverse Translation @@ -114,16 +82,28 @@ struct ContentView: View { } .width(80) .alignment(.center) - } rows: { - OutlineGroup(appModel.localizeItems, children: \.children) { item in - TableRow(item) - .contextMenu { rowContextMenu(for: item) } + ForEach(appModel.documents) { document in +// OutlineGroup(document.item, children: \.children) { item in +// TableRow(item) +// .contextMenu { rowContextMenu(for: item) } +// } + + // Simulated header with TableRow for each document. + // Used this way because a simple OutlineGroup always appears initially collapsed. + + TableRow(document.item) + if !collapsedNodes.contains(document.item.id) { + OutlineGroup(document.localizeItems, children: \.children) { item in + TableRow(item) + .contextMenu { rowContextMenu(for: item) } + } + } } } .focused($focusedField, equals: .table) .searchable(text: $appModel.searchText) - .navigationTitle(appModel.title ?? "XCStringsEditor") + .navigationTitle(appModel.title) .onAppear { startMonitorKeyboardEvent() @@ -143,7 +123,7 @@ struct ContentView: View { .frame(width: 6, height: 6) } } - + if appModel.languages.isEmpty == false { ToolbarItemGroup(placement: .primaryAction) { Spacer() @@ -210,7 +190,6 @@ struct ContentView: View { .menuIndicator(.hidden) } } - } .toolbarRole(.editor) .onChange(of: appModel.sortOrder, { oldValue, newValue in @@ -219,7 +198,7 @@ struct ContentView: View { .onChange(of: focusedField) { oldValue, newValue in if oldValue == .translation && newValue != .translation { logger.debug("textfield focusout") - + let oldSelected = appModel.selected endEditing() @@ -237,7 +216,7 @@ struct ContentView: View { Button("OK", role: .cancel) {} } message: { Text("API Key must be set in settings to use this function.") - } + } .alert("Confirm Close", isPresented: $showConfirmClose) { Button("Cancel", role: .cancel) {} Button("Discard Changes", role: .destructive) { @@ -249,7 +228,7 @@ struct ContentView: View { } } // NavigationStack - .modifier(ActivityIndicatorModifier(isPresented: $appModel.isLoading)) + .modifier(ActivityIndicatorModifier(isPresented: appModel.isLoading)) } private func endEditing(updateTranslation: Bool = true) { @@ -258,7 +237,9 @@ struct ContentView: View { #endif // update editing item if let editingID = appModel.editingID, updateTranslation == true { - appModel.updateTranslation(for: editingID, with: translation) + for document in appModel.documents { + document.updateTranslation(for: editingID, with: translation) + } } appModel.editingID = nil @@ -279,6 +260,14 @@ struct ContentView: View { private func keyColumnView(item: LocalizeItem) -> some View { HStack { + if let children = item.children, !children.isEmpty { + // Chevron reflecting expanded/collapsed state + Image(systemName: collapsedNodes.contains(item.id) ? "chevron.right" : "chevron.down") + .onTapGesture { + toggleCollapsed(item.id) + } + .foregroundStyle(.secondary) + } Circle() .fill(.blue) .frame(width: 6, height: 6) @@ -299,6 +288,43 @@ struct ContentView: View { .foregroundStyle(item.translateLater || item.shouldTranslate == false ? .secondary : .primary) } + @ViewBuilder + private func currentColumnView(item: LocalizeItem) -> some View { + ZStack { + Text(verbatim: item.translation ?? item.sourceString) + .foregroundStyle(item.translation == nil ? .secondary.opacity(0.5) : (item.needsWork ? Color.orange : .primary)) + .opacity(isEditing && item.id == appModel.editingID ? 0.0 : 1.0) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .allowsHitTesting(item.children == nil) + .onTapGesture { + onTapTranslation(item: item) + } + + if isEditing && appModel.editingID == item.id { + // Editing TextField + TextField(item.sourceString, text: $translation, axis: .vertical) + .lineLimit(nil) + .focused($focusedField, equals: .translation) + .onSubmit { + focusedField = .table + } + .onAppear { + logger.debug("textfield appear") + + self.translation = item.translation ?? "" + DispatchQueue.main.async { + focusedField = .translation + } + } + } + } + } + + @ViewBuilder private func reverseTranslationColumnView(item: LocalizeItem) -> some View { if isReverseTranslationMatch(item) { @@ -326,56 +352,76 @@ struct ContentView: View { .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } - + @ViewBuilder private func rowContextMenu(for item: LocalizeItem) -> some View { let itemIDs = contextMenuItemIDs(itemID: item.id) - + Button("Auto Translate") { Task { - await appModel.translate(ids: itemIDs) + for document in appModel.documents { + await document.translate(ids: itemIDs) + } } } Button("Reverse Translate") { Task { - await appModel.reverseTranslate(ids: itemIDs) + for document in appModel.documents { + await document.reverseTranslate(ids: itemIDs) + } } } - + Divider() - + Button("Mark for Review") { - appModel.markNeedsReview(ids: itemIDs) + for document in appModel.documents { + document.markNeedsReview(ids: itemIDs) + } } Button("Mark as Reviewed") { - appModel.reviewed(ids: itemIDs) + for document in appModel.documents { + document.reviewed(ids: itemIDs) + } } - + Divider() - if appModel.items(with: Array(itemIDs)).allSatisfy({ $0.shouldTranslate == false }) { + if appModel.documents.flatMap({$0.items(with: Array(itemIDs))}).allSatisfy({ $0.shouldTranslate == false }) { Button("Mark for Translation") { - appModel.setShouldTranslate(true, for: itemIDs) + for document in appModel.documents { + document.setShouldTranslate(true, for: itemIDs) + } } } else { Button("Mark as \"Don't Translate\"") { - appModel.setShouldTranslate(false, for: itemIDs) + for document in appModel.documents { + document.setShouldTranslate(false, for: itemIDs) + } } } - + Divider() Button("Mark for Translate Later") { - appModel.markTranslateLater(ids: itemIDs, value: true) + for document in appModel.documents { + document.markTranslateLater(ids: itemIDs, value: true) + } } Button("Unmark Translate Later") { - appModel.markTranslateLater(ids: itemIDs, value: false) + for document in appModel.documents { + document.markTranslateLater(ids: itemIDs, value: false) + } } Button("Mark for Needs Work") { - appModel.markNeedsWork(ids: itemIDs, value: true) + for document in appModel.documents { + document.markNeedsWork(ids: itemIDs, value: true) + } } Button("Unmark Needs Work") { - appModel.markNeedsWork(ids: itemIDs, value: false) + for document in appModel.documents { + document.markNeedsWork(ids: itemIDs, value: false) + } } } @@ -411,8 +457,9 @@ struct ContentView: View { // print(item) // } // #endif - - appModel.save() + for document in appModel.documents { + document.save() + } } private func contextMenuItemIDs(itemID: LocalizeItem.ID) -> Set { @@ -463,6 +510,14 @@ struct ContentView: View { private func isReverseTranslationMatch(_ item: LocalizeItem) -> Bool { return item.reverseTranslation?.uppercased() == item.sourceString.uppercased() } + + private func toggleCollapsed(_ id: LocalizeItem.ID) { + if collapsedNodes.contains(id) { + collapsedNodes.remove(id) + } else { + collapsedNodes.insert(id) + } + } } #Preview { diff --git a/XCStringsEditor/Model/AppModel.swift b/XCStringsEditor/Model/AppModel.swift index 6b520bb..315d944 100644 --- a/XCStringsEditor/Model/AppModel.swift +++ b/XCStringsEditor/Model/AppModel.swift @@ -40,83 +40,277 @@ struct Filter { @Observable class AppModel { - - private(set) var fileURL: URL? - private(set) var title: String? - - private(set) var xcstrings: XCStrings? - private(set) var languages: [Language] = [] - - @ObservationIgnored - private(set) var allLocalizeItems: [LocalizeItem] = [] - private(set) var localizeItems: [LocalizeItem] = [] - var baseLanguage: Language = .english + var title: String { + documents.count == 1 ? documents.first?.title ?? "" : "XCStringEditor" + } + var currentLanguage: Language = .english { didSet { selected.removeAll() - reloadData() - - settings.lastLanguage = currentLanguage.code - if let settingsFileURL { - settings.save(to: settingsFileURL) + for doc in documents { + doc.currentLanguage = currentLanguage } } } + var documents: [DocumentModel] = [] + var selected = Set() var editingID: String? var sortOrder: [KeyPathComparator] = [ .init(\.state, order: SortOrder.forward), .init(\.key, order: SortOrder.forward) ] - var searchText: String = "" { - didSet { - // TODO: debounce - localizeItems = filteredItems() - } - } - var isModified: Bool = false + var forceClose: Bool = false - var canClose: Bool { forceClose || isModified == false } - - var openingFileURL: URL? + var canClose: Bool { documents.allSatisfy(\.canClose) || forceClose} + var isModified: Bool { documents.map(\.isModified).contains(true) } + var isLoading: Bool { documents.map(\.isLoading).contains(true) } + var languages: [Language] { + documents + .map(\.languages) + .reduce(into: Set()) { result, languages in + result = result.union(languages) + } + .map(\.self) + } + + var localizeItems: [LocalizeItem] { + documents.flatMap(\.localizeItems) + } -// private var debouncedSearchText: String = "" -// private var cancellables = Set() var filter: Filter = Filter() { didSet { - localizeItems = filteredItems() + for document in documents { + document.localizeItems = document.filteredItems() + } } } - var translateLaterItemsHidden: Bool = false { + var searchText: String = "" { didSet { - UserDefaults.standard.set(translateLaterItemsHidden, forKey: "TranslateLaterItemsHidden") - localizeItems = filteredItems() + for document in documents { + document.localizeItems = document.filteredItems() + } } } + + var showAPIKeyAlert: Bool = false + var staleItemsHidden: Bool = false { didSet { UserDefaults.standard.set(staleItemsHidden, forKey: "StaleItemsHidden") - localizeItems = filteredItems() + for document in documents { + document.localizeItems = document.filteredItems() + } } } + var dontTranslateItemsHidden: Bool = false { didSet { UserDefaults.standard.set(dontTranslateItemsHidden, forKey: "DontTranslateItemsHidden") - localizeItems = filteredItems() + for document in documents { + document.localizeItems = document.filteredItems() + } + } + } + + var translateLaterItemsHidden: Bool = false { + didSet { + UserDefaults.standard.set(translateLaterItemsHidden, forKey: "TranslateLaterItemsHidden") + for document in documents { + document.localizeItems = document.filteredItems() + } + } + } + + func load(file: URL) { + do { + try loadFile(at: file) + } catch { + print("Failed to load file: \(error)") + } + } + + func loadFile(at file: URL) throws { + let ext = file.pathExtension.lowercased() + + switch ext { + case "xcstrings": + try loadXCString(of: file) + + addToRecent(file: file) + case "xcodeproj": + // Buscar todos los .xcstrings dentro del proyecto o workspace + for xcstringUrl in xcstringsInProject(file) { + try loadXCString(of: xcstringUrl) + } + + addToRecent(file: file) + default: + print("Unsupported file type: \(ext)") + } + } + + func addToRecent(file: URL) { + // Update recent files + var recents = UserDefaults.standard.array(forKey: "RecentFiles") as? [String] ?? [String]() + if let index = recents.firstIndex(where: { $0 == file.path(percentEncoded: false) }) { + recents.remove(at: index) } + recents.append(file.path(percentEncoded: false)) + if recents.count > 15 { + recents.removeFirst(recents.count - 15) + } + UserDefaults.standard.set(recents, forKey: "RecentFiles") } + + func xcstringsInProject(_ xcodeproj: URL) -> [URL] { + let projectFile = xcodeproj.appendingPathComponent("project.pbxproj") + guard let content = try? String(contentsOf: projectFile) else { + print("No se pudo leer project.pbxproj") + return [] + } - var selected = Set() + let pattern = #"path = (.+\.xcstrings);"# + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let nsrange = NSRange(content.startIndex.. URL? { + let fm = FileManager.default + if let enumerator = fm.enumerator(at: directory, includingPropertiesForKeys: nil) { + for case let file as URL in enumerator { + if file.lastPathComponent == named { + return file + } + } + } + return nil + } - var settings: FileSettings! + private func findXCStrings(in directory: URL) -> [URL] { + var results = [URL]() + + func scan(url: URL) { + guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return + } + + for item in contents { + if item.pathExtension.lowercased() == "xcstrings" { + results.append(item) + } else { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDirectory), isDirectory.boolValue { + scan(url: item) + } + } + } + } + + scan(url: directory) + return results + } + + func loadXCString(of file: URL) throws { + let document = DocumentModel(appModel: self) + try document.load(file: file) + document.currentLanguage = currentLanguage + + documents.append(document) + } + + func item(with id: LocalizeItem.ID) -> LocalizeItem? { + for document in documents { + if let item = document.item(with: id) { + return item + } + } + + return nil + } + + func sort(using comparator: [KeyPathComparator]) { + for document in documents { + document.sort(using: comparator) + } + } - var showAPIKeyAlert: Bool = false - var isLoading: Bool = false - var translator = TranslatorFactory.translator init() { - translateLaterItemsHidden = UserDefaults.standard.bool(forKey: "TranslateLaterItemsHidden") staleItemsHidden = UserDefaults.standard.bool(forKey: "StaleItemsHidden") + translateLaterItemsHidden = UserDefaults.standard.bool(forKey: "TranslateLaterItemsHidden") dontTranslateItemsHidden = UserDefaults.standard.bool(forKey: "DontTranslateItemsHidden") + } +} + +@Observable +class DocumentModel: Identifiable { + private let appModel: AppModel + + private(set) var fileURL: URL? + private(set) var title: String? + + private(set) var xcstrings: XCStrings? + private(set) var languages: [Language] = [] + + @ObservationIgnored + private(set) var allLocalizeItems: [LocalizeItem] = [] + var localizeItems: [LocalizeItem] = [] + var baseLanguage: Language = .english + var currentLanguage: Language = .english { + didSet { + reloadData() + + settings.lastLanguage = currentLanguage.code + if let settingsFileURL { + settings.save(to: settingsFileURL) + } + } + } + + var sortOrder: [KeyPathComparator] { appModel.sortOrder } + var searchText: String { appModel.searchText } + var isModified: Bool = false + var canClose: Bool { isModified == false } + + var openingFileURL: URL? + +// private var debouncedSearchText: String = "" +// private var cancellables = Set() + var filter: Filter { + appModel.filter + } + + var item: LocalizeItem { + .init(document: self) + } + + var settings: FileSettings! + + var isLoading: Bool = false + var translator = TranslatorFactory.translator + init(appModel: AppModel) { + self.appModel = appModel // searchText.publisher // .debounce(for: 0.2, scheduler: RunLoop.main) @@ -135,56 +329,39 @@ class AppModel { // .store(in: &cancellables) } - func load(file: URL) { - do { - let data = try Data(contentsOf: file) - let xcstrings = try JSONDecoder().decode(XCStrings.self, from: data) - - print(xcstrings.version, xcstrings.sourceLanguage) - print("string count", xcstrings.strings.count) - - // xcstrings.printStrings() - - self.baseLanguage = xcstrings.sourceLanguage - self.xcstrings = xcstrings - self.languages = languages(in: xcstrings).sorted(using: KeyPathComparator(\.localizedName, order: .forward)) - - self.fileURL = file - if let projectName = self.projectName(for: file) { - self.title = "\(projectName)/\(file.deletingPathExtension().lastPathComponent)" - } else { - self.title = file.deletingPathExtension().lastPathComponent - } - self.settings = loadSettings() - - print("settings file", settingsFileURL!.standardizedFileURL) - print("settings translatelater", settings.translateLater.count) - - self.allLocalizeItems = xcStringsToLocalizeItems(xcstrings: xcstrings, languages: self.languages) - - // Setting currentLanguage triggers reloadData - self.currentLanguage = if let lastLanguage = Language(code: settings.lastLanguage), self.languages.contains(lastLanguage) { - lastLanguage - } else { - self.languages.first! - } - isModified = false - - - // Update recent files - var recents = UserDefaults.standard.array(forKey: "RecentFiles") as? [String] ?? [String]() - if let index = recents.firstIndex(where: { $0 == file.path(percentEncoded: false) }) { - recents.remove(at: index) - } - recents.append(file.path(percentEncoded: false)) - if recents.count > 15 { - recents.removeFirst(recents.count - 15) - } - UserDefaults.standard.set(recents, forKey: "RecentFiles") - - } catch { - print("Failed to load", error) + func load(file: URL) throws { + let data = try Data(contentsOf: file) + let xcstrings = try JSONDecoder().decode(XCStrings.self, from: data) + + print(xcstrings.version, xcstrings.sourceLanguage) + print("string count", xcstrings.strings.count) + + // xcstrings.printStrings() + + self.baseLanguage = xcstrings.sourceLanguage + self.xcstrings = xcstrings + self.languages = languages(in: xcstrings).sorted(using: KeyPathComparator(\.localizedName, order: .forward)) + + self.fileURL = file + if let projectName = self.projectName(for: file) { + self.title = "\(projectName)/\(file.deletingPathExtension().lastPathComponent)" + } else { + self.title = file.deletingPathExtension().lastPathComponent } + self.settings = loadSettings() + + print("settings file", settingsFileURL!.standardizedFileURL) + print("settings translatelater", settings.translateLater.count) + + self.allLocalizeItems = xcStringsToLocalizeItems(xcstrings: xcstrings, languages: self.languages) + + // Setting currentLanguage triggers reloadData + self.currentLanguage = if let lastLanguage = Language(code: settings.lastLanguage), self.languages.contains(lastLanguage) { + lastLanguage + } else { + self.languages.first! + } + isModified = false } func projectName(for url: URL) -> String? { @@ -344,7 +521,7 @@ class AppModel { allLocalizeItems = allItems } - private func filteredItems() -> [LocalizeItem] { + func filteredItems() -> [LocalizeItem] { // TODO: filter sub items if isModified { @@ -356,13 +533,13 @@ class AppModel { return false } - if dontTranslateItemsHidden == true && $0.shouldTranslate == false { + if appModel.dontTranslateItemsHidden == true && $0.shouldTranslate == false { return false } - if translateLaterItemsHidden == true && $0.translateLater { + if appModel.translateLaterItemsHidden == true && $0.translateLater { return false } - if staleItemsHidden == true && $0.isStale { + if appModel.staleItemsHidden == true && $0.isStale { return false } @@ -821,7 +998,7 @@ class AppModel { } func clearTranslation(ids: Set? = nil) { - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -852,7 +1029,7 @@ class AppModel { } isLoading = true - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected // TODO: Allow sending multiple translation requests simultaneously for itemID in itemIDs { @@ -874,7 +1051,7 @@ class AppModel { } catch { // Handle errors during translation if error as? TranslatorError == TranslatorError.invalidAPI { - self.showAPIKeyAlert = true // Notify the user to check API key + self.appModel.showAPIKeyAlert = true // Notify the user to check API key } else { logger.error("Failed to translate. \(error)") } @@ -886,7 +1063,7 @@ class AppModel { func reverseTranslate(ids: Set? = nil) async { isLoading = true - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected // TODO: Allow sending multiple translation requests simultaneously for itemID in itemIDs { @@ -902,7 +1079,7 @@ class AppModel { } catch { if error as? TranslatorError == TranslatorError.invalidAPI { - showAPIKeyAlert = true + appModel.showAPIKeyAlert = true } else { logger.error("Failed to reverse translation. \(error)") } @@ -913,7 +1090,7 @@ class AppModel { } func markNeedsReview(ids: Set? = nil) { - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -927,7 +1104,7 @@ class AppModel { func setShouldTranslate(_ shouldTranslate: Bool, for ids: Set? = nil) { var updatedIDs = [LocalizeItem.ID]() - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -963,7 +1140,7 @@ class AppModel { } func reviewed(ids: Set? = nil) { - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -979,7 +1156,7 @@ class AppModel { /// /// Mark can be done only to root items func markTranslateLater(ids: Set? = nil, value: Bool = true) { - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -1028,7 +1205,7 @@ class AppModel { /// Mark can be done only to root items func markNeedsWork(ids: Set? = nil, value: Bool = true, allLanguages: Bool = false) { var updatedIDs = [LocalizeItem.ID]() - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { updateItem(with: itemID) { item in @@ -1152,7 +1329,7 @@ class AppModel { func copyFromSourceText(ids: Set? = nil) { - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { guard let item = item(with: itemID) else { @@ -1164,7 +1341,7 @@ class AppModel { func copySourceText(ids: Set? = nil) { var lines = [String]() - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { guard let item = item(with: itemID) else { @@ -1174,13 +1351,16 @@ class AppModel { lines.append(item.sourceString) } } + + guard lines.isEmpty == false else { return } + NSPasteboard.general.clearContents() NSPasteboard.general.setString(lines.joined(separator: "\n"), forType: .string) } func copyTranslationText(ids: Set? = nil) { var lines = [String]() - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { guard let item = item(with: itemID) else { @@ -1190,13 +1370,16 @@ class AppModel { lines.append(translation) } } + + guard lines.isEmpty == false else { return } + NSPasteboard.general.clearContents() NSPasteboard.general.setString(lines.joined(separator: "\n"), forType: .string) } func copySourceAndTranslationText(ids: Set? = nil) { var lines = [String]() - let itemIDs = ids ?? self.selected + let itemIDs = ids ?? self.appModel.selected for itemID in itemIDs { guard let item = item(with: itemID) else { @@ -1204,6 +1387,9 @@ class AppModel { } lines.append("\(item.sourceString) = \(item.translation ?? "")") } + + guard lines.isEmpty == false else { return } + NSPasteboard.general.clearContents() NSPasteboard.general.setString(lines.joined(separator: "\n"), forType: .string) } @@ -1273,3 +1459,4 @@ class AppModel { } } + diff --git a/XCStringsEditor/Model/LocalizeItem.swift b/XCStringsEditor/Model/LocalizeItem.swift index aae4a17..c624210 100644 --- a/XCStringsEditor/Model/LocalizeItem.swift +++ b/XCStringsEditor/Model/LocalizeItem.swift @@ -153,6 +153,24 @@ struct LocalizeItem: Identifiable, Hashable, CustomStringConvertible { self.children = children } + init(document: DocumentModel) { + self.init( + id: document.id.debugDescription, + key: document.title ?? "Unnamed Document", + sourceString: "", + language: document.baseLanguage, + needsReview: false, + children: document.localizeItems + ) + } + + func wiht(children: [LocalizeItem]) -> Self { + var result = self + result.children = children + + return result + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } @@ -192,6 +210,9 @@ struct LocalizeItem: Identifiable, Hashable, CustomStringConvertible { /// - Returns: The base ID. static func baseID(_ id: LocalizeItem.ID) -> LocalizeItem.ID { let components = id.components(separatedBy: LocalizeItem.ID_DIVIDER) + guard components.count > 2 else { + return "" + } let key = components[0] let subcomponents = components[1].components(separatedBy: "/") let langCode = subcomponents[0] diff --git a/XCStringsEditor/SettingsView.swift b/XCStringsEditor/SettingsView.swift index 64edec1..a3d4ced 100644 --- a/XCStringsEditor/SettingsView.swift +++ b/XCStringsEditor/SettingsView.swift @@ -43,7 +43,9 @@ struct SettingsView: View { .padding() .frame(width: 500, height: 250) .onChange(of: translateService) { oldValue, newValue in - appModel.translator = TranslatorFactory.translator + for document in appModel.documents { + document.translator = TranslatorFactory.translator + } } } } diff --git a/XCStringsEditor/WelcomeView.swift b/XCStringsEditor/WelcomeView.swift index 4e7f7e0..d4aa8e4 100644 --- a/XCStringsEditor/WelcomeView.swift +++ b/XCStringsEditor/WelcomeView.swift @@ -53,13 +53,6 @@ struct WelcomeView: View { dismissWindow() appModel.load(file: url) - - var recents = UserDefaults.standard.array(forKey: "RecentFiles") as? [String] ?? [String]() - if let index = recents.firstIndex(where: { $0 == url.path(percentEncoded: false) }) { - recents.remove(at: index) - recents.append(url.path(percentEncoded: false)) - UserDefaults.standard.set(recents, forKey: "RecentFiles") - } } label: { HStack { Image(nsImage: NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false))) diff --git a/XCStringsEditor/XCStringEditorApp.swift b/XCStringsEditor/XCStringEditorApp.swift index 50099ba..42fee16 100644 --- a/XCStringsEditor/XCStringEditorApp.swift +++ b/XCStringsEditor/XCStringEditorApp.swift @@ -15,6 +15,7 @@ extension Notification.Name { struct XCStringEditorApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.openWindow) private var openWindow @State private var appModel: AppModel = AppModel() @State private var isDiscardConfirmVisible: Bool = false @@ -28,31 +29,38 @@ struct XCStringEditorApp: App { .environment(appModel) .environment(appDelegate.windowDelegate) .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { newValue in - if let url = appModel.settingsFileURL { - appModel.settings.save(to: url) + for document in appModel.documents { + if let url = document.settingsFileURL { + document.settings.save(to: url) + } } } .onReceive(NotificationCenter.default.publisher(for: .receivedOpenURLsNotification), perform: { newValue in - guard let urls = newValue.userInfo?["urls"] as? [URL], let url = urls.first else { + guard let urls = newValue.userInfo?["urls"] as? [URL] else { return } - - openURL(url) + for url in urls { + openURL(url) + } }) .confirmationDialog("Unsaved Changes Detected", isPresented: $isDiscardConfirmVisible) { Button("Save and Open", role: .none) { - guard let url = appModel.openingFileURL else { - return + for document in appModel.documents { + guard let url = document.openingFileURL else { + return + } + document.save() + try? document.load(file: url) } - appModel.save() - appModel.load(file: url) } Button("Discard and Open", role: .destructive) { - guard let url = appModel.openingFileURL else { - return + for document in appModel.documents { + guard let url = document.openingFileURL else { + return + } + try? document.load(file: url) } - appModel.load(file: url) } Button("Cancel", role: .cancel) { @@ -76,13 +84,6 @@ struct XCStringEditorApp: App { ForEach(recents.reversed(), id: \.self) { url in Button { appModel.load(file: url) - - var recents = UserDefaults.standard.array(forKey: "RecentFiles") as? [String] ?? [String]() - if let index = recents.firstIndex(where: { $0 == url.path(percentEncoded: false) }) { - recents.remove(at: index) - recents.append(url.path(percentEncoded: false)) - UserDefaults.standard.set(recents, forKey: "RecentFiles") - } } label: { HStack { Image(nsImage: NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false))) @@ -100,26 +101,34 @@ struct XCStringEditorApp: App { Divider() Button("Save") { - appModel.save() + for document in appModel.documents { + document.save() + } } .keyboardShortcut("s", modifiers: [.command]) // Cmd + S - .disabled(appModel.fileURL == nil) +// .disabled(appModel.fileURL == nil) } CommandGroup(after: .pasteboard) { Button("Copy Source Text") { - appModel.copySourceText() + for document in appModel.documents { + document.copySourceText() + } } .keyboardShortcut("c", modifiers: [.command, .control]) // Cmd + Control + C .disabled(appModel.selected.isEmpty) Button("Copy Translation") { - appModel.copyTranslationText() + for document in appModel.documents { + document.copyTranslationText() + } } .keyboardShortcut("c", modifiers: [.command, .option]) // Cmd + Option + C .disabled(appModel.selected.isEmpty) Button("Copy Source and Translation Text") { - appModel.copySourceAndTranslationText() + for document in appModel.documents { + document.copySourceAndTranslationText() + } } .keyboardShortcut("c", modifiers: [.command, .option, .control]) // Cmd + Option + Control + C .disabled(appModel.selected.isEmpty) @@ -127,13 +136,17 @@ struct XCStringEditorApp: App { Divider() // ------------------------ Button("Clear Translation") { - appModel.clearTranslation() + for document in appModel.documents { + document.clearTranslation() + } } .keyboardShortcut("e", modifiers: [.command]) // Cmd + E .disabled(appModel.selected.isEmpty) Button("Copy from Source Text") { - appModel.copyFromSourceText() + for document in appModel.documents { + document.copyFromSourceText() + } } .keyboardShortcut("d", modifiers: [.command]) // Cmd + D .disabled(appModel.selected.isEmpty) @@ -141,21 +154,29 @@ struct XCStringEditorApp: App { Divider() // ------------------------ Button("Mark for Review") { - appModel.markNeedsReview() + for document in appModel.documents { + document.markNeedsReview() + } } .disabled(appModel.selected.isEmpty) Button("Mark as Reviewed") { - appModel.reviewed() + for document in appModel.documents { + document.reviewed() + } } .disabled(appModel.selected.isEmpty) - if appModel.selected.isEmpty == false && appModel.items(with: Array(appModel.selected)).allSatisfy({ $0.shouldTranslate == false }) { + if appModel.selected.isEmpty == false && appModel.documents.flatMap({ $0.items(with: Array(appModel.selected))}).allSatisfy({ $0.shouldTranslate == false }) { Button("Mark for Translation") { - appModel.setShouldTranslate(true) + for document in appModel.documents { + document.setShouldTranslate(true) + } } } else { Button("Mark as \"Don't Translate\"") { - appModel.setShouldTranslate(false) + for document in appModel.documents { + document.setShouldTranslate(false) + } } .disabled(appModel.selected.isEmpty) } @@ -163,35 +184,47 @@ struct XCStringEditorApp: App { Divider() Button("Mark for Translate Later") { - appModel.markTranslateLater(value: true) + for document in appModel.documents { + document.markTranslateLater(value: true) + } } .keyboardShortcut("l", modifiers: [.command]) // Cmd + L .disabled(appModel.selected.isEmpty) Button("Unmark Translate Later") { - appModel.markTranslateLater(value: false) + for document in appModel.documents { + document.markTranslateLater(value: false) + } } .keyboardShortcut("l", modifiers: [.shift, .command]) // Cmd + Shift + L .disabled(appModel.selected.isEmpty) Button("Mark for Needs Work") { - appModel.markNeedsWork(value: true) + for document in appModel.documents { + document.markNeedsWork(value: true) + } } .keyboardShortcut("w", modifiers: [.control, .command]) // Cmd + Control + W .disabled(appModel.selected.isEmpty) Button("Mark for Needs Work for All Languages") { - appModel.markNeedsWork(value: true, allLanguages: true) + for document in appModel.documents { + document.markNeedsWork(value: true, allLanguages: true) + } } .disabled(appModel.selected.isEmpty) .keyboardShortcut("w", modifiers: [.control, .option, .command]) // Cmd + Option + Control + W Button("Clear Needs Work for All Languages") { - appModel.clearNeedsWork(allLanguages: true) + for document in appModel.documents { + document.clearNeedsWork(allLanguages: true) + } } Button("Unmark Needs Work") { - appModel.markNeedsWork(value: false) + for document in appModel.documents { + document.markNeedsWork(value: false) + } } .keyboardShortcut("w", modifiers: [.control, .shift, .command]) // Cmd + Shift + Control + W .disabled(appModel.selected.isEmpty) @@ -200,7 +233,9 @@ struct XCStringEditorApp: App { Button("Auto Translate") { Task { - await appModel.translate() + for document in appModel.documents { + await document.translate() + } } } .keyboardShortcut("t", modifiers: [.command, .option]) // Cmd + Option + T @@ -208,14 +243,18 @@ struct XCStringEditorApp: App { Button("Reverse Translate") { Task { - await appModel.reverseTranslate() + for document in appModel.documents { + await document.reverseTranslate() + } } } .keyboardShortcut("t", modifiers: [.shift, .option, .command]) // Cmd + Option + Shift + T .disabled(appModel.selected.isEmpty) Button("Check Translation") { - appModel.detectLanguage() + for document in appModel.documents { + document.detectLanguage() + } } .disabled(true) //stringsModel.selected.isEmpty) } @@ -279,11 +318,8 @@ extension XCStringEditorApp { } private func openURL(_ url: URL) { - if appModel.isModified == false { - appModel.load(file: url) - } else { - appModel.openingFileURL = url - isDiscardConfirmVisible = true - } + appModel.load(file: url) + + openWindow(id: "main") } }