├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── screenshots └── editor.png └── sources ├── .swiftlint.yml ├── LocalizationEditor.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── LocalizationEditor.xcscheme ├── LocalizationEditor ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128@1x.png │ │ ├── icon_128@2x.png │ │ ├── icon_16@1x.png │ │ ├── icon_16@2x.png │ │ ├── icon_256@1x.png │ │ ├── icon_256@2x.png │ │ ├── icon_32@1x.png │ │ ├── icon_32@2x.png │ │ ├── icon_512x512@1x.png │ │ └── icon_512x512@2x.png │ └── Contents.json ├── Credits.rtf ├── Extensions │ ├── FileManager+Extension.swift │ ├── NSView+Localization.swift │ └── String+Extensions.swift ├── Info.plist ├── LocalizationEditor.entitlements ├── Models │ ├── Flag.swift │ ├── Localization.swift │ ├── LocalizationGroup.swift │ └── LocalizationString.swift ├── Providers │ ├── LocalizationProvider.swift │ ├── LocalizationsDataSource.swift │ ├── Parser.swift │ └── ParserTypes.swift ├── Resources │ ├── de.lproj │ │ └── Localizable.strings │ ├── en.lproj │ │ └── Localizable.strings │ ├── es.lproj │ │ └── Localizable.strings │ ├── hr.lproj │ │ └── Localizable.strings │ ├── ja.lproj │ │ └── Localizable.strings │ ├── ru.lproj │ │ └── Localizable.strings │ ├── zh-Hans.lproj │ │ └── Localizable.strings │ └── zh-Hant.lproj │ │ └── Localizable.strings └── UI │ ├── AddViewController.swift │ ├── Base.lproj │ └── Main.storyboard │ ├── Cells │ ├── ActionsCell.swift │ ├── ActionsCell.xib │ ├── KeyCell.swift │ ├── KeyCell.xib │ ├── LocalizationCell.swift │ └── LocalizationCell.xib │ ├── ViewController.swift │ └── WindowController.swift └── LocalizationEditorTests ├── Data ├── InfoPList-en.strings ├── InfoPList-sk.strings ├── LocalizableStrings-en-with-complete-messages.strings ├── LocalizableStrings-en-with-incomplete-messages.strings ├── LocalizableStrings-en.strings ├── LocalizableStrings-sk-missing.strings ├── LocalizableStrings-sk.strings └── Special.strings ├── Extensions └── XCTestCase+Extensions.swift ├── Info.plist ├── LoalizationProviderUpdatingTests.swift ├── LocalizationProviderAddingTests.swift ├── LocalizationProviderDeletingTests.swift ├── LocalizationProviderParsingTests.swift ├── ParserIsolationTests.swift └── TestFile.swift /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @igorkulman -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ['https://www.buymeacoffee.com/igorkulman', 'https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WND2WGRGBLQVE¤cy_code=EUR'] 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macOS-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Install needed software 13 | run: gem install xcpretty 14 | - name: Run tests 15 | run: xcodebuild test -project sources/LocalizationEditor.xcodeproj -scheme LocalizationEditor -destination 'platform=OS X,arch=x86_64' CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcpretty 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Xcode 4 | # 5 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 6 | 7 | ## Build generated 8 | build/ 9 | DerivedData/ 10 | 11 | ## Various settings 12 | *.pbxuser 13 | !default.pbxuser 14 | *.mode1v3 15 | !default.mode1v3 16 | *.mode2v3 17 | !default.mode2v3 18 | *.perspectivev3 19 | !default.perspectivev3 20 | xcuserdata/ 21 | 22 | ## Other 23 | *.moved-aside 24 | *.xccheckout 25 | *.xcscmblueprint 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | .build/ 44 | 45 | # CocoaPods 46 | # 47 | # We recommend against adding the Pods directory to your .gitignore. However 48 | # you should judge for yourself, the pros and cons are mentioned at: 49 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 50 | # 51 | # Pods/ 52 | 53 | # Carthage 54 | # 55 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 56 | # Carthage/Checkouts 57 | 58 | Carthage/Build 59 | 60 | # fastlane 61 | # 62 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 63 | # screenshots whenever they are needed. 64 | # For more information about the recommended setup visit: 65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 66 | 67 | fastlane/report.xml 68 | fastlane/Preview.html 69 | fastlane/screenshots/**/*.png 70 | fastlane/test_output 71 | /Carthage 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at igor@kulman.sk. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to iOSLocalizationEditor 2 | 3 | First off, thank you for considering contributing to Localization Editor. It's people like you that make Localization Editor such a great tool. 4 | 5 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. 6 | 7 | There are many ways to contribute, from submitting bug reports and feature requests to writing code which can be incorporated into the project. 8 | 9 | ## Issues and feature requests 10 | 11 | Feel free to submit issues and feature requests. 12 | 13 | ## Contributing code 14 | 15 | All code should be contributed using a Pull request. Before opening a Pull request it is advisable to first create an issue describing the bug being fixed or the new functionality being added. 16 | 17 | ### Code style 18 | 19 | Make sure you have [SwiftLint](https://github.com/realm/SwiftLint) installed and it does not give you any errors or warnings. SwiftLint is integrated into the build process so there is no need to invoke it manually, just build and run the app. 20 | 21 | ### Tests 22 | 23 | Make sure all the unit tests still pass after your changes. If you add a new functionality to the localization parser or provider, please include a unit test for the new functionality. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Igor Kulman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Localization Editor

6 | 7 | 8 | 9 | Simple macOS editor app to help you manage iOS app localizations by allowing you to edit all the translations side by side, highlighting missing translations 10 | 11 | ![Localization Editor](https://github.com/igorkulman/iOSLocalizationEditor/raw/master/screenshots/editor.png) 12 | 13 | ## Motivation 14 | 15 | Managing localization files (`Localizable.strings`) is a pain, there is no tooling for it. There is no easy way to know what strings are missing or to compare them across languages. 16 | 17 | ## What does this tool do? 18 | 19 | Start the Localization Editor, choose File | Open folder with localization files and point it to the folder where your localization files are stored. The tool finds all the `Localizable.strings`, detects their language and displays your strings side by side as shown on the screenshot above. You can point it to the root of your project but it will take longer to process. 20 | 21 | All the translations are sorted by their key (shown as first column) and you can see and compare them quickly, you can also see missing translations in any language. 22 | 23 | When you change any of the translations the corresponding `Localizable.strings` gets updated. 24 | 25 | ## Installation 26 | 27 | ### Homebrew 28 | 29 | ```bash 30 | brew install --cask localizationeditor 31 | ``` 32 | 33 | ### Manual 34 | 35 | To download and run the app 36 | 37 | - Go to [Releases](https://github.com/igorkulman/iOSLocalizationEditor/releases) and download the built app archive **LocalizationEditor.app.zip** from the latest release 38 | - Unzip **LocalizationEditor.app.zip** 39 | - Right click on the extracted **LocalizationEditor.app** and choose Open (just a double-clicking will show a warning because the app is only signed with a development certificate) 40 | 41 | ## Support the project 42 | 43 | Buy Me A Coffee 44 | 45 | ## Contributing 46 | 47 | All contributions are welcomed, including bug reports and pull requests with new features. Please read [CONTRIBUTING](CONTRIBUTING.md) for more details. 48 | 49 | ### Localizing the app 50 | 51 | The app is currently localized into English and Chinese. If you want to add localization for your language, just translate the [Localizable.strings](https://github.com/igorkulman/iOSLocalizationEditor/blob/master/sources/LocalizationEditor/Resources/en.lproj/Localizable.strings) files. You can use this app to do it! 52 | 53 | ## Author 54 | 55 | - **Igor Kulman** - *Initial work* - igor@kulman.sk 56 | 57 | See also the list of [contributors](https://github.com/igorkulman/iOSLocalizationEditor/contributors) who participated in this project. 58 | 59 | ## License 60 | 61 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 62 | 63 | ## Icon 64 | 65 | App icon created by [@sergeykushner](https://github.com/sergeykushner) 66 | -------------------------------------------------------------------------------- /screenshots/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/screenshots/editor.png -------------------------------------------------------------------------------- /sources/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - LocalizationEditor 3 | 4 | disabled_rules: 5 | - line_length 6 | - force_try 7 | - force_cast 8 | - prohibited_interface_builder 9 | 10 | analyzer_rules: 11 | - unused_import 12 | - unused_declaration 13 | 14 | number_separator: 15 | minimum_length: 7 16 | 17 | opt_in_rules: 18 | - closure_end_indentation 19 | - closure_spacing 20 | - collection_alignment 21 | - contains_over_first_not_nil 22 | - empty_string 23 | - empty_xctest_method 24 | - explicit_init 25 | - fallthrough 26 | - fatal_error_message 27 | - first_where 28 | - identical_operands 29 | - joined_default_parameter 30 | - let_var_whitespace 31 | - literal_expression_end_indentation 32 | - lower_acl_than_parent 33 | - nimble_operator 34 | - number_separator 35 | - object_literal 36 | - operator_usage_whitespace 37 | - overridden_super_call 38 | - pattern_matching_keywords 39 | - private_action 40 | - private_outlet 41 | - prohibited_interface_builder 42 | - prohibited_super_call 43 | - quick_discouraged_call 44 | - quick_discouraged_focused_test 45 | - quick_discouraged_pending_test 46 | - redundant_nil_coalescing 47 | - redundant_type_annotation 48 | - single_test_class 49 | - sorted_first_last 50 | - sorted_imports 51 | - static_operator 52 | - unneeded_parentheses_in_closure_argument 53 | - vertical_parameter_alignment_on_call 54 | - yoda_condition 55 | 56 | function_parameter_count: 57 | warning: 8 58 | error: 10 59 | 60 | large_tuple: 61 | warning: 3 62 | custom_rules: 63 | double_space: # from https://github.com/IBM-Swift/Package-Builder 64 | include: "*.swift" 65 | name: "Double space" 66 | regex: '([a-z,A-Z] \s+)' 67 | message: "Double space between keywords" 68 | match_kinds: keyword 69 | severity: warning 70 | comments_space: # from https://github.com/brandenr/swiftlintconfig 71 | name: "Space After Comment" 72 | regex: '(^ *//\w+)' 73 | message: "There should be a space after //" 74 | severity: warning 75 | empty_line_after_guard: # from https://github.com/brandenr/swiftlintconfig 76 | name: "Empty Line After Guard" 77 | regex: '(^ *guard[ a-zA-Z0-9=?.\(\),> 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sources/LocalizationEditor.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /sources/LocalizationEditor.xcodeproj/xcshareddata/xcschemes/LocalizationEditor.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @NSApplicationMain 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | 14 | // swiftlint:disable private_outlet 15 | @IBOutlet weak var openFolderMenuItem: NSMenuItem! 16 | @IBOutlet weak var reloadMenuItem: NSMenuItem! 17 | // swiftlint:enable private_outlet 18 | 19 | private var editorWindow: NSWindow? { 20 | return NSApp.windows.first(where: { $0.windowController is WindowController }) 21 | } 22 | 23 | func applicationDidFinishLaunching(_: Notification) {} 24 | 25 | func applicationWillTerminate(_: Notification) {} 26 | 27 | func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool { 28 | showEditorWindow() 29 | return true 30 | } 31 | 32 | func application(_ sender: NSApplication, openFile filename: String) -> Bool { 33 | var isDirectory: ObjCBool = false 34 | guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory), 35 | isDirectory.boolValue == true 36 | else { 37 | return false 38 | } 39 | showEditorWindow() 40 | let windowController = (editorWindow?.windowController) as! WindowController 41 | windowController.openFolder(withPath: filename) 42 | return true 43 | } 44 | 45 | private func showEditorWindow() { 46 | guard let editorWindow = editorWindow else { 47 | let mainStoryboard = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: nil) 48 | let editorWindowController = mainStoryboard.instantiateInitialController() as! WindowController 49 | editorWindowController.showWindow(self) 50 | return 51 | } 52 | editorWindow.makeKeyAndOrderFront(nil) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16@1x.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32@1x.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128@1x.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256@1x.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512@1x.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_128@1x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_16@1x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_256@1x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_32@1x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSLocalizationEditor/ea8af79355efb8eeadc5c1f77e13381b53aa2b6d/sources/LocalizationEditor/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /sources/LocalizationEditor/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /sources/LocalizationEditor/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1671\cocoasubrtf500 2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | \paperw12240\paperh15840\margl1440\margr1440\vieww9000\viewh8400\viewkind0 6 | \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\qc\partightenfactor0 7 | 8 | \f0\fs24 \cf0 This project is open source and are contributions are welcomed.\ 9 | \ 10 | Visit {\field{\*\fldinst{HYPERLINK "https://github.com/igorkulman/iOSLocalizationEditor"}}{\fldrslt https://github.com/igorkulman/iOSLocalizationEditor}} for more information or to report a bug or to suggest a new feature.} -------------------------------------------------------------------------------- /sources/LocalizationEditor/Extensions/FileManager+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+Extension.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 01/02/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension FileManager { 12 | func getAllFilesRecursively(url: URL) -> [URL] { 13 | guard let enumerator = FileManager.default.enumerator(atPath: url.path) else { 14 | return [] 15 | } 16 | 17 | return enumerator.compactMap({ element -> URL? in 18 | guard let path = element as? String else { 19 | return nil 20 | } 21 | 22 | return url.appendingPathComponent(path, isDirectory: false) 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Extensions/NSView+Localization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSVIew+Localization.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 24/10/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | // Inspired by https://github.com/PiXeL16/IBLocalizable for iOS 9 | // 10 | 11 | import AppKit 12 | import Foundation 13 | 14 | /** 15 | * Localizable Protocol 16 | */ 17 | protocol Localizable: AnyObject { 18 | /// The property that can be localized for each view, for example in a UILabel its the text, in a UIButton its the title, etc 19 | var localizableProperty: String? { get set } 20 | 21 | /// The localizable string value in the your localizable strings 22 | var localizableString: String { get set } 23 | 24 | /** 25 | Applies the localizable string to the supported view attribute 26 | */ 27 | func applyLocalizableString(_ localizableString: String?) 28 | } 29 | 30 | extension Localizable { 31 | /** 32 | Applies the localizable string to the supported view attribute 33 | 34 | - parameter localizableString: localizable String Value 35 | */ 36 | public func applyLocalizableString(_ localizableString: String?) { 37 | localizableProperty = localizableString?.localized 38 | } 39 | } 40 | 41 | extension NSCell: Localizable { 42 | /// Not implemented in base class 43 | @objc var localizableProperty: String? { 44 | get { 45 | return "" 46 | } 47 | // swiftlint:disable unused_setter_value 48 | set {} 49 | // swiftlint:enable unused_setter_value 50 | } 51 | 52 | /// Applies the localizable string to the localizable field of the supported view 53 | @IBInspectable var localizableString: String { 54 | get { 55 | guard let text = self.localizableProperty else { 56 | return "" 57 | } 58 | return text 59 | } 60 | set { 61 | /** 62 | * Applys the localization to the property 63 | */ 64 | applyLocalizableString(newValue) 65 | } 66 | } 67 | } 68 | 69 | extension NSMenuItem: Localizable { 70 | /// Not implemented in base class 71 | @objc var localizableProperty: String? { 72 | get { 73 | return title 74 | } 75 | set { 76 | title = newValue ?? "" 77 | } 78 | } 79 | 80 | /// Applies the localizable string to the localizable field of the supported view 81 | @IBInspectable var localizableString: String { 82 | get { 83 | guard let text = self.localizableProperty else { 84 | return "" 85 | } 86 | return text 87 | } 88 | set { 89 | /** 90 | * Applys the localization to the property 91 | */ 92 | applyLocalizableString(newValue) 93 | } 94 | } 95 | 96 | func applyLocalizableString(_ localizableString: String?) { 97 | title = localizableString?.localized ?? "" 98 | } 99 | } 100 | 101 | extension NSMenu { 102 | /// Not implemented in base class 103 | @objc var localizableProperty: String? { 104 | get { 105 | return title 106 | } 107 | set { 108 | title = newValue ?? "" 109 | } 110 | } 111 | 112 | /// Applies the localizable string to the localizable field of the supported view 113 | @IBInspectable var localizableString: String { 114 | get { 115 | guard let text = self.localizableProperty else { 116 | return "" 117 | } 118 | return text 119 | } 120 | set { 121 | /** 122 | * Applys the localization to the property 123 | */ 124 | applyLocalizableString(newValue) 125 | } 126 | } 127 | 128 | func applyLocalizableString(_ localizableString: String?) { 129 | title = localizableString?.localized ?? "" 130 | } 131 | } 132 | 133 | extension NSSearchField { 134 | /// Not implemented in base class 135 | @objc var localizableProperty: String? { 136 | get { 137 | return placeholderString 138 | } 139 | set { 140 | placeholderString = newValue ?? "" 141 | } 142 | } 143 | 144 | /// Applies the localizable string to the localizable field of the supported view 145 | @IBInspectable var localizableString: String { 146 | get { 147 | guard let text = self.localizableProperty else { 148 | return "" 149 | } 150 | return text 151 | } 152 | set { 153 | /** 154 | * Applys the localization to the property 155 | */ 156 | applyLocalizableString(newValue) 157 | } 158 | } 159 | 160 | func applyLocalizableString(_ localizableString: String?) { 161 | placeholderString = localizableString?.localized ?? "" 162 | } 163 | } 164 | 165 | extension NSTextFieldCell { 166 | public override var localizableProperty: String? { 167 | get { 168 | return title 169 | } 170 | set { 171 | title = newValue ?? "" 172 | } 173 | } 174 | } 175 | 176 | extension NSButtonCell { 177 | public override var localizableProperty: String? { 178 | get { 179 | return title 180 | } 181 | set { 182 | title = newValue ?? "" 183 | } 184 | } 185 | } 186 | 187 | extension NSPopUpButtonCell { 188 | public override var localizableProperty: String? { 189 | get { 190 | return title 191 | } 192 | set { 193 | title = newValue ?? "" 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 05/02/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | func slice(from fromString: String, to toString: String) -> String? { 13 | return (range(of: fromString)?.upperBound).flatMap { substringFrom in 14 | (range(of: toString, range: substringFrom.. 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDocumentTypes 8 | 9 | 10 | CFBundleTypeName 11 | Localization Folder 12 | CFBundleTypeRole 13 | Editor 14 | LSHandlerRank 15 | Default 16 | LSItemContentTypes 17 | 18 | public.folder 19 | 20 | 21 | 22 | CFBundleExecutable 23 | $(EXECUTABLE_NAME) 24 | CFBundleIconFile 25 | 26 | CFBundleIdentifier 27 | $(PRODUCT_BUNDLE_IDENTIFIER) 28 | CFBundleInfoDictionaryVersion 29 | 6.0 30 | CFBundleName 31 | $(PRODUCT_NAME) 32 | CFBundlePackageType 33 | APPL 34 | CFBundleShortVersionString 35 | $(MARKETING_VERSION) 36 | CFBundleVersion 37 | 324 38 | LSMinimumSystemVersion 39 | $(MACOSX_DEPLOYMENT_TARGET) 40 | NSHumanReadableCopyright 41 | Copyright © 2018-2019 Igor Kulman. All rights reserved. 42 | NSMainStoryboardFile 43 | Main 44 | NSPrincipalClass 45 | NSApplication 46 | 47 | 48 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/LocalizationEditor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Models/Flag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Flag.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 07/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Flag { 12 | private let languageCode: String 13 | 14 | init(languageCode: String) { 15 | self.languageCode = languageCode.uppercased() 16 | } 17 | 18 | var emoji: String { 19 | guard let flag = emojiFlag else { 20 | return languageCode 21 | } 22 | 23 | return "\(flag) \(languageCode)" 24 | } 25 | 26 | private var emojiFlag: String? { 27 | // special cases for zh-Hant and zh-Hans 28 | if languageCode.hasPrefix("ZH-") { 29 | if languageCode.hasSuffix("HK") { 30 | return "🇭🇰" 31 | } else if languageCode.hasSuffix("TW") || languageCode.uppercased().hasSuffix("HANT") { 32 | return "🇹🇼" 33 | } else { 34 | return "🇨🇳" 35 | } 36 | } 37 | 38 | guard languageCode.count == 2 || (languageCode.count == 5 && languageCode.contains("-")) else { 39 | return nil 40 | } 41 | 42 | let parts = languageCode.split(separator: "-") 43 | 44 | // language and country code like en-US 45 | if parts.count == 2 { 46 | let country = parts[1] 47 | return emojiFlag(countryCode: String(country)) 48 | } 49 | 50 | // checking iOS supported languages (https://www.ibabbleon.com/iOS-Language-Codes-ISO-639.html) 51 | let language = String(parts[0]) 52 | 53 | switch language { 54 | case "EN": 55 | return "🇬🇧" 56 | case "FR": 57 | return "🇫🇷" 58 | case "ES": 59 | return "🇪🇸" 60 | case "PT": 61 | return "🇵🇹" 62 | case "IT": 63 | return "🇮🇹" 64 | case "DE": 65 | return "🇩🇪" 66 | case "ZH": 67 | return "🇨🇳" 68 | case "NL": 69 | return "🇳🇱" 70 | case "JA": 71 | return "🇯🇵" 72 | case "VI": 73 | return "🇻🇳" 74 | case "RU": 75 | return "🇷🇺" 76 | case "SV": 77 | return "🇸🇪" 78 | case "DA": 79 | return "🇩🇰" 80 | case "FI": 81 | return "🇫🇮" 82 | case "NB": 83 | return "🇳🇴" 84 | case "TR": 85 | return "🇹🇷" 86 | case "EL": 87 | return "🇬🇷" 88 | case "ID": 89 | return "🇮🇩" 90 | case "MS": 91 | return "🇲🇾" 92 | case "TH": 93 | return "🇹🇭" 94 | case "HI": 95 | return "🇮🇳" 96 | case "HU": 97 | return "🇭🇺" 98 | case "PL": 99 | return "🇵🇱" 100 | case "CS": 101 | return "🇨🇿" 102 | case "SK": 103 | return "🇸🇰" 104 | case "UK": 105 | return "🇺🇦" 106 | case "CA": 107 | return "CA" // no emoji flag 108 | case "RO": 109 | return "🇷🇴" 110 | case "HR": 111 | return "🇭🇷" 112 | case "HE": 113 | return "🇮🇱" 114 | case "AR": 115 | return "🇱🇧" 116 | case "KO": 117 | return "🇰🇷" 118 | default: 119 | return emojiFlag(countryCode: language) 120 | } 121 | } 122 | 123 | private func emojiFlag(countryCode: String) -> String? { 124 | var string = "" 125 | 126 | for unicodeScalar in countryCode.unicodeScalars { 127 | if let scalar = UnicodeScalar(127397 + unicodeScalar.value) { 128 | string.append(String(scalar)) 129 | } 130 | } 131 | 132 | return string.isEmpty ? nil : string 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Models/Localization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Localization.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Complete localization for a single language. Represents a single strings file for a single language 13 | */ 14 | final class Localization { 15 | let language: String 16 | private(set) var translations: [LocalizationString] 17 | let path: String 18 | 19 | init(language: String, translations: [LocalizationString], path: String) { 20 | self.language = language 21 | self.translations = translations 22 | self.path = path 23 | } 24 | 25 | func update(key: String, value: String, message: String?) { 26 | if let object = translations.first(where: { string in 27 | string.message != nil && string.message!.contains("\n ") 28 | }), let index = translations.firstIndex(of: object) { 29 | let setObject = LocalizationString(key: object.key, value: object.value, message: "/*[Header]\(object.message!.replacingOccurrences(of: "/*", with: "").replacingOccurrences(of: "*/", with: ""))*/") 30 | translations.insert(setObject, at: 0) 31 | translations.remove(at: index + 1) 32 | } 33 | if let existing = translations.first(where: { $0.key == key }) { 34 | existing.update(newValue: value) 35 | return 36 | } 37 | let newTranslation = LocalizationString(key: key, value: value, message: message) 38 | translations.append(newTranslation) 39 | } 40 | 41 | func add(key: String, message: String?) -> LocalizationString { 42 | let newTranslation = LocalizationString(key: key, value: "", message: message) 43 | translations.append(newTranslation) 44 | return newTranslation 45 | } 46 | 47 | func remove(key: String) { 48 | translations = translations.filter({ $0.key != key }) 49 | } 50 | } 51 | 52 | // MARK: Description 53 | 54 | extension Localization: CustomStringConvertible { 55 | var description: String { 56 | return language.uppercased() 57 | } 58 | } 59 | 60 | // MARK: Equality 61 | 62 | extension Localization: Equatable { 63 | static func == (lhs: Localization, rhs: Localization) -> Bool { 64 | return lhs.language == rhs.language && lhs.translations == rhs.translations && lhs.path == rhs.path 65 | } 66 | } 67 | 68 | // MARK: Debug description 69 | 70 | extension Localization: CustomDebugStringConvertible { 71 | var debugDescription: String { 72 | return "\(language.uppercased()): \(translations.count) translations (\(path))" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Models/LocalizationGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationGroup.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Florian Agsteiner on 19.06.18. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Group of localizations, like Localizabe.strings, InfoPlist.strings, etc. 13 | */ 14 | final class LocalizationGroup { 15 | let name: String 16 | let path: String 17 | let localizations: [Localization] 18 | 19 | init(name: String, localizations: [Localization], path: String) { 20 | self.name = name 21 | self.localizations = localizations 22 | self.path = path 23 | } 24 | } 25 | 26 | // MARK: Description 27 | 28 | extension LocalizationGroup: CustomStringConvertible { 29 | var description: String { 30 | return name 31 | } 32 | } 33 | 34 | // MARK: Comparison 35 | 36 | extension LocalizationGroup: Comparable { 37 | static func < (lhs: LocalizationGroup, rhs: LocalizationGroup) -> Bool { 38 | return lhs.name < rhs.name 39 | } 40 | 41 | static func == (lhs: LocalizationGroup, rhs: LocalizationGroup) -> Bool { 42 | return lhs.name == rhs.name && lhs.path == rhs.path && lhs.localizations == rhs.localizations 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Models/LocalizationString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationString.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Class representing single localization string in form of key: "value"; as found in strings files 13 | */ 14 | final class LocalizationString { 15 | let key: String 16 | private(set) var value: String 17 | private (set) var message: String? 18 | 19 | init(key: String, value: String, message: String?) { 20 | self.key = key 21 | self.value = value 22 | self.message = message 23 | } 24 | 25 | func update(newValue: String) { 26 | value = newValue 27 | } 28 | } 29 | 30 | // MARK: Description 31 | 32 | extension LocalizationString: CustomStringConvertible { 33 | var description: String { 34 | return "\(key) = \(value)" + (message.map { "/* \($0) */" } ?? "") 35 | } 36 | } 37 | 38 | // MARK: Comparison 39 | 40 | extension LocalizationString: Comparable { 41 | static func < (lhs: LocalizationString, rhs: LocalizationString) -> Bool { 42 | return lhs.key < rhs.key 43 | } 44 | 45 | static func == (lhs: LocalizationString, rhs: LocalizationString) -> Bool { 46 | return lhs.key == rhs.key && lhs.value == rhs.value 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Providers/LocalizationProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationProvider.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | /** 13 | Service for working with the strings files 14 | */ 15 | final class LocalizationProvider { 16 | /** 17 | List of folder that should be ignored when searching for localization files 18 | */ 19 | private let ignoredDirectories: Set = ["Pods/", "Carthage/", "build/", ".framework"] 20 | 21 | // MARK: Actions 22 | 23 | /** 24 | Updates given localization values in given localization file. Basially regenerates the whole localization files changing the given value 25 | 26 | - Parameter localization: localization to update 27 | - Parameter key: localization string key 28 | - Parameter value: new value for the localization string 29 | */ 30 | func updateLocalization(localization: Localization, key: String, with value: String, message: String?) { 31 | if let existing = localization.translations.first(where: { $0.key == key }), existing.value == value, existing.message == message { 32 | os_log("Same value provided for %@, not updating", type: OSLogType.debug, existing.description) 33 | return 34 | } 35 | 36 | os_log("Updating %@ in %@ with Message: %@)", type: OSLogType.debug, key, value, message ?? "No Message.") 37 | 38 | localization.update(key: key, value: value, message: message) 39 | 40 | writeToFile(localization: localization) 41 | } 42 | 43 | /** 44 | Writes given translations to a file at given path 45 | 46 | - Parameter translatins: trabslations to write 47 | - Parameter path: file path 48 | */ 49 | private func writeToFile(localization: Localization) { 50 | let data = localization.translations.map { string -> String in 51 | let stringForMessage: String 52 | if let newMessage = string.message, !newMessage.replacingOccurrences(of: " ", with: "").isEmpty { 53 | stringForMessage = "\n/* \(newMessage) */\n" 54 | } else { 55 | stringForMessage = "" 56 | } 57 | if let originalMessage = string.message, originalMessage.contains("[Header]") { 58 | let topFormat = originalMessage.replacingOccurrences(of: "[Header]", with: "") 59 | let message = string.value.replacingOccurrences(of: topFormat, with: "") 60 | return "\(topFormat)\n\n\"\(string.key)\" = \"\(message.escaped)\";" 61 | } else { 62 | return "\(stringForMessage)\"\(string.key)\" = \"\(string.value.escaped)\";" 63 | } 64 | }.reduce("") { prev, next in 65 | "\(prev)\n\(next)" 66 | }.replacingOccurrences(of: "\n/*", with: "/*") 67 | 68 | do { 69 | try data.write(toFile: localization.path, atomically: false, encoding: .utf8) 70 | os_log("Localization file for %@ updated", type: OSLogType.debug, localization.path) 71 | } catch { 72 | os_log("Writing localization file for %@ failed with %@", type: OSLogType.error, localization.path, error.localizedDescription) 73 | } 74 | } 75 | 76 | /** 77 | Deletes key from given localization 78 | 79 | - Parameter localization: localization to update 80 | - Parameter key: key to delete 81 | */ 82 | func deleteKeyFromLocalization(localization: Localization, key: String) { 83 | localization.remove(key: key) 84 | writeToFile(localization: localization) 85 | } 86 | 87 | /** 88 | Adds new key with a message to given localization 89 | 90 | - Parameter localization: localization to add the data to 91 | - Parameter key: new key to add 92 | - Parameter message: message for the key 93 | 94 | - Returns: new localization string 95 | */ 96 | func addKeyToLocalization(localization: Localization, key: String, message: String?) -> LocalizationString { 97 | let newTranslation = localization.add(key: key, message: message) 98 | writeToFile(localization: localization) 99 | return newTranslation 100 | } 101 | 102 | /** 103 | Finds and constructs localiations for given directory path 104 | 105 | - Parameter url: directory URL to start the search 106 | - Returns: list of localization groups 107 | */ 108 | func getLocalizations(url: URL) -> [LocalizationGroup] { 109 | os_log("Searching %@ for Localizable.strings", type: OSLogType.debug, url.description) 110 | 111 | let localizationFiles = Dictionary(grouping: FileManager.default.getAllFilesRecursively(url: url).filter { file in 112 | file.pathExtension == "strings" && !ignoredDirectories.contains(where: { file.path.contains($0) }) 113 | }, by: { $0.path.components(separatedBy: "/").filter({ !$0.hasSuffix(".lproj") }).joined(separator: "/") }) 114 | 115 | os_log("Found %d localization files", type: OSLogType.info, localizationFiles.count) 116 | 117 | return localizationFiles.map({ path, files in 118 | let name = URL(fileURLWithPath: path).lastPathComponent 119 | return LocalizationGroup(name: name, localizations: files.map({ file in 120 | let parts = file.path.split(separator: "/") 121 | let lang = String(parts[parts.count - 2]).replacingOccurrences(of: ".lproj", with: "") 122 | return Localization(language: lang, translations: getLocalizationStrings(path: file.path), path: file.path) 123 | }).sorted(by: { $0.language < $1.language }), path: path) 124 | }).sorted() 125 | } 126 | 127 | // MARK: Internal implementation 128 | 129 | /** 130 | Reads given strings file and constructs an array of localization strings from it 131 | 132 | - Parameter path: strings file path 133 | - Returns: array of localization strings 134 | */ 135 | private func getLocalizationStrings(path: String) -> [LocalizationString] { 136 | do { 137 | let contentOfFileAsString = try String(contentsOfFile: path) 138 | let parser = Parser(input: contentOfFileAsString) 139 | let localizationStrings = try parser.parse() 140 | os_log("Found %d keys for in %@ using built-in parser.", type: OSLogType.debug, localizationStrings.count, path.description) 141 | return localizationStrings.sorted() 142 | } catch { 143 | // The parser could not parse the input. Fallback to NSDictionary 144 | os_log("Could not parse %@ as String", type: OSLogType.error, path.description) 145 | if let dict = NSDictionary(contentsOfFile: path) as? [String: String] { 146 | var localizationStrings: [LocalizationString] = [] 147 | for (key, value) in dict { 148 | let localizationString = LocalizationString(key: key, value: value, message: nil) 149 | localizationStrings.append(localizationString) 150 | } 151 | os_log("Found %d keys for in %@.", type: OSLogType.debug, localizationStrings.count, path.description) 152 | return localizationStrings.sorted() 153 | } else { 154 | os_log("Could not parse %@ as dictionary.", type: OSLogType.error, path.description) 155 | return [] 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Providers/LocalizationsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationsDataSource.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Foundation 11 | import os 12 | 13 | typealias LocalizationsDataSourceData = ([String], String?, [LocalizationGroup]) 14 | 15 | enum Filter: Int, CaseIterable, CustomStringConvertible { 16 | case all 17 | case missing 18 | 19 | var description: String { 20 | switch self { 21 | case .all: 22 | return "all".localized 23 | case .missing: 24 | return "missing".localized 25 | } 26 | } 27 | } 28 | 29 | /** 30 | Data source for the NSTableView with localizations 31 | */ 32 | final class LocalizationsDataSource: NSObject { 33 | // MARK: - Properties 34 | 35 | private let localizationProvider = LocalizationProvider() 36 | private var localizationGroups: [LocalizationGroup] = [] 37 | private var selectedLocalizationGroup: LocalizationGroup? 38 | private var languagesCount = 0 39 | private var mainLocalization: Localization? 40 | 41 | /** 42 | Dictionary indexed by localization key on the first level and by language on the second level for easier access 43 | */ 44 | private var data: [String: [String: LocalizationString?]] = [:] 45 | 46 | /** 47 | Keys for the consumer. Depend on applied filter. 48 | */ 49 | private var filteredKeys: [String] = [] 50 | 51 | // MARK: - Actions 52 | 53 | /** 54 | Loads data for directory at given path 55 | 56 | - Parameter folder: directory path to start the search 57 | - Parameter onCompletion: callback with data 58 | */ 59 | func load(folder: URL, onCompletion: @escaping (LocalizationsDataSourceData) -> Void) { 60 | DispatchQueue.global(qos: .background).async { 61 | let localizationGroups = self.localizationProvider.getLocalizations(url: folder) 62 | guard localizationGroups.count > 0, let group = localizationGroups.first(where: { $0.name == "Localizable.strings" }) ?? localizationGroups.first else { 63 | os_log("No localization data found", type: OSLogType.error) 64 | DispatchQueue.main.async { 65 | onCompletion(([], nil, [])) 66 | } 67 | return 68 | } 69 | 70 | self.localizationGroups = localizationGroups 71 | let languages = self.select(group: group) 72 | 73 | DispatchQueue.main.async { 74 | onCompletion((languages, group.name, localizationGroups)) 75 | } 76 | } 77 | } 78 | 79 | /** 80 | Selects given localization group, converting its data to a more usable form and returning an array of available languages 81 | 82 | - Parameter group: group to select 83 | - Returns: an array of available languages 84 | */ 85 | private func select(group: LocalizationGroup) -> [String] { 86 | selectedLocalizationGroup = group 87 | 88 | let localizations = group.localizations.sorted(by: { lhs, rhs in 89 | if lhs.language.lowercased() == "base" { 90 | return true 91 | } 92 | 93 | if rhs.language.lowercased() == "base" { 94 | return false 95 | } 96 | 97 | return lhs.translations.count > rhs.translations.count 98 | }) 99 | mainLocalization = localizations.first 100 | languagesCount = localizations.count 101 | 102 | data = [:] 103 | for key in mainLocalization!.translations.map({ $0.key }) { 104 | data[key] = [:] 105 | for localization in localizations { 106 | data[key]![localization.language] = localization.translations.first(where: { $0.key == key }) 107 | } 108 | } 109 | 110 | // making sure filteredKeys are computed 111 | filter(by: Filter.all, searchString: nil) 112 | 113 | return localizations.map({ $0.language }) 114 | } 115 | 116 | /** 117 | Selects given group and gets available languages 118 | 119 | - Parameter group: group name 120 | - Returns: array of languages 121 | */ 122 | func selectGroupAndGetLanguages(for group: String) -> [String] { 123 | let group = localizationGroups.first(where: { $0.name == group })! 124 | let languages = select(group: group) 125 | return languages 126 | } 127 | 128 | /** 129 | Filters the data by given filter and search string. Empty search string means all data us included. 130 | 131 | Filtering is done by setting the filteredKeys property. A key is included if it matches the search string or any of its translations matches. 132 | */ 133 | func filter(by filter: Filter, searchString: String?) { 134 | os_log("Filtering by %@", type: OSLogType.debug, "\(filter)") 135 | 136 | // first use filter, missing translation is a translation that is missing in any language for the given key 137 | let data = filter == .all ? self.data : self.data.filter({ dict in 138 | return dict.value.keys.count != self.languagesCount || !dict.value.values.allSatisfy({ $0?.value.isEmpty == false }) 139 | }) 140 | 141 | // no search string, just use teh filtered data 142 | guard let searchString = searchString, !searchString.isEmpty else { 143 | filteredKeys = data.keys.map({ $0 }).sorted(by: { $0 < $1 }) 144 | return 145 | } 146 | 147 | os_log("Searching for %@", type: OSLogType.debug, searchString) 148 | 149 | var keys: [String] = [] 150 | for (key, value) in data { 151 | // include if key matches (no need to check further) 152 | if key.normalized.contains(searchString.normalized) { 153 | keys.append(key) 154 | continue 155 | } 156 | 157 | // include if any of the translations matches 158 | if value.compactMap({ $0.value }).map({ $0.value }).contains(where: { $0.normalized.contains(searchString.normalized) }) { 159 | keys.append(key) 160 | } 161 | } 162 | 163 | // sorting because the dictionary does not keep the sort 164 | filteredKeys = keys.sorted(by: { $0 < $1 }) 165 | } 166 | 167 | /** 168 | Gets key for specified row 169 | 170 | - Parameter row: row number 171 | - Returns: key if valid 172 | */ 173 | func getKey(row: Int) -> String? { 174 | return row < filteredKeys.count ? filteredKeys[row] : nil 175 | } 176 | 177 | /** 178 | Gets the message for specified row 179 | 180 | - Parameter row: row number 181 | - Returns: message if any 182 | */ 183 | func getMessage(row: Int) -> String? { 184 | guard let key = getKey(row: row), let part = data[key], let languageKey = mainLocalization?.language else { 185 | return nil 186 | } 187 | guard let message = part[languageKey]??.message, !message.contains("\n ") else { 188 | return nil 189 | } 190 | return message 191 | } 192 | 193 | /** 194 | Gets localization for specified language and row. The language should be always valid. The localization might be missing, returning it with empty value in that case 195 | 196 | - Parameter language: language to get the localization for 197 | - Parameter row: row number 198 | - Returns: localization string 199 | */ 200 | func getLocalization(language: String, row: Int) -> LocalizationString { 201 | guard let key = getKey(row: row) else { 202 | // should not happen but you never know 203 | fatalError("No key for given row") 204 | } 205 | 206 | guard let section = data[key], let data = section[language], let localization = data else { 207 | return LocalizationString(key: key, value: "", message: "") 208 | } 209 | 210 | return localization 211 | } 212 | 213 | /** 214 | Updates given localization values in given language 215 | 216 | - Parameter language: language to update 217 | - Parameter key: localization string key 218 | - Parameter value: new value for the localization string 219 | */ 220 | func updateLocalization(language: String, key: String, with value: String, message: String?) { 221 | guard let localization = selectedLocalizationGroup?.localizations.first(where: { $0.language == language }) else { 222 | return 223 | } 224 | localizationProvider.updateLocalization(localization: localization, key: key, with: value, message: message) 225 | } 226 | 227 | /** 228 | Deletes given key from all the localizations 229 | 230 | - Parameter key: key to delete 231 | */ 232 | func deleteLocalization(key: String) { 233 | guard let selectedLocalizationGroup = selectedLocalizationGroup else { 234 | return 235 | } 236 | 237 | selectedLocalizationGroup.localizations.forEach({ localization in 238 | self.localizationProvider.deleteKeyFromLocalization(localization: localization, key: key) 239 | }) 240 | data.removeValue(forKey: key) 241 | } 242 | 243 | /** 244 | Adds new localization key with a message to all the localizations 245 | 246 | - Parameter key: key to add 247 | - Parameter message: message (optional) 248 | */ 249 | func addLocalizationKey(key: String, message: String?) { 250 | guard let selectedLocalizationGroup = selectedLocalizationGroup else { 251 | return 252 | } 253 | 254 | selectedLocalizationGroup.localizations.forEach({ localization in 255 | let newTranslation = localizationProvider.addKeyToLocalization(localization: localization, key: key, message: message) 256 | // If we already created the entry in the data dict, do not overwrite the entry entirely. 257 | // Instead just add the data to the already present entry. 258 | if data[key] != nil { 259 | data[key]?[localization.language] = newTranslation 260 | } else { 261 | data[key] = [localization.language: newTranslation] 262 | } 263 | }) 264 | } 265 | 266 | /** 267 | Returns row number for given key 268 | 269 | - Parameter key: key to check 270 | 271 | - Returns: row number (if any) 272 | */ 273 | func getRowForKey(key: String) -> Int? { 274 | return filteredKeys.firstIndex(of: key) 275 | } 276 | } 277 | 278 | // MARK: - Delegate 279 | 280 | extension LocalizationsDataSource: NSTableViewDataSource { 281 | func numberOfRows(in _: NSTableView) -> Int { 282 | return filteredKeys.count 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Providers/Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Parser.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Andreas Neusüß on 25.12.18. 6 | // Copyright © 2018 Andreas Neusüß. All rights reserved. 7 | // 8 | // swiftlint:disable file_length 9 | 10 | import Foundation 11 | 12 | /** 13 | The Parser is responsible for transferring an input string into an array of model objects. 14 | 15 | The input is given as an argument during initialization. Call ```parse``` to start the process. 16 | 17 | It uses a two-setps approach to accomplish the extraction. In the first step tokens are produced that contain information about the type of information (using a state machine). 18 | In the second step, those tokens are inspected and model objects are constructed. 19 | */ 20 | class Parser { 21 | /// Possible state of the parser. Determines what operations need to be done in the next step. 22 | /// - readingKey The parser is currently reading a key since an opening " is recognized. The following text (until another " is found) must be interpreted as key-token. 23 | /// - readingValue The parser is currently reading a value since an opening " is recognized. The following text (until another " is found) must be interpreted as value-token. 24 | /// - readingMessage The parser is currently reading a message since an opening /* is recognized. The following text (until another */ is found) must be interpreted as message-token. 25 | /// - other The parser needs to decide which token comes next. In this state, the upcoming control character needs to be inspected and the state must be changed accordingly. 26 | fileprivate enum ParserState { 27 | case readingKey 28 | case readingValue 29 | case readingMessage(isSingleLine: Bool) 30 | case other 31 | } 32 | /// The current state of the parser. 33 | fileprivate var state: ParserState = .other 34 | /// The tokens that are produced during the first step. 35 | var tokens: [Token] = .init() 36 | /// The input text from which model information should be extracted from. 37 | fileprivate var input: String 38 | /// The results that are produced by the parser. 39 | fileprivate var results: [LocalizationString] = .init() 40 | /// Init the parser with a given input string. 41 | /// 42 | /// - Parameter input: The input from which model information should be extracted. 43 | init(input: String) { 44 | self.input = input 45 | } 46 | /// Call this function to start the parsing process. Will return the extracted model information or throw an error if the parser could not make any sense from the input. In this case, maybe a fallback to another extraction method should be used. 47 | /// 48 | /// - Returns: The model data. 49 | /// - Throws: A ```ParserError``` when the input string could not be parsed. 50 | func parse() throws -> [LocalizationString] { 51 | try tokenize() 52 | results = try interpretTokens() 53 | return results 54 | } 55 | 56 | /** 57 | This function reads through the input and populates an array of tokens. 58 | 59 | Implemented using a state machine. The state machine depends on ```ParserState```. When in .other, the next control character is used to determine the next state. When reading a key/value/message, upcoming text is interpreted as key/value/message until the corresponding closing control character is found. 60 | Currently, " and friends are escaped by also inspecting the upcoming control character. In Swift 5, String Literals may open the possibility to interpred bachslashed \ as escaping characters. 61 | */ 62 | private func tokenize() throws { 63 | // Iterate through the input until it is cleared. 64 | while !input.isEmpty { 65 | // Actions depend on the current state. 66 | switch state { 67 | case .other: 68 | // Extract the upcoming control character, also switch the current state and append the extracted token, if any. 69 | if let extractedToken = try prepareNextState() { 70 | tokens.append(extractedToken) 71 | } 72 | case .readingKey: 73 | extractAndAppendIfPossible(for: .key(""), until: .quote) 74 | case .readingValue: 75 | extractAndAppendIfPossible(for: .value(""), until: .quote) 76 | case .readingMessage(let isReadingSingleLine): 77 | // If the prior token as also a message, DO NOT append it since the prior message could be a license header. 78 | let endMarker: EnclosingControlCharacters = isReadingSingleLine ? .singleLineMessageClose : .messageBoundaryClose 79 | let currentMessageText = extractText(until: endMarker) 80 | let newToken: Token = .message(currentMessageText) 81 | tokens.append(newToken) 82 | state = .other 83 | } 84 | } 85 | } 86 | /// Extracts text from the input until the end marker is reached. Uses that text to create a new token and appends it to a prior extracted token if possible. In any case it updates the current list of extracted tokens. 87 | /// 88 | /// - Parameters: 89 | /// - token: The type of token that should be created from the text before the end marker. The associated value of the input is ignored. 90 | /// - endMarker: Marks the end of the tokens content. 91 | private func extractAndAppendIfPossible(for token: Token, until endMarker: EnclosingControlCharacters) { 92 | let currentText = extractText(until: endMarker) 93 | let potentialNewToken: Token 94 | switch token { 95 | case .key: 96 | potentialNewToken = .key(currentText) 97 | case .value: 98 | potentialNewToken = .value(currentText) 99 | default: 100 | assertionFailure("Currently, only the .key and .value support joining.") 101 | return 102 | } 103 | // Append to the prior token if possible. 104 | let newToken = tokenByConcatinatingwithPriorToken(potentialNewToken, seperatingString: endMarker.rawValue) 105 | tokens.append(newToken) 106 | // Do not stop reading when a newline or a quote is the next control character. Otherwise an unescaped quote may exclude text from the value. Keep the state unchanged if any other control character follows. 107 | if let nextControlCharacter = findNextControlCharacter(andExtractFromSource: false) { 108 | switch nextControlCharacter { 109 | case SeperatingControlCharacters.newline, EnclosingControlCharacters.singleLineMessageClose, EnclosingControlCharacters.quote: 110 | // Do not change the state and just continue. 111 | return 112 | default: 113 | break 114 | } 115 | } 116 | state = .other 117 | } 118 | /// Call this method when the list of tokens is ready and model object can be created. It will iterate through the tokens and try to map their values into model objects. Whe the mapping failed, an error is thrown. 119 | /// 120 | /// - Returns: The extracted model values. 121 | /// - Throws: In case of an malformatted input or anything unexpected happens, an error is thrown. 122 | private func interpretTokens() throws -> [LocalizationString] { 123 | var currentMessage: String? 124 | var currentKey: String? 125 | var currentValue: String? 126 | var results = [LocalizationString]() 127 | // The token that delimits an entry. 128 | guard let endToken = entriesEndToken(for: tokens) else { 129 | throw ParserError.malformattedInput 130 | } 131 | // Generates a result and appends it to the list of results if possible. 132 | func generateResultIfPossible(from processedToken: Token) { 133 | guard processedToken.isCaseEqual(to: endToken) else { return } 134 | // Done with that line. Check if values are populated and append them to the results. 135 | guard let key = currentKey, let value = currentValue else { 136 | return 137 | } 138 | let correctedMessage = removeLeadingTrailingSpaces(from: currentMessage) 139 | let entry = LocalizationString(key: key, value: value.unescaped, message: correctedMessage) 140 | results.append(entry) 141 | // Reset the properties to be ready for the next line. 142 | currentValue = nil 143 | currentKey = nil 144 | currentMessage = nil 145 | } 146 | // Iterate through the tokens and transform them into model objects. 147 | for token in tokens { 148 | switch token { 149 | case .message(let containedText): 150 | currentMessage = containedText 151 | case .key(let containedText): 152 | currentKey = containedText 153 | case .value(let containedText): 154 | currentValue = containedText 155 | default: 156 | () 157 | } 158 | generateResultIfPossible(from: token) 159 | } 160 | // Throw an execption to indicate that something went wront when tokens are extracted but they could not be transferred into model objects: 161 | if !tokens.isEmpty && results.isEmpty { 162 | throw ParserError.malformattedInput 163 | } 164 | return results 165 | } 166 | /// Determines the token that ends an entry. An entry can either be ended by a semicolon (if no comment was provided or the comment is above the entry) or a comment located at the end of a line. In the second case the `.message` token marks the end of the entry. 167 | /// 168 | /// - Parameter tokens: The tokens that were extracted during tokenization. 169 | /// - Returns: The token that ends an entry. 170 | private func entriesEndToken(for tokens: [Token]) -> Token? { 171 | // Assumption: after the first semicolon comes a new line -> semicolon delimits entry 172 | // After first semicolon comes a message, followed by a new line -> message delimits entry 173 | guard let semicolonIndex = tokens.firstIndex(where: { $0.isCaseEqual(to: .semicolon) }) else { 174 | return nil 175 | } 176 | guard let indexAfterSemicolon = tokens.index(semicolonIndex, offsetBy: 1, limitedBy: tokens.endIndex - 1) else { return nil } 177 | let elementAfterSemicolon = tokens[indexAfterSemicolon] 178 | switch elementAfterSemicolon { 179 | case .newline: 180 | return .semicolon 181 | default: 182 | return elementAfterSemicolon 183 | } 184 | } 185 | /// This function removes leading and trailing spaces from the input. 186 | /// 187 | /// - Parameter input: The string whose leading and trailing spaces should be removed. 188 | /// - Returns: The input string without leading or trailing spaces or nil when the input was also nil. 189 | private func removeLeadingTrailingSpaces(from input: String?) -> String? { 190 | if let cleanedAndReversed = input?.drop(while: { $0 == " " }).reversed().drop(while: { $0 == " " }) { 191 | return String(cleanedAndReversed.reversed()) 192 | } 193 | return nil 194 | } 195 | 196 | /// Returns the first unescaped index where the control character was found in the input string. 197 | /// - Parameters: 198 | /// - control: The enclosing control character whose first appearance should be found. 199 | /// - input: The string to search for the control character in. 200 | /// - escape: The escape character to check for when searching the input string. 201 | /// - Returns: The input's first index where the control character was found. 202 | private func firstUnescapedInstance(of control: EnclosingControlCharacters, in input: String, escape: Character = "\\") -> String.Index? { 203 | let controlString = control.rawValue 204 | 205 | // If the control is a single character in length, then check for the escape character. This allows for value strings to contain an escaped quote. 206 | if controlString.count == 1 { 207 | let controlCharacter = controlString[controlString.startIndex] 208 | var iterator = input.indices.makeIterator() 209 | while let index = iterator.next() { 210 | switch input[index] { 211 | // We've found an unescaped instance of the control character. 212 | case controlCharacter: 213 | return index 214 | // If we find the escape character then we should skip a character. 215 | case escape: 216 | _ = iterator.next() 217 | default: 218 | break 219 | } 220 | } 221 | 222 | return nil 223 | // Otherwise just do a simple substring search. 224 | } else { 225 | return input.index(of: controlString) 226 | } 227 | } 228 | 229 | /// This function finds the index where a given enclosing control character can be found. This index determines where this token may be terminated. 230 | /// 231 | /// - Parameter control: The enclosing control character whose first appearance should be found. 232 | /// - Returns: The index of the input control character relative to the start index of the input string. 233 | private func endIndex(for control: EnclosingControlCharacters) -> String.Index { 234 | // Search for the end of the command. 235 | let endIndex: String.Index 236 | if let closeIndex = firstUnescapedInstance(of: control, in: input) { 237 | // Closing index found. 238 | endIndex = closeIndex 239 | } else { 240 | // Find another way to end the enclosed text. Most likely the input is not well formatted. Keep on trying. 241 | print("Badly formatted control characters!") 242 | 243 | var recoveryIndex: String.Index 244 | if let messageEndIndex = input.index(of: EnclosingControlCharacters.messageBoundaryClose.rawValue) { 245 | recoveryIndex = messageEndIndex 246 | } else if let lineEndIndex = input.index(of: "\n") { 247 | recoveryIndex = lineEndIndex 248 | } else if let lineEndIndex = input.index(of: "\r\n") { 249 | recoveryIndex = lineEndIndex 250 | } else if let quoteEndIndex = input.index(of: EnclosingControlCharacters.quote.rawValue) { 251 | recoveryIndex = quoteEndIndex 252 | } else if let nextSemicolonIndex = input.index(of: SeperatingControlCharacters.semicolon.rawValue) { 253 | recoveryIndex = nextSemicolonIndex 254 | } else { 255 | // Tried everything. Use the end index in order to avoid crashing. 256 | recoveryIndex = input.endIndex 257 | } 258 | endIndex = recoveryIndex 259 | } 260 | return endIndex 261 | } 262 | /// This function extracts text until a given enclosing control character is found. 263 | /// 264 | /// - Parameter endType: The enclosing control charater that terminates a token. 265 | /// - Returns: The text that is contained form the inputs start until the enclosing control character is found. My be empty if the input string starts with the given control character. 266 | fileprivate func extractText(until endType: EnclosingControlCharacters) -> String { 267 | let endIndexOfText = endIndex(for: endType) 268 | let currentKeyText = extract(until: endIndexOfText, includingControlCharacter: endType) 269 | return currentKeyText 270 | } 271 | /// This function appends a given input token to a prior extracted token if it is of the same type. 272 | /// 273 | /// Inspectes the token that was added last and checks its type. If it matches the input token, both values are concatinated. The prior token is removed from the list and the freshly created token is returned for appending it into the list. 274 | /// - Parameters: 275 | /// - inputToken: The input token whose value may be concatinated with the prior token. 276 | /// - seperatingString: A seperator string that should be inserted between the text of the lastly added token and the input token. 277 | /// - Returns: If the last token in the list is of the same type as the input token, their values are concatinated, a new token is produced and returned. If not, the input token is returned. 278 | private func tokenByConcatinatingwithPriorToken(_ inputToken: Token, seperatingString: String = "") -> Token { 279 | // check if the prior token is of the same type as the current one. 280 | // If so, append the input and return the combined tokens. 281 | // If not, just return the input token 282 | if let priorToken = tokens.last { 283 | // When the prior token and the new token are of the same type, combine their values. Otherwise just return the new token. 284 | switch (priorToken, inputToken) { 285 | case let (.key(oldText), .key(newText)): 286 | let combinedText = oldText + seperatingString + newText 287 | // Also remove the token that is now included in the new token. 288 | tokens.removeLast() 289 | return .key(combinedText) 290 | case let (.value(oldText), .value(newText)): 291 | let combinedText = oldText + seperatingString + newText 292 | // Also remove the token that is now included in the new token. 293 | tokens.removeLast() 294 | return .value(combinedText) 295 | case let (.message( oldText), .message(newText)): 296 | let combinedText = oldText + seperatingString + newText 297 | // Also remove the token that is now included in the new token. 298 | tokens.removeLast() 299 | return .message(combinedText) 300 | default: 301 | return inputToken 302 | } 303 | } else { 304 | return inputToken 305 | } 306 | } 307 | /// This function extracts text from the input string. It starts at the beginning of the input and extracts text until the passed argument ```endIndex```. This text is also removed from the input. 308 | /// 309 | /// Apart from this, the characters of ```includingControlCharacter``` are also removed. 310 | /// 311 | /// - Parameters: 312 | /// - endindex: The index until which text should be extracted. 313 | /// - includingControlCharacter: The control character that should also be removed from the input string. They will not be part of the returned string. 314 | /// - Returns: The The string from the beginning of the input string to the given end index. The given control character will not be included but removed from the input. 315 | private func extract(until endindex: String.Index, includingControlCharacter: ControlCharacterType) -> String { 316 | // Extract the given range and remove it from the input string. 317 | 318 | let lengthOfControlCharacter: Int = includingControlCharacter.skippingLength 319 | let endIndexOfExtraction = input.index(endindex, offsetBy: lengthOfControlCharacter, limitedBy: input.endIndex) ?? input.endIndex 320 | // Remove the range that includes the control character. The input range is used for extracting the text before it. 321 | let rangeForRemoving = input.startIndex ..< endIndexOfExtraction 322 | let rangeForExtraction = input.startIndex ..< endindex 323 | let extracted = String(input[rangeForExtraction]) 324 | input.removeSubrange(rangeForRemoving) 325 | return extracted 326 | } 327 | /// Clears the input string. 328 | private func clearInput() { 329 | input = "" 330 | } 331 | /// This function finds the next control character and returns it. If no new control character can be found, it returns nil (signaling that the input does not contain any valuable information anymore). 332 | /// 333 | /// - Parameter shouldExtract: A flag that determies whether the found control character should also be removed from the input string. 334 | /// - Returns: The next control character or nil if the input string does not contain any valuable information. 335 | private func findNextControlCharacter(andExtractFromSource shouldExtract: Bool) -> ControlCharacterType? { 336 | // Check what the nearest control character is and return it. 337 | // Also extract the found control character since the input string must be kept up to date. 338 | // 339 | // Find the first occurances of each control character. Then pick the first nearest one. 340 | // Unfortunately, a control character -> Index map can not be build since ControlCharacterType is seperated into two types. Therefore two distinct dictionaries must be used :/ 341 | var matchIndexMapSeperatingControlCharacters = [SeperatingControlCharacters: String.Index]() 342 | var matchIndexMapEnclosingControlCharacters = [EnclosingControlCharacters: String.Index]() 343 | // Find the next occurance of any enclosing & seperating control character. 344 | for enclosingControlCharacter in EnclosingControlCharacters.allCases { 345 | matchIndexMapEnclosingControlCharacters[enclosingControlCharacter] = input.index(of: enclosingControlCharacter.rawValue) 346 | } 347 | for seperatingControlCharacter in SeperatingControlCharacters.allCases { 348 | matchIndexMapSeperatingControlCharacters[seperatingControlCharacter] = input.index(of: seperatingControlCharacter.rawValue) 349 | } 350 | // The Map is build up. Now search for the smallest value: 351 | let smallestEnclosing = matchIndexMapEnclosingControlCharacters.smallestValue() 352 | let smallestSeperating = matchIndexMapSeperatingControlCharacters.smallestValue() 353 | let nextControlCharacter: ControlCharacterType 354 | let nextControlCharacterIndex: String.Index 355 | // Determine what of the two elements is smaller: 356 | switch (smallestEnclosing, smallestSeperating) { 357 | case (.none, .some(let smallSeperating)): 358 | nextControlCharacter = smallSeperating.key 359 | nextControlCharacterIndex = smallSeperating.value 360 | case (.some(let smallEnclosing), .none): 361 | nextControlCharacter = smallEnclosing.key 362 | nextControlCharacterIndex = smallEnclosing.value 363 | case let (.some(smallEnclosing), .some(smallSeperating)): 364 | if smallSeperating.value < smallEnclosing.value { 365 | nextControlCharacter = smallSeperating.key 366 | nextControlCharacterIndex = smallSeperating.value 367 | } else { 368 | nextControlCharacter = smallEnclosing.key 369 | nextControlCharacterIndex = smallEnclosing.value 370 | } 371 | case (.none, .none): 372 | // No small element found. Apparently the input is parsed completely. Since no token can be found, it is save to assume that the input string does not contain any valuable information. Just remove all of its input and the system can come to a result. 373 | clearInput() 374 | return nil 375 | } 376 | // Remove til the found control character: 377 | if shouldExtract { 378 | _ = extract(until: nextControlCharacterIndex, includingControlCharacter: nextControlCharacter) 379 | } 380 | return nextControlCharacter 381 | } 382 | } 383 | 384 | extension Parser { 385 | // Handling .other case here. 386 | // 387 | /// This function should be called when the current state is .other. It finds the upcoming control character and switches the state accordingly. 388 | /// 389 | /// - Returns: A token that was extracted from the input or nil if no token can be found. 390 | /// - Throws: Throws an execption when a parse error occured. 391 | fileprivate func prepareNextState() throws -> Token? { 392 | // Read input until the next control command is found. 393 | // Extract the control command. 394 | guard let nextControlCharacter = findNextControlCharacter(andExtractFromSource: true) else { 395 | // No new control character is found which means that the input does not contain any information. Return so that the system can finish. 396 | return nil 397 | } 398 | // Token taht will be returned if appropriate: 399 | var returnToken: Token? 400 | // Switch state to reflect the upcoming input. 401 | switch nextControlCharacter { 402 | case EnclosingControlCharacters.quote: 403 | // Handle this case in a seperate function: 404 | prepareStateForCurrentQuoteToken() 405 | case EnclosingControlCharacters.messageBoundaryOpen: 406 | // A new message begins. 407 | // Set the state to expect a message. 408 | state = .readingMessage(isSingleLine: false) 409 | case EnclosingControlCharacters.messageBoundaryClose: 410 | // Message-end markers should only be detected when the lexer is reading a message. If they occure 'in the wild' the input must be ill formatted. 411 | break 412 | case SeperatingControlCharacters.equal: 413 | // Extract equal sign as token. A quote will follow as next control character but for now the state remains .other in order to detect that quote. 414 | returnToken = .equal 415 | state = .other 416 | case SeperatingControlCharacters.semicolon: 417 | // Extract semicolon as token. A quote or message-start mark will follow as next control character but for now the state remains .other in order to detect that quote. 418 | returnToken = .semicolon 419 | state = .other 420 | case SeperatingControlCharacters.newline: 421 | returnToken = .newline 422 | case EnclosingControlCharacters.singleLineMessageOpen: 423 | state = .readingMessage(isSingleLine: true) 424 | case EnclosingControlCharacters.singleLineMessageClose: 425 | returnToken = .newline 426 | default: 427 | // New types need to be registered. 428 | throw ParserError.notParsable 429 | } 430 | // Maybe tell only available options/tokens to the system. 431 | return returnToken 432 | } 433 | /// This function should be called when the upcoming control character is a quote. It inspects the most recently added tokens and decides whether the upcoming text should be interpreted as key or value. This procedure is neccessary since no escaping characters (\) are available. This may change in Swift 5 String Literal functions. 434 | private func prepareStateForCurrentQuoteToken() { 435 | // Check whether a key or value is to be exected: 436 | // Use heuristics like 'equal before' or 'semicolon before'. 437 | // If value before: value follows that was not escaped. 438 | // Set the state to expect a value as the next token 439 | // If key before: unescaped key follows. 440 | // Set the state to expect a key as the next token 441 | // If equal before: value follows. 442 | // Else: key follows. 443 | if let valueBefore = tokens.last { 444 | switch valueBefore { 445 | case .key: 446 | state = .readingKey 447 | case .value: 448 | state = .readingValue 449 | case .equal: 450 | state = .readingValue 451 | case .semicolon: 452 | state = .readingKey 453 | default: 454 | state = .readingKey 455 | } 456 | } else { 457 | // A key will follow this quote. 458 | state = .readingKey 459 | } 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Providers/ParserTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserTypes.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Andreas Neusüß on 31.12.18. 6 | // Copyright © 2018 Andreas Neusüß. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This enum represents the tokens that can be extracted from the input file. They are used in the first step of the parsing until their information are combined into model objects. 12 | /// 13 | /// - message: The message of a key-value pair. Text is mandatory since the message is optional and a non-existing message does not have a token. 14 | /// - value: The value and its text. 15 | /// - key: The key and its text. 16 | /// - equal: The equal sign that maps a key to a value: ". 17 | /// - semicolon: The semicolon that ends a line: ; 18 | /// - newline: A new line \n. 19 | enum Token { 20 | case message(String) 21 | case value(String) 22 | case key(String) 23 | case equal 24 | case semicolon 25 | case newline 26 | /// Checks if `self` is of the same type as `other` without taking the associated values into account. 27 | /// 28 | /// - Parameter other: The token to which self should be compared to. 29 | /// - Returns: `true` when the type of `other` matches the type of `self` without taking associated values into account. 30 | func isCaseEqual(to other: Token) -> Bool { 31 | switch (self, other) { 32 | case (.message, .message): 33 | return true 34 | case (.value, .value): 35 | return true 36 | case (.key, .key): 37 | return true 38 | case (.equal, .equal): 39 | return true 40 | case (.semicolon, .semicolon): 41 | return true 42 | case (.newline, .newline): 43 | return true 44 | default: 45 | return false 46 | } 47 | } 48 | } 49 | 50 | /// Control characters define starting and end points of tokens. They can be for example ", /* or ; 51 | protocol ControlCharacterType { 52 | /// The length of the control character. Used to skip the input string by this amount of characters. 53 | var skippingLength: Int { get } 54 | } 55 | 56 | /// Control characters can enclose text. 57 | protocol EnclosingType: ControlCharacterType {} 58 | /// Other control characters can not contain any text, they only function as a position-marker. 59 | protocol SeperatingType: ControlCharacterType {} 60 | 61 | /// Enclosing control characters that wrapp text. They may start or end a message or contain a value/key. 62 | /// - messageBoundaryOpen: Opens a message. 63 | /// - messageBoundaryClose: Ends a message. 64 | /// - quote: Wraps a key or a value. 65 | /// - singleLineMessageOpen: Opens a single line message. 66 | /// - singleLineMessageClose: Closes the single line message. 67 | enum EnclosingControlCharacters: String, EnclosingType, CaseIterable { 68 | case messageBoundaryOpen = "/*" 69 | case messageBoundaryClose = "*/" 70 | case quote = "\"" 71 | case singleLineMessageOpen = "//" 72 | case singleLineMessageClose = "\n" 73 | 74 | var skippingLength: Int { 75 | switch self { 76 | case .messageBoundaryOpen: 77 | return EnclosingControlCharacters.messageBoundaryOpen.rawValue.count 78 | case .messageBoundaryClose: 79 | return EnclosingControlCharacters.messageBoundaryClose.rawValue.count 80 | case .quote: 81 | return EnclosingControlCharacters.quote.rawValue.count 82 | case .singleLineMessageOpen: 83 | return EnclosingControlCharacters.singleLineMessageOpen.rawValue.count 84 | case .singleLineMessageClose: 85 | return EnclosingControlCharacters.singleLineMessageClose.rawValue.count 86 | } 87 | } 88 | } 89 | 90 | /// Seperating control characters do not wrap text. They function as position markers. For example they seperate a key from its value or end the line. 91 | /// - equal: The equal sign that seperates a key from its value. 92 | /// - semicolon: The semicolon that end a line. 93 | /// - newline: A new line. 94 | enum SeperatingControlCharacters: String, SeperatingType, CaseIterable { 95 | var skippingLength: Int { 96 | switch self { 97 | case .equal: 98 | return SeperatingControlCharacters.equal.rawValue.count 99 | case .semicolon: 100 | return SeperatingControlCharacters.semicolon.rawValue.count 101 | case .newline: 102 | return SeperatingControlCharacters.newline.rawValue.count 103 | } 104 | } 105 | 106 | case equal = "=" 107 | case semicolon = ";" 108 | case newline = "\n" 109 | } 110 | 111 | /// Errors that may occure during parsing. 112 | /// 113 | /// - notParsable: The input can not be parsed. 114 | /// - malformattedInput Probably the input is mal formatted and the parser can not make any sense of it. 115 | enum ParserError: Error { 116 | case notParsable 117 | case malformattedInput 118 | } 119 | 120 | extension Dictionary where Dictionary.Value: Comparable { 121 | /// Finds the smallest value of the dictionary. If there is one it returns the value and its corresponding key. 122 | /// 123 | /// O(n) time complexity. 124 | /// - Returns: The smallest value of the dictionary and the corresponding key. 125 | func smallestValue() -> (key: Key, value: Value)? { 126 | guard !isEmpty else { 127 | return nil 128 | } 129 | var smallestValue: Value? 130 | var smallestKey: Key? 131 | // Iterate through the dictionary and find a value that is smaller than the current smalles. O(n) time complexity. 132 | for (key, value) in self { 133 | if let currentSmallest = smallestValue { 134 | if value < currentSmallest { 135 | // New smallest value found. 136 | smallestValue = value 137 | smallestKey = key 138 | } 139 | } else { 140 | // This must be the smallest since the smalles does not exist, yet. 141 | smallestValue = value 142 | smallestKey = key 143 | } 144 | } 145 | assert(smallestKey != nil, "Key must not be nil since at least one pair is stored and it must be identified as smalles.") 146 | assert(smallestValue != nil, "Value must not be nil since at least one pair is stored and it must be identified as smalles.") 147 | return (key: smallestKey!, value: smallestValue!) 148 | } 149 | } 150 | 151 | extension String { 152 | // Find the index of a substring in another string 153 | /// Finds the index of a substring in a superstring. Uses the build in string searching mechanisms implemented by Foundation. 154 | /// 155 | /// - Parameters: 156 | /// - substring: The string to search for. 157 | /// - startPosition: The start position from where the string should be searched from. 158 | /// - options: Searching options. 159 | /// - Returns: The index where the searched string occures or nil if no match can be found. 160 | func index(of substring: String, from startPosition: Index? = nil, options: CompareOptions = .literal) -> Index? { 161 | let start = startPosition ?? startIndex 162 | let matchedRange = range(of: substring, options: options, range: start ..< endIndex) 163 | let matchedIndex = matchedRange?.lowerBound 164 | return matchedIndex 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | German translation: Milo Ivir , 2020. 8 | */ 9 | 10 | "key" = "Schlüssel"; 11 | "comment" = "Kommentar"; 12 | "cancel" = "Abbrechen"; 13 | "add" = "Hinzufügen"; 14 | "view" = "Ansicht"; 15 | "file" = "Ablage"; 16 | "open_folder_with" = "Ordner mit Übersetzungsdateien öffnen …"; 17 | "close" = "Schließen"; 18 | "edit" = "Bearbeiten"; 19 | "cut" = "Ausschneiden"; 20 | "copy" = "Kopieren"; 21 | "paste" = "Einsetzen"; 22 | "select_all" = "Alles auswählen"; 23 | "enter_full_screen" = "Vollbildmodus ein"; 24 | "window" = "Fenster"; 25 | "minimize" = "Im Dock ablegen"; 26 | "bring_all_to_front" = "Alle nach vorne bringen"; 27 | "zoom" = "Zoomen"; 28 | "all" = "Alle"; 29 | "missing" = "Fehlt"; 30 | "actions" = "Aktionen"; 31 | "delete" = "Löschen"; 32 | "open_folder" = "Ordner öffnen"; 33 | "filter" = "Filter"; 34 | "string_table" = "Zeichenketten-Tabelle"; 35 | "new_translation" = "Neue Übersetzung"; 36 | "search" = "Suchen"; 37 | "open_folder_first" = "Öffne zuerst einen Ordner"; 38 | "about" = "Über LocalizationEditor"; 39 | "quit" = "LocalizationEditor beenden"; 40 | "show_all" = "Alle einblenden"; 41 | "services" = "Dienste"; 42 | "hide_others" = "Andere ausblenden"; 43 | "hide_editor" = "LocalizationEditor ausblenden"; 44 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "key" = "Key"; 10 | "comment" = "Comment"; 11 | "cancel" = "Cancel"; 12 | "add" = "Add"; 13 | "view" = "View"; 14 | "file" = "File"; 15 | "open_folder_with" = "Open folder with localization files..."; 16 | "close" = "Close"; 17 | "edit" = "Edit"; 18 | "cut" = "Cut"; 19 | "copy" = "Copy"; 20 | "paste" = "Paste"; 21 | "select_all" = "Select All"; 22 | "enter_full_screen" = "Enter Full Screen"; 23 | "window" = "Window"; 24 | "minimize" = "Minimize"; 25 | "bring_all_to_front" = "Bring All to Front"; 26 | "zoom" = "Zoom"; 27 | "all" = "All"; 28 | "missing" = "Missing"; 29 | "actions" = "Actions"; 30 | "delete" = "Delete"; 31 | "open_folder" = "Open folder"; 32 | "filter" = "Filter"; 33 | "string_table" = "String table"; 34 | "new_translation" = "New translation"; 35 | "search" = "Search"; 36 | "open_folder_first" = "Open folder first"; 37 | "about" = "About LocalizationEditor"; 38 | "quit" = "Quit LocalizationEditor"; 39 | "show_all" = "Show All"; 40 | "services" = "Services"; 41 | "hide_others" = "Hide Others"; 42 | "hide_editor" = "Hide LocalizationEditor"; 43 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "key" = "Clave"; 10 | "comment" = "Comentario"; 11 | "cancel" = "Cancelar"; 12 | "add" = "Añadir"; 13 | "view" = "Vista"; 14 | "file" = "Fichero"; 15 | "open_folder_with" = "Abrir carpeta con ficheros de traducción..."; 16 | "close" = "Cerrar"; 17 | "edit" = "Editar"; 18 | "cut" = "Cortar"; 19 | "copy" = "Copiar"; 20 | "paste" = "Pegar"; 21 | "select_all" = "Seleccionar todo"; 22 | "enter_full_screen" = "Entrar en pantalla completa"; 23 | "window" = "Ventana"; 24 | "minimize" = "Minimizar"; 25 | "bring_all_to_front" = "Traer todo al frente"; 26 | "zoom" = "Ampliar"; 27 | "all" = "Todo"; 28 | "missing" = "Sin traduccir"; 29 | "actions" = "Acciones"; 30 | "delete" = "Borrar"; 31 | "open_folder" = "Abrir carpeta"; 32 | "filter" = "Filtrar"; 33 | "string_table" = "Tabla de cadenas"; 34 | "new_translation" = "Nueva traducción"; 35 | "search" = "Buscar"; 36 | "open_folder_first" = "Abrir primera carpeta"; 37 | "about" = "Sobre LocalizationEditor"; 38 | "quit" = "Cerrar LocalizationEditor"; 39 | "show_all" = "Mostrar todo"; 40 | "services" = "Servicios"; 41 | "hide_others" = "Ocultar otros"; 42 | "hide_editor" = "Ocultar LocalizationEditor"; 43 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/hr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | Croatian translation: Milo Ivir , 2020. 8 | */ 9 | 10 | "key" = "Ključ"; 11 | "comment" = "Komentar"; 12 | "cancel" = "Odustani"; 13 | "add" = "Dodaj"; 14 | "view" = "Prikaz"; 15 | "file" = "Datoteka"; 16 | "open_folder_with" = "Otvori mapu s prevodilačkim datotekama …"; 17 | "close" = "Zatvori"; 18 | "edit" = "Uredi"; 19 | "cut" = "Izreži"; 20 | "copy" = "Kopiraj"; 21 | "paste" = "Umetni"; 22 | "select_all" = "Odaberi sve"; 23 | "enter_full_screen" = "Cjeloekranski prikaz"; 24 | "window" = "Prozor"; 25 | "minimize" = "Smanji"; 26 | "bring_all_to_front" = "Postavi sve naprijed"; 27 | "zoom" = "Zumiraj"; 28 | "all" = "Sve"; 29 | "missing" = "Nedostaje"; 30 | "actions" = "Radnje"; 31 | "delete" = "Izbriši"; 32 | "open_folder" = "Otvori mapu"; 33 | "filter" = "Filtar"; 34 | "string_table" = "Tablica nizova"; 35 | "new_translation" = "Novi prijevod"; 36 | "search" = "Traži"; 37 | "open_folder_first" = "Najprije otvori mapu"; 38 | "about" = "O programu LocalizationEditor"; 39 | "quit" = "Zatvori LocalizationEditor"; 40 | "show_all" = "Prikaži sve"; 41 | "services" = "Usluge"; 42 | "hide_others" = "Sakrij ostalo"; 43 | "hide_editor" = "Sakrij LocalizationEditor"; 44 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | Japanese translation: Fus1onDev, 2022. 8 | */ 9 | 10 | "about" = "LocalizationEditorについて"; 11 | "actions" = "アクション"; 12 | "add" = "追加"; 13 | "all" = "すべて"; 14 | "bring_all_to_front" = "すべてを手前に移動"; 15 | "cancel" = "キャンセル"; 16 | "close" = "閉じる"; 17 | "comment" = "コメント"; 18 | "copy" = "コピー"; 19 | "cut" = "カット"; 20 | "delete" = "削除"; 21 | "edit" = "編集"; 22 | "enter_full_screen" = "フルスクリーンにする"; 23 | "file" = "ファイル"; 24 | "filter" = "フィルター"; 25 | "hide_editor" = "LocalizationEditorを非表示"; 26 | "hide_others" = "ほかを非表示"; 27 | "key" = "キー"; 28 | "minimize" = "しまう"; 29 | "missing" = "不足"; 30 | "new_translation" = "新規"; 31 | "open_folder" = "フォルダを開く"; 32 | "open_folder_first" = "最初にフォルダを開く"; 33 | "open_folder_with" = "翻訳ファイルのフォルダを開く..."; 34 | "paste" = "ペースト"; 35 | "quit" = "LocalizationEditorを終了"; 36 | "search" = "検索"; 37 | "select_all" = "すべてを選択"; 38 | "services" = "サービス"; 39 | "show_all" = "すべてを表示"; 40 | "string_table" = "文字列テーブル"; 41 | "view" = "表示"; 42 | "window" = "ウインドウ"; 43 | "zoom" = "拡大"; 44 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/ru.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "key" = "Ключ"; 10 | "comment" = "Комментарий"; 11 | "cancel" = "Отмена"; 12 | "add" = "Добавить"; 13 | "view" = "Вид"; 14 | "file" = "Файл"; 15 | "open_folder_with" = "Открыть папку с файлами локализации ..."; 16 | "close" = "Закрыть"; 17 | "edit" = "Правка"; 18 | "cut" = "Вырезать"; 19 | "copy" = "Скопировать"; 20 | "paste" = "Вставить"; 21 | "select_all" = "Выбрать все"; 22 | "enter_full_screen" = "Полноэкранный Режим"; 23 | "window" = "Окно"; 24 | "minimize" = "Убрать в Dock"; 25 | "bring_all_to_front" = "Все окна - на передний план"; 26 | "zoom" = "Изменить масштаб"; 27 | "all" = "Все"; 28 | "missing" = "Отсутствующий"; 29 | "actions" = "Действия"; 30 | "delete" = "Удалить"; 31 | "open_folder" = "Открыть папку"; 32 | "filter" = "Фильтр"; 33 | "string_table" = "Таблица строк"; 34 | "new_translation" = "Новый перевод"; 35 | "search" = "Поиск"; 36 | "open_folder_first" = "Сначала откройте папку"; 37 | "about" = "О LocalizationEditor"; 38 | "quit" = "Завершить LocalizationEditor"; 39 | "show_all" = "Показать Все"; 40 | "services" = "Сервисы"; 41 | "hide_others" = "Скрыть остальные"; 42 | "hide_editor" = "Скрыть LocalizationEditor"; 43 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/zh-Hans.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "key" = "Key"; 10 | "comment" = "Comment"; 11 | "cancel" = "取消"; 12 | "add" = "添加"; 13 | "view" = "视图"; 14 | "file" = "文件"; 15 | "open_folder_with" = "打开带有本地化文件的文件夹..."; 16 | "close" = "关闭"; 17 | "edit" = "编辑"; 18 | "cut" = "剪切"; 19 | "copy" = "复制"; 20 | "paste" = "粘贴"; 21 | "select_all" = "选择全部"; 22 | "enter_full_screen" = "进入全屏幕"; 23 | "window" = "窗口"; 24 | "minimize" = "最小化"; 25 | "bring_all_to_front" = "前置全部窗口"; 26 | "zoom" = "缩放"; 27 | "all" = "全部"; 28 | "missing" = "缺失"; 29 | "actions" = "动作"; 30 | "delete" = "删除"; 31 | "open_folder" = "打开文件夹"; 32 | "filter" = "筛选"; 33 | "string_table" = "字符串表"; 34 | "new_translation" = "新的翻译"; 35 | "search" = "搜索"; 36 | "open_folder_first" = "请先打开文件夹"; 37 | "about" = "关于 LocalizationEditor"; 38 | "quit" = "退出 LocalizationEditor"; 39 | "show_all" = "显示全部"; 40 | "services" = "服务"; 41 | "hide_others" = "隐藏其他"; 42 | "hide_editor" = "隐藏 LocalizationEditor"; 43 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/Resources/zh-Hant.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 24/10/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "about" = "關於 LocalizationEditor"; 10 | "actions" = "動作"; 11 | "add" = "增加"; 12 | "all" = "全部語言"; 13 | "bring_all_to_front" = "全部視窗往前"; 14 | "close" = "關閉"; 15 | "cancel" = "取消"; 16 | "comment" = "註解"; 17 | "copy" = "複製"; 18 | "cut" = "剪下"; 19 | "delete" = "刪除"; 20 | "edit" = "編輯"; 21 | "enter_full_screen" = "全螢幕"; 22 | "file" = "檔案"; 23 | "filter" = "過濾內容"; 24 | "hide_editor" = "隱藏 LocalizationEditor"; 25 | "hide_others" = "隱藏其他應用程式視窗"; 26 | "key" = "Key"; 27 | "minimize" = "縮到最小"; 28 | "missing" = "內容缺少"; 29 | "new_translation" = "建立新的翻譯"; 30 | "open_folder" = "開啟資料夾"; 31 | "open_folder_first" = "請先開啟資料夾"; 32 | "open_folder_with" = "請開啟帶有多國文件的資料夾..."; 33 | "paste" = "貼上"; 34 | "quit" = "離開 LocalizationEditor"; 35 | "search" = "搜尋"; 36 | "select_all" = "搜尋全部"; 37 | "services" = "服務"; 38 | "show_all" = "顯示全部應用程式視窗"; 39 | "string_table" = "字元列表"; 40 | "view" = "顯示方式"; 41 | "window" = "視窗"; 42 | "zoom" = "縮放"; 43 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/AddViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddViewController.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 14/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol AddViewControllerDelegate: AnyObject { 12 | func userDidCancel() 13 | func userDidAddTranslation(key: String, message: String?) 14 | } 15 | 16 | final class AddViewController: NSViewController { 17 | 18 | // MARK: - Outlets 19 | 20 | @IBOutlet private weak var keyTextField: NSTextField! 21 | @IBOutlet private weak var addButton: NSButton! 22 | @IBOutlet private weak var messageTextField: NSTextField! 23 | 24 | // MARK: - Properties 25 | 26 | weak var delegate: AddViewControllerDelegate? 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | setup() 32 | } 33 | 34 | // MARK: - Setup 35 | 36 | private func setup() { 37 | keyTextField.delegate = self 38 | } 39 | 40 | // MARK: - Actions 41 | 42 | @IBAction private func cancelAction(_ sender: Any) { 43 | delegate?.userDidCancel() 44 | } 45 | 46 | @IBAction private func addAction(_ sender: Any) { 47 | guard !keyTextField.stringValue.isEmpty else { 48 | return 49 | } 50 | 51 | delegate?.userDidAddTranslation(key: keyTextField.stringValue, message: messageTextField.stringValue.isEmpty ? nil : messageTextField.stringValue) 52 | } 53 | } 54 | 55 | // MARK: - NSTextFieldDelegate 56 | 57 | extension AddViewController: NSTextFieldDelegate { 58 | func controlTextDidChange(_ obj: Notification) { 59 | addButton.isEnabled = !keyTextField.stringValue.isEmpty 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/ActionsCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionsCell.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 05/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol ActionsCellDelegate: AnyObject { 12 | func userDidRequestRemoval(of key: String) 13 | } 14 | 15 | final class ActionsCell: NSTableCellView { 16 | // MARK: - Outlets 17 | 18 | @IBOutlet private weak var deleteButton: NSButton! 19 | 20 | // MARK: - Properties 21 | 22 | static let identifier = "ActionsCell" 23 | 24 | var key: String? 25 | weak var delegate: ActionsCellDelegate? 26 | 27 | override func awakeFromNib() { 28 | super.awakeFromNib() 29 | 30 | deleteButton.image = NSImage(named: NSImage.stopProgressTemplateName) 31 | deleteButton.toolTip = "delete".localized 32 | } 33 | 34 | @IBAction private func removalClicked(_ sender: NSButton) { 35 | guard let key = key else { 36 | return 37 | } 38 | 39 | delegate?.userDidRequestRemoval(of: key) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/ActionsCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/KeyCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyCell.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Foundation 11 | 12 | final class KeyCell: NSTableCellView { 13 | // MARK: - Outlets 14 | 15 | @IBOutlet private weak var keyLabel: NSTextField! 16 | @IBOutlet private weak var messageLabel: NSTextField! 17 | 18 | // MARK: - Properties 19 | 20 | static let identifier = "KeyCell" 21 | 22 | var key: String? { 23 | didSet { 24 | keyLabel.stringValue = key ?? "" 25 | } 26 | } 27 | var message: String? { 28 | didSet { 29 | messageLabel.stringValue = message ?? "" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/KeyCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/LocalizationCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationCell.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol LocalizationCellDelegate: AnyObject { 12 | func controlTextDidEndEditing(_ obj: Notification) 13 | func userDidUpdateLocalizationString(language: String, key: String, with value: String, message: String?) 14 | } 15 | 16 | final class LocalizationCell: NSTableCellView { 17 | // MARK: - Outlets 18 | 19 | @IBOutlet private weak var valueTextField: NSTextField! 20 | 21 | // MARK: - Properties 22 | 23 | static let identifier = "LocalizationCell" 24 | 25 | weak var delegate: LocalizationCellDelegate? 26 | 27 | var language: String? 28 | 29 | var value: LocalizationString? { 30 | didSet { 31 | valueTextField.stringValue = value?.value ?? "" 32 | valueTextField.delegate = self 33 | setStateUI() 34 | } 35 | } 36 | 37 | private func setStateUI() { 38 | valueTextField.layer?.borderColor = valueTextField.stringValue.isEmpty ? NSColor.red.cgColor : NSColor.clear.cgColor 39 | } 40 | 41 | override func awakeFromNib() { 42 | super.awakeFromNib() 43 | 44 | valueTextField.wantsLayer = true 45 | valueTextField.layer?.borderWidth = 1.0 46 | valueTextField.layer?.cornerRadius = 0.0 47 | } 48 | 49 | /** 50 | Focues the cell by activating the NSTextField, making sure there is no selection and cursor is moved to the end 51 | */ 52 | func focus() { 53 | valueTextField?.becomeFirstResponder() 54 | valueTextField?.currentEditor()?.selectedRange = NSRange(location: 0, length: 0) 55 | valueTextField?.currentEditor()?.moveToEndOfDocument(nil) 56 | } 57 | } 58 | 59 | // MARK: - Delegate 60 | 61 | extension LocalizationCell: NSTextFieldDelegate { 62 | func controlTextDidEndEditing(_ obj: Notification) { 63 | delegate?.controlTextDidEndEditing(obj) 64 | guard let language = language, let value = value else { 65 | return 66 | } 67 | 68 | setStateUI() 69 | delegate?.userDidUpdateLocalizationString(language: language, key: value.key, with: valueTextField.stringValue, message: value.message) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/Cells/LocalizationCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 30/05/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | /** 12 | Protocol for announcing changes to the toolbar. Needed because the VC does not have direct access to the toolbar (handled by WindowController) 13 | */ 14 | protocol ViewControllerDelegate: AnyObject { 15 | /** 16 | Invoked when localization groups should be set in the toolbar's dropdown list 17 | */ 18 | func shouldSetLocalizationGroups(groups: [LocalizationGroup]) 19 | 20 | /** 21 | Invoiked when search and filter should be reset in the toolbar 22 | */ 23 | func shouldResetSearchTermAndFilter() 24 | 25 | /** 26 | Invoked when localization group should be selected in the toolbar's dropdown list 27 | */ 28 | func shouldSelectLocalizationGroup(title: String) 29 | } 30 | 31 | final class ViewController: NSViewController { 32 | enum FixedColumn: String { 33 | case key 34 | case actions 35 | } 36 | 37 | // MARK: - Outlets 38 | 39 | @IBOutlet private weak var tableView: NSTableView! 40 | @IBOutlet private weak var progressIndicator: NSProgressIndicator! 41 | 42 | // MARK: - Properties 43 | 44 | weak var delegate: ViewControllerDelegate? 45 | 46 | private var currentFilter: Filter = .all 47 | private var currentSearchTerm: String = "" 48 | private let dataSource = LocalizationsDataSource() 49 | private var presendedAddViewController: AddViewController? 50 | private var currentOpenFolderUrl: URL? 51 | 52 | override func viewDidLoad() { 53 | super.viewDidLoad() 54 | 55 | setupData() 56 | } 57 | 58 | // MARK: - Setup 59 | 60 | private func setupData() { 61 | let cellIdentifiers = [KeyCell.identifier, LocalizationCell.identifier, ActionsCell.identifier] 62 | cellIdentifiers.forEach { identifier in 63 | let cell = NSNib(nibNamed: identifier, bundle: nil) 64 | tableView.register(cell, forIdentifier: NSUserInterfaceItemIdentifier(rawValue: identifier)) 65 | } 66 | 67 | tableView.delegate = self 68 | tableView.dataSource = dataSource 69 | tableView.allowsColumnResizing = true 70 | tableView.usesAutomaticRowHeights = true 71 | 72 | tableView.selectionHighlightStyle = .none 73 | } 74 | 75 | private func reloadData(with languages: [String], title: String?) { 76 | delegate?.shouldResetSearchTermAndFilter() 77 | 78 | let appName = Bundle.main.infoDictionary![kCFBundleNameKey as String] as! String 79 | if #available(macOS 11, *) { 80 | view.window?.title = appName 81 | view.window?.subtitle = title ?? "" 82 | } else { 83 | view.window?.title = title.flatMap({ "\(appName) [\($0)]" }) ?? appName 84 | } 85 | 86 | let columns = tableView.tableColumns 87 | columns.forEach { 88 | self.tableView.removeTableColumn($0) 89 | } 90 | 91 | // not sure why this is needed but without it autolayout crashes and the whole tableview breaks visually 92 | tableView.reloadData() 93 | 94 | let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(FixedColumn.key.rawValue)) 95 | column.title = "key".localized 96 | tableView.addTableColumn(column) 97 | 98 | languages.forEach { language in 99 | let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(language)) 100 | column.title = Flag(languageCode: language).emoji 101 | column.maxWidth = 460 102 | column.minWidth = 50 103 | self.tableView.addTableColumn(column) 104 | } 105 | 106 | let actionsColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(FixedColumn.actions.rawValue)) 107 | actionsColumn.title = "actions".localized 108 | actionsColumn.maxWidth = 48 109 | actionsColumn.minWidth = 32 110 | tableView.addTableColumn(actionsColumn) 111 | 112 | tableView.reloadData() 113 | 114 | // Also resize the columns: 115 | tableView.sizeToFit() 116 | 117 | // Needed to properly size the actions column 118 | DispatchQueue.main.async { 119 | self.tableView.sizeToFit() 120 | self.tableView.layout() 121 | } 122 | } 123 | 124 | private func filter() { 125 | dataSource.filter(by: currentFilter, searchString: currentSearchTerm) 126 | tableView.reloadData() 127 | } 128 | 129 | private func handleOpenFolder(_ url: URL) { 130 | self.progressIndicator.startAnimation(self) 131 | self.dataSource.load(folder: url) { [unowned self] languages, title, localizationFiles in 132 | self.currentOpenFolderUrl = url 133 | self.reloadData(with: languages, title: title) 134 | self.progressIndicator.stopAnimation(self) 135 | 136 | if let title = title { 137 | self.delegate?.shouldSetLocalizationGroups(groups: localizationFiles) 138 | self.delegate?.shouldSelectLocalizationGroup(title: title) 139 | } 140 | } 141 | } 142 | 143 | private func openFolder(forPath path: String? = nil) { 144 | if let path = path { 145 | handleOpenFolder(URL(fileURLWithPath: path)) 146 | return 147 | } 148 | 149 | let openPanel = NSOpenPanel() 150 | openPanel.allowsMultipleSelection = false 151 | openPanel.canChooseDirectories = true 152 | openPanel.canCreateDirectories = true 153 | openPanel.canChooseFiles = false 154 | openPanel.begin { result -> Void in 155 | guard result.rawValue == NSApplication.ModalResponse.OK.rawValue, let url = openPanel.url else { 156 | return 157 | } 158 | self.handleOpenFolder(url) 159 | } 160 | } 161 | } 162 | 163 | // MARK: - NSTableViewDelegate 164 | 165 | extension ViewController: NSTableViewDelegate { 166 | func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 167 | guard let identifier = tableColumn?.identifier else { 168 | return nil 169 | } 170 | 171 | switch identifier.rawValue { 172 | case FixedColumn.key.rawValue: 173 | let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: KeyCell.identifier), owner: self)! as! KeyCell 174 | cell.key = dataSource.getKey(row: row) 175 | cell.message = dataSource.getMessage(row: row) 176 | return cell 177 | case FixedColumn.actions.rawValue: 178 | let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: ActionsCell.identifier), owner: self)! as! ActionsCell 179 | cell.delegate = self 180 | cell.key = dataSource.getKey(row: row) 181 | return cell 182 | default: 183 | let language = identifier.rawValue 184 | let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: LocalizationCell.identifier), owner: self)! as! LocalizationCell 185 | cell.delegate = self 186 | cell.language = language 187 | cell.value = row < dataSource.numberOfRows(in: tableView) ? dataSource.getLocalization(language: language, row: row) : nil 188 | return cell 189 | } 190 | } 191 | } 192 | 193 | // MARK: - LocalizationCellDelegate 194 | 195 | extension ViewController: LocalizationCellDelegate { 196 | func userDidUpdateLocalizationString(language: String, key: String, with value: String, message: String?) { 197 | dataSource.updateLocalization(language: language, key: key, with: value, message: message) 198 | } 199 | 200 | func controlTextDidEndEditing(_ obj: Notification) { 201 | guard let view = obj.object as? NSView, let textMovementInt = obj.userInfo?["NSTextMovement"] as? Int, let textMovement = NSTextMovement(rawValue: textMovementInt) else { 202 | return 203 | } 204 | 205 | let columnIndex = tableView.column(for: view) 206 | let rowIndex = tableView.row(for: view) 207 | 208 | let newRowIndex: Int 209 | let newColumnIndex: Int 210 | 211 | switch textMovement { 212 | case .tab: 213 | if columnIndex + 1 >= tableView.numberOfColumns - 1 { 214 | newRowIndex = rowIndex + 1 215 | newColumnIndex = 1 216 | } else { 217 | newColumnIndex = columnIndex + 1 218 | newRowIndex = rowIndex 219 | } 220 | if newRowIndex >= tableView.numberOfRows { 221 | return 222 | } 223 | case .backtab: 224 | if columnIndex - 1 <= 0 { 225 | newRowIndex = rowIndex - 1 226 | newColumnIndex = tableView.numberOfColumns - 2 227 | } else { 228 | newColumnIndex = columnIndex - 1 229 | newRowIndex = rowIndex 230 | } 231 | if newRowIndex < 0 { 232 | return 233 | } 234 | default: 235 | return 236 | } 237 | 238 | DispatchQueue.main.async { [weak self] in 239 | self?.tableView.editColumn(newColumnIndex, row: newRowIndex, with: nil, select: true) 240 | } 241 | } 242 | } 243 | 244 | // MARK: - ActionsCellDelegate 245 | 246 | extension ViewController: ActionsCellDelegate { 247 | func userDidRequestRemoval(of key: String) { 248 | dataSource.deleteLocalization(key: key) 249 | 250 | // reload keeping scroll position 251 | let rect = tableView.visibleRect 252 | filter() 253 | tableView.scrollToVisible(rect) 254 | } 255 | } 256 | 257 | // MARK: - WindowControllerToolbarDelegate 258 | 259 | extension ViewController: WindowControllerToolbarDelegate { 260 | /** 261 | Invoked when user requests adding a new translation 262 | */ 263 | func userDidRequestAddNewTranslation() { 264 | let addViewController = storyboard!.instantiateController(withIdentifier: "Add") as! AddViewController 265 | addViewController.delegate = self 266 | presendedAddViewController = addViewController 267 | presentAsSheet(addViewController) 268 | } 269 | 270 | /** 271 | Invoked when user requests filter change 272 | 273 | - Parameter filter: new filter setting 274 | */ 275 | func userDidRequestFilterChange(filter: Filter) { 276 | guard currentFilter != filter else { 277 | return 278 | } 279 | 280 | currentFilter = filter 281 | self.filter() 282 | } 283 | 284 | /** 285 | Invoked when user requests searching 286 | 287 | - Parameter searchTerm: new search term 288 | */ 289 | func userDidRequestSearch(searchTerm: String) { 290 | guard currentSearchTerm != searchTerm else { 291 | return 292 | } 293 | 294 | currentSearchTerm = searchTerm 295 | filter() 296 | } 297 | 298 | /** 299 | Invoked when user request change of the selected localization group 300 | 301 | - Parameter group: new localization group title 302 | */ 303 | func userDidRequestLocalizationGroupChange(group: String) { 304 | let languages = dataSource.selectGroupAndGetLanguages(for: group) 305 | reloadData(with: languages, title: group) 306 | } 307 | 308 | /** 309 | Invoked when user requests opening a folder 310 | */ 311 | func userDidRequestFolderOpen() { 312 | openFolder() 313 | } 314 | 315 | /** 316 | Invoked when user requests opening a folder for specific path 317 | */ 318 | func userDidRequestFolderOpen(withPath path: String) { 319 | openFolder(forPath: path) 320 | } 321 | 322 | /** 323 | Invoked when user requests reload selected folder 324 | */ 325 | func userDidRequestReloadData() { 326 | guard let currentOpenFolderUrl = currentOpenFolderUrl else { 327 | return 328 | } 329 | handleOpenFolder(currentOpenFolderUrl) 330 | 331 | } 332 | } 333 | 334 | // MARK: - AddViewControllerDelegate 335 | 336 | extension ViewController: AddViewControllerDelegate { 337 | func userDidCancel() { 338 | dismiss() 339 | } 340 | 341 | func userDidAddTranslation(key: String, message: String?) { 342 | dismiss() 343 | 344 | dataSource.addLocalizationKey(key: key, message: message) 345 | filter() 346 | 347 | if let row = dataSource.getRowForKey(key: key) { 348 | DispatchQueue.main.async { 349 | self.tableView.scrollRowToVisible(row) 350 | } 351 | } 352 | } 353 | 354 | private func dismiss() { 355 | guard let presendedAddViewController = presendedAddViewController else { 356 | return 357 | } 358 | 359 | dismiss(presendedAddViewController) 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /sources/LocalizationEditor/UI/WindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowController.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Igor Kulman on 05/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | /** 12 | Protocol for announcing user interaction with the toolbar 13 | */ 14 | protocol WindowControllerToolbarDelegate: AnyObject { 15 | /** 16 | Invoked when user requests opening a folder 17 | */ 18 | func userDidRequestFolderOpen() 19 | 20 | /** 21 | Invoked when user requests opening a folder for a specific path 22 | */ 23 | func userDidRequestFolderOpen(withPath: String) 24 | 25 | /** 26 | Invoked when user requests filter change 27 | 28 | - Parameter filter: new filter setting 29 | */ 30 | func userDidRequestFilterChange(filter: Filter) 31 | 32 | /** 33 | Invoked when user requests searching 34 | 35 | - Parameter searchTerm: new search term 36 | */ 37 | func userDidRequestSearch(searchTerm: String) 38 | 39 | /** 40 | Invoked when user requests change of the selected localization group 41 | 42 | - Parameter group: new localization group title 43 | */ 44 | func userDidRequestLocalizationGroupChange(group: String) 45 | 46 | /** 47 | Invoked when user requests adding a new translation 48 | */ 49 | func userDidRequestAddNewTranslation() 50 | 51 | /** 52 | Invoked when user requests reload selected folder 53 | */ 54 | func userDidRequestReloadData() 55 | } 56 | 57 | final class WindowController: NSWindowController { 58 | 59 | // MARK: - Outlets 60 | 61 | @IBOutlet private weak var openButton: NSToolbarItem! 62 | @IBOutlet private weak var searchField: NSSearchField! 63 | @IBOutlet private weak var selectButton: NSPopUpButton! 64 | @IBOutlet private weak var filterButton: NSPopUpButton! 65 | @IBOutlet private weak var newButton: NSToolbarItem! 66 | 67 | // MARK: - Properties 68 | 69 | weak var delegate: WindowControllerToolbarDelegate? 70 | 71 | override func windowDidLoad() { 72 | super.windowDidLoad() 73 | 74 | setupUI() 75 | setupSearch() 76 | setupFilter() 77 | setupMenu() 78 | setupDelegates() 79 | } 80 | 81 | // MAKR: - Interfaces 82 | 83 | func openFolder(withPath path: String) { 84 | delegate?.userDidRequestFolderOpen(withPath: path) 85 | } 86 | 87 | // MARK: - Setup 88 | 89 | private func setupUI() { 90 | openButton.image = NSImage(named: NSImage.folderName) 91 | openButton.toolTip = "open_folder".localized 92 | searchField.toolTip = "search".localized 93 | filterButton.toolTip = "filter".localized 94 | selectButton.toolTip = "string_table".localized 95 | newButton.toolTip = "new_translation".localized 96 | } 97 | 98 | private func setupSearch() { 99 | searchField.delegate = self 100 | searchField.stringValue = "" 101 | 102 | _ = searchField.resignFirstResponder() 103 | } 104 | 105 | private func setupFilter() { 106 | filterButton.menu?.removeAllItems() 107 | 108 | for option in Filter.allCases { 109 | let item = NSMenuItem(title: "\(option.description)".capitalizedFirstLetter, action: #selector(WindowController.filterAction(sender:)), keyEquivalent: "") 110 | item.tag = option.rawValue 111 | filterButton.menu?.addItem(item) 112 | } 113 | } 114 | 115 | private func setupMenu() { 116 | let appDelegate = NSApplication.shared.delegate as! AppDelegate 117 | appDelegate.openFolderMenuItem.action = #selector(WindowController.openFolderAction(_:)) 118 | appDelegate.reloadMenuItem.action = #selector(WindowController.reloadDataAction(_:)) 119 | } 120 | 121 | private func enableControls() { 122 | searchField.isEnabled = true 123 | filterButton.isEnabled = true 124 | selectButton.isEnabled = true 125 | newButton.isEnabled = true 126 | } 127 | 128 | private func setupDelegates() { 129 | guard let mainViewController = window?.contentViewController as? ViewController else { 130 | fatalError("Broken window hierarchy") 131 | } 132 | 133 | // informing the window about toolbar appearence 134 | mainViewController.delegate = self 135 | 136 | // informing the VC about user interacting with the toolbar 137 | self.delegate = mainViewController 138 | } 139 | 140 | // MARK: - Actions 141 | 142 | @objc private func selectAction(sender: NSMenuItem) { 143 | let groupName = sender.title 144 | delegate?.userDidRequestLocalizationGroupChange(group: groupName) 145 | } 146 | 147 | @objc private func filterAction(sender: NSMenuItem) { 148 | guard let filter = Filter(rawValue: sender.tag) else { 149 | return 150 | } 151 | 152 | delegate?.userDidRequestFilterChange(filter: filter) 153 | } 154 | 155 | @IBAction private func openFolder(_ sender: Any) { 156 | delegate?.userDidRequestFolderOpen() 157 | } 158 | 159 | @IBAction private func addAction(_ sender: Any) { 160 | guard newButton.isEnabled else { 161 | return 162 | } 163 | 164 | delegate?.userDidRequestAddNewTranslation() 165 | } 166 | 167 | @objc private func openFolderAction(_ sender: NSMenuItem) { 168 | delegate?.userDidRequestFolderOpen() 169 | } 170 | 171 | @objc private func reloadDataAction(_ sender: NSMenuItem) { 172 | delegate?.userDidRequestReloadData() 173 | } 174 | } 175 | 176 | // MARK: - NSSearchFieldDelegate 177 | 178 | extension WindowController: NSSearchFieldDelegate { 179 | func controlTextDidChange(_ obj: Notification) { 180 | delegate?.userDidRequestSearch(searchTerm: searchField.stringValue) 181 | } 182 | } 183 | 184 | // MARK: - ViewControllerDelegate 185 | 186 | extension WindowController: ViewControllerDelegate { 187 | /** 188 | Invoked when localization groups should be set in the toolbar's dropdown list 189 | */ 190 | func shouldSetLocalizationGroups(groups: [LocalizationGroup]) { 191 | selectButton.menu?.removeAllItems() 192 | groups.map({ NSMenuItem(title: $0.name, action: #selector(WindowController.selectAction(sender:)), keyEquivalent: "") }).forEach({ selectButton.menu?.addItem($0) }) 193 | } 194 | 195 | /** 196 | Invoiked when search and filter should be reset in the toolbar 197 | */ 198 | func shouldResetSearchTermAndFilter() { 199 | setupSearch() 200 | setupFilter() 201 | 202 | delegate?.userDidRequestSearch(searchTerm: "") 203 | delegate?.userDidRequestFilterChange(filter: .all) 204 | } 205 | 206 | /** 207 | Invoked when localization group should be selected in the toolbar's dropdown list 208 | */ 209 | func shouldSelectLocalizationGroup(title: String) { 210 | enableControls() 211 | selectButton.selectItem(at: selectButton.indexOfItem(withTitle: title)) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/InfoPList-en.strings: -------------------------------------------------------------------------------- 1 | /* 2 | InfoPList-en.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 16/12/2018. 6 | Copyright © 2018 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "one" = "Number one"; 10 | "two" = "Number two"; 11 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/InfoPList-sk.strings: -------------------------------------------------------------------------------- 1 | /* 2 | InfoPList-sk.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 16/12/2018. 6 | Copyright © 2018 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "one" = "Číslo jedna"; 10 | "two" = "Číslo dva"; 11 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/LocalizableStrings-en-with-complete-messages.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iOSSampleApp 4 | 5 | Created by Igor Kulman on 03/10/2017. 6 | Copyright © 2017 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | /* Commenting Message for About */ 10 | "about" = "About"; 11 | 12 | /* Commenting Message */ 13 | "add_custom" = "Add custom"; 14 | 15 | /* Commenting Message */ 16 | "add_custom_source" = "Add custom source"; 17 | 18 | /* Commenting Message for About Author */ 19 | "author" = "About author"; 20 | 21 | /* Commenting Message for Back */ 22 | "back" = "Back"; 23 | 24 | /* Commenting Message for Feed URL */ 25 | "bad_url" = "Invalid RSS feed URL"; 26 | 27 | /* Commenting Message for Author's blog */ 28 | "blog" = "Author's blog"; 29 | 30 | // Commenting Message with "quotes" 31 | "done" = "Done"; 32 | 33 | /* Commenting Message for Feed */ 34 | "feed" = "Feed"; 35 | 36 | /* Commenting Message for Used Libraries. Long message is long. Longer message is even longer! */ 37 | "libraries" = "Used libraries"; 38 | 39 | /* Commenting Message for Logo URL */ 40 | "logo_url" = "Logo URL"; 41 | 42 | /* Commenting Message for Network Problems */ 43 | "network_problem" = "Network problem has occured"; 44 | 45 | /* Commenting Message for Optional */ 46 | "optional" = "Optional"; 47 | 48 | /* Commenting Message for Pull to Refresh */ 49 | "pull_to_refresh" = "Pull to refresh"; 50 | 51 | /* Commenting Message for RSS URL */ 52 | "rss_url" = "RSS URL"; 53 | 54 | /* Commenting Message */ 55 | "select_source" = "Select source"; 56 | 57 | /* Commenting Message for Title */ 58 | "title" = "Title"; 59 | 60 | /* Commenting Message for URL */ 61 | "url" = "Url"; 62 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/LocalizableStrings-en-with-incomplete-messages.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iOSSampleApp 4 | 5 | Created by Igor Kulman on 03/10/2017. 6 | Copyright © 2017 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | /* Commenting Message for About */ 10 | "about" = "About"; 11 | 12 | /* Commenting Message */ 13 | "add_custom" = "Add custom"; 14 | 15 | /* Commenting Message */ 16 | "add_custom_source" = "Add custom source"; 17 | 18 | /* Commenting Message for About Author */ 19 | "author" = "About author"; 20 | 21 | "back" = "Back"; 22 | 23 | /* Commenting Message for Feed URL */ 24 | "bad_url" = "Invalid RSS feed URL"; 25 | 26 | /* Commenting Message for Author's blog */ 27 | "blog" = "Author's blog"; 28 | 29 | "done" = "Done"; 30 | 31 | /* Commenting Message for Feed */ 32 | "feed" = "Feed"; 33 | 34 | "libraries" = "Used libraries"; 35 | 36 | /* Commenting Message for Logo URL */ 37 | "logo_url" = "Logo URL"; 38 | 39 | /* Commenting Message for Network Problems */ 40 | "network_problem" = "Network problem has occured"; 41 | 42 | "optional" = "Optional"; 43 | 44 | /* Commenting Message for Pull to Refresh */ 45 | "pull_to_refresh" = "Pull to refresh"; 46 | 47 | /* Commenting Message for RSS URL */ 48 | "rss_url" = "RSS URL"; 49 | 50 | "select_source" = "Select source"; 51 | 52 | "title" = "Title"; 53 | 54 | /* Commenting Message for URL */ 55 | "url" = "Url"; 56 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/LocalizableStrings-en.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iOSSampleApp 4 | 5 | Created by Igor Kulman on 03/10/2017. 6 | Copyright © 2017 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "select_source" = "Select source"; 10 | "done" = "Done"; 11 | "add_custom" = "Add custom"; 12 | "add_custom_source" = "Add custom source"; 13 | "url" = "Url"; 14 | "title" = "Title"; 15 | "logo_url" = "Logo URL"; 16 | "rss_url" = "RSS URL"; 17 | "optional" = "Optional"; 18 | "feed" = "Feed"; 19 | "pull_to_refresh" = "Pull to refresh"; 20 | "bad_url" = "Invalid RSS feed URL"; 21 | "network_problem" = "Network problem has occured"; 22 | "back" = "Back"; 23 | "about" = "About"; 24 | "author" = "About author"; 25 | "blog" = "Author's blog"; 26 | "libraries" = "Used libraries"; 27 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/LocalizableStrings-sk-missing.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iOSSampleApp 4 | 5 | Created by Igor Kulman on 03/10/2017. 6 | Copyright © 2017 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "select_source" = "Vybrať zdroj"; 10 | "done" = "Hotovo"; 11 | "add_custom" = "Pridať vlastný"; 12 | "add_custom_source" = "Pridať vlastný zdroj"; 13 | "url" = "Url"; 14 | "title" = "Názov"; 15 | "logo_url" = "URL loga"; 16 | "rss_url" = "URL RSS"; 17 | "optional" = "Voliteľné"; 18 | "feed" = "Feed"; 19 | "pull_to_refresh" = "Aktualizujte potiahnutím"; 20 | "bad_url" = "Neplatná URL pre RSS"; 21 | "author" = "O autorovi"; 22 | "blog" = "Autorov blog"; 23 | "libraries" = "Použité knižnice"; 24 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/LocalizableStrings-sk.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iOSSampleApp 4 | 5 | Created by Igor Kulman on 03/10/2017. 6 | Copyright © 2017 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | "select_source" = "Vybrať zdroj"; 10 | "done" = "Hotovo"; 11 | "add_custom" = "Pridať vlastný"; 12 | "add_custom_source" = "Pridať vlastný zdroj"; 13 | "url" = "Url"; 14 | "title" = "Názov"; 15 | "logo_url" = "URL loga"; 16 | "rss_url" = "URL RSS"; 17 | "optional" = "Voliteľné"; 18 | "feed" = "Feed"; 19 | "pull_to_refresh" = "Aktualizujte potiahnutím"; 20 | "bad_url" = "Neplatná URL pre RSS"; 21 | "network_problem" = "Chyba pripojenia"; 22 | "back" = "Späť"; 23 | "about" = "O aplikácii"; 24 | "author" = "O autorovi"; 25 | "blog" = "Autorov blog"; 26 | "libraries" = "Použité knižnice"; 27 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Data/Special.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Special.strings 3 | LocalizationEditor 4 | 5 | Created by Igor Kulman on 14/03/2019. 6 | Copyright © 2019 Igor Kulman. All rights reserved. 7 | */ 8 | 9 | /* The presence of a non-quoted string and a quoted string would previously cause the quoted string to be dropped. This string is here to check for regressions in quoted string parsing. */ 10 | "regression_test" = "DO NOT DELETE"; 11 | 12 | "quoted" = "\"http://\" or \"https://\""; 13 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Extensions/XCTestCase+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+Extensions.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 16/12/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | extension XCTest { 13 | private func getFullPath(for fileName: String) -> URL { 14 | let bundle = Bundle(for: type(of: self)) 15 | return bundle.bundleURL.appendingPathComponent("Contents").appendingPathComponent("Resources").appendingPathComponent(fileName) 16 | } 17 | 18 | func createTestingDirectory(with files: [TestFile]) -> URL { 19 | let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) 20 | for file in try! FileManager.default.contentsOfDirectory(atPath: tmp.path) { 21 | try? FileManager.default.removeItem(at: tmp.appendingPathComponent(file)) 22 | } 23 | for file in files { 24 | try? FileManager.default.createDirectory(at: tmp.appendingPathComponent(file.destinationFolder), withIntermediateDirectories: false, attributes: nil) 25 | try? FileManager.default.removeItem(at: tmp.appendingPathComponent(file.destinationFolder).appendingPathComponent(file.destinationFileName)) 26 | try! FileManager.default.copyItem(at: getFullPath(for: file.originalFileName), to: tmp.appendingPathComponent(file.destinationFolder).appendingPathComponent(file.destinationFileName)) 27 | } 28 | return tmp 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/LoalizationProviderUpdatingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoalizationProviderUpdatingTests.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 16/12/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import LocalizationEditor 12 | 13 | class LoalizationProviderUpdatingTests: XCTestCase { 14 | 15 | func testUpdatingValuesInSingleLanguage() { 16 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 17 | let provider = LocalizationProvider() 18 | let groups = provider.getLocalizations(url: directoryUrl) 19 | 20 | let baseLocalization = groups[0].localizations[0] 21 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: "New value line 2", message: baseLocalization.translations[2].message) 22 | let updated = provider.getLocalizations(url: directoryUrl) 23 | 24 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].key : "New value line 2"]] 25 | testLocalizationsMatch(base: groups, updated: updated, changes: changes) 26 | } 27 | 28 | func testUpdatingValuesInSingleLanguageWithCompleteComments() { 29 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en-with-complete-messages.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 30 | let provider = LocalizationProvider() 31 | let groups = provider.getLocalizations(url: directoryUrl) 32 | 33 | let baseLocalization = groups[0].localizations[0] 34 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: "New value line 2", message: baseLocalization.translations[2].message) 35 | let updated = provider.getLocalizations(url: directoryUrl) 36 | 37 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].key : "New value line 2"]] 38 | testLocalizationsMatch(base: groups, updated: updated, changes: changes) 39 | } 40 | 41 | func testUpdatingValuesInSingleLanguageWithIncompleteComments() { 42 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en-with-incomplete-messages.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 43 | let provider = LocalizationProvider() 44 | let groups = provider.getLocalizations(url: directoryUrl) 45 | 46 | let baseLocalization = groups[0].localizations[0] 47 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: "New value line 2", message: baseLocalization.translations[2].message) 48 | let updated = provider.getLocalizations(url: directoryUrl) 49 | 50 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].key : "New value line 2"]] 51 | testLocalizationsMatch(base: groups, updated: updated, changes: changes) 52 | } 53 | 54 | 55 | func testUpdatingMessagesInSingleLanguageWithCompleteComments() { 56 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en-with-complete-messages.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 57 | let provider = LocalizationProvider() 58 | let groups = provider.getLocalizations(url: directoryUrl) 59 | 60 | let baseLocalization = groups[0].localizations[0] 61 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: baseLocalization.translations[2].value, message: "New Message line 2") 62 | let updated = provider.getLocalizations(url: directoryUrl) 63 | 64 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].message! : "New Message line 2"]] 65 | testLocalizationsMatch(base: groups, updated: updated, changes: changes, onlyMessagesChanged: true) 66 | } 67 | 68 | func testUpdatingMessagesInSingleLanguageWithIncompleteComments() { 69 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en-with-incomplete-messages.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 70 | let provider = LocalizationProvider() 71 | let groups = provider.getLocalizations(url: directoryUrl) 72 | 73 | let baseLocalization = groups[0].localizations[0] 74 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: baseLocalization.translations[2].value, message: "New Message line 2") 75 | let updated = provider.getLocalizations(url: directoryUrl) 76 | 77 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].message! : "New Message line 2"]] 78 | testLocalizationsMatch(base: groups, updated: updated, changes: changes, onlyMessagesChanged: true) 79 | } 80 | 81 | 82 | func testUpdatingValuesInMultipleLanguage() { 83 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")]) 84 | let provider = LocalizationProvider() 85 | let groups = provider.getLocalizations(url: directoryUrl) 86 | 87 | let baseLocalization = groups[0].localizations[0] 88 | provider.updateLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key, with: "New value line 2", message: baseLocalization.translations[2].message) 89 | let updated = provider.getLocalizations(url: directoryUrl) 90 | 91 | let changes: [String: [String: String]] = ["Base": [baseLocalization.translations[2].key : "New value line 2"]] 92 | testLocalizationsMatch(base: groups, updated: updated, changes: changes) 93 | } 94 | 95 | func testUpdatingValuesInMultipleLanguagesForSecondLanguage() { 96 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")]) 97 | let provider = LocalizationProvider() 98 | let groups = provider.getLocalizations(url: directoryUrl) 99 | 100 | let skLocalization = groups[0].localizations[1] 101 | provider.updateLocalization(localization: skLocalization, key: skLocalization.translations[2].key, with: "New value line 2 SK", message: skLocalization.translations[2].message) 102 | let updated = provider.getLocalizations(url: directoryUrl) 103 | 104 | let changes: [String: [String: String]] = ["sk": [skLocalization.translations[2].key : "New value line 2 SK"]] 105 | testLocalizationsMatch(base: groups, updated: updated, changes: changes, onlyMessagesChanged: true) 106 | } 107 | 108 | func testUpdatingMissingValue() { 109 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk-missing.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")]) 110 | let provider = LocalizationProvider() 111 | let groups = provider.getLocalizations(url: directoryUrl) 112 | 113 | let skLocalization = groups[0].localizations[1] 114 | provider.updateLocalization(localization: skLocalization, key: "about", with: "O aplikácií", message: nil) 115 | let updated = provider.getLocalizations(url: directoryUrl) 116 | 117 | let changes: [String: [String: String]] = ["sk": ["about" : "O aplikácií"]] 118 | testLocalizationsMatch(base: groups, updated: updated, changes: changes) 119 | } 120 | 121 | private func testLocalizationsMatch(base: [LocalizationGroup], updated: [LocalizationGroup], changes: [String: [String: String]], onlyMessagesChanged: Bool = false) { 122 | XCTAssertEqual(base.count, updated.count) 123 | for i in 0.. Bool in 136 | return changesForLanguage.map({$0.key}).contains(string.key) 137 | } 138 | XCTAssertEqual(changesForLanguage.count, existingKeys.count) 139 | } 140 | } 141 | 142 | for key in baseKeys { 143 | let originalValue = base[i].localizations[j].translations.first(where: { $0.key == key })?.value 144 | let updatedValue = updated[i].localizations[j].translations.first(where: { $0.key == key })?.value 145 | 146 | XCTAssertFalse(originalValue == nil) 147 | 148 | let originalMessage = base[i].localizations[j].translations.first(where: { $0.key == key })?.message 149 | let updatedMessage = updated[i].localizations[j].translations.first(where: { $0.key == key })?.message 150 | XCTAssertEqual(originalMessage, updatedMessage) 151 | 152 | if let lang = changes[base[i].localizations[j].language], let newValue = lang[key] { 153 | XCTAssertEqual(updatedValue, newValue) 154 | } else { 155 | XCTAssertEqual(originalValue, updatedValue) 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/LocalizationProviderAddingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationProviderAddingTests.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 14/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import LocalizationEditor 12 | 13 | class LocalizationProviderAddingTests: XCTestCase { 14 | func testAddingValuesInSingleLanguage() { 15 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 16 | let provider = LocalizationProvider() 17 | let groups = provider.getLocalizations(url: directoryUrl) 18 | 19 | let baseLocalization = groups[0].localizations[0] 20 | let count = groups[0].localizations[0].translations.count 21 | _ = provider.addKeyToLocalization(localization: baseLocalization, key: "test", message: "test key") 22 | let updated = provider.getLocalizations(url: directoryUrl) 23 | 24 | XCTAssertEqual(updated.count, groups.count) 25 | XCTAssertEqual(groups[0].localizations.count, groups[0].localizations.count) 26 | XCTAssertEqual(groups[0].localizations[0].translations.count, count + 1) 27 | XCTAssert(groups[0].localizations[0].translations.contains(where: { $0.key == "test"})) 28 | XCTAssertEqual(updated[0].localizations.count, groups[0].localizations.count) 29 | XCTAssertEqual(updated[0].localizations[0].translations.count, count + 1) 30 | XCTAssert(updated[0].localizations[0].translations.contains(where: { $0.key == "test"})) 31 | } 32 | 33 | func testAddingValuesInMultipleLanguage() { 34 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")]) 35 | let provider = LocalizationProvider() 36 | let groups = provider.getLocalizations(url: directoryUrl) 37 | 38 | let baseLocalization = groups[0].localizations[0] 39 | let count = groups[0].localizations[0].translations.count 40 | let countOther = groups[0].localizations[1].translations.count 41 | _ = provider.addKeyToLocalization(localization: baseLocalization, key: "test", message: "test key") 42 | let updated = provider.getLocalizations(url: directoryUrl) 43 | 44 | XCTAssertEqual(updated.count, groups.count) 45 | XCTAssertEqual(groups[0].localizations.count, groups[0].localizations.count) 46 | XCTAssertEqual(groups[0].localizations[0].translations.count, count + 1) 47 | XCTAssertEqual(groups[0].localizations[1].translations.count, countOther) 48 | XCTAssert(groups[0].localizations[0].translations.contains(where: { $0.key == "test"})) 49 | XCTAssertEqual(updated[0].localizations.count, groups[0].localizations.count) 50 | XCTAssertEqual(updated[0].localizations[0].translations.count, count + 1) 51 | XCTAssertEqual(updated[0].localizations[1].translations.count, countOther) 52 | XCTAssert(updated[0].localizations[0].translations.contains(where: { $0.key == "test"})) 53 | XCTAssert(!updated[0].localizations[1].translations.contains(where: { $0.key == "test"})) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/LocalizationProviderDeletingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationProviderDeletingTests.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 06/03/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import LocalizationEditor 12 | 13 | class LocalizationProviderDeletingTests: XCTestCase { 14 | func testDeletingValuesInSingleLanguage() { 15 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")]) 16 | let provider = LocalizationProvider() 17 | let groups = provider.getLocalizations(url: directoryUrl) 18 | 19 | let baseLocalization = groups[0].localizations[0] 20 | let count = groups[0].localizations[0].translations.count 21 | let key = baseLocalization.translations[2].key 22 | provider.deleteKeyFromLocalization(localization: baseLocalization, key: key) 23 | let updated = provider.getLocalizations(url: directoryUrl) 24 | 25 | XCTAssertEqual(updated.count, groups.count) 26 | XCTAssertEqual(groups[0].localizations.count, groups[0].localizations.count) 27 | XCTAssertEqual(groups[0].localizations[0].translations.count, count - 1) 28 | XCTAssert(!groups[0].localizations[0].translations.contains(where: { $0.key == key})) 29 | XCTAssertEqual(updated[0].localizations.count, groups[0].localizations.count) 30 | XCTAssertEqual(updated[0].localizations[0].translations.count, count - 1) 31 | XCTAssert(!updated[0].localizations[0].translations.contains(where: { $0.key == key})) 32 | } 33 | 34 | func testDeletingValuesInMultipleLanguage() { 35 | let directoryUrl = createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")]) 36 | let provider = LocalizationProvider() 37 | let groups = provider.getLocalizations(url: directoryUrl) 38 | 39 | let baseLocalization = groups[0].localizations[0] 40 | let key = baseLocalization.translations[2].key 41 | let count = groups[0].localizations[0].translations.count 42 | let countOther = groups[0].localizations[1].translations.count 43 | provider.deleteKeyFromLocalization(localization: baseLocalization, key: baseLocalization.translations[2].key) 44 | let updated = provider.getLocalizations(url: directoryUrl) 45 | 46 | XCTAssertEqual(updated.count, groups.count) 47 | XCTAssertEqual(groups[0].localizations.count, groups[0].localizations.count) 48 | XCTAssertEqual(groups[0].localizations[0].translations.count, count - 1) 49 | XCTAssertEqual(groups[0].localizations[1].translations.count, countOther) 50 | XCTAssert(!groups[0].localizations[0].translations.contains(where: { $0.key == key})) 51 | XCTAssertEqual(updated[0].localizations.count, groups[0].localizations.count) 52 | XCTAssertEqual(updated[0].localizations[0].translations.count, count - 1) 53 | XCTAssertEqual(updated[0].localizations[1].translations.count, countOther) 54 | XCTAssert(!updated[0].localizations[0].translations.contains(where: { $0.key == key})) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/LocalizationProviderParsingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizationEditorTests.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 16/12/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import AppKit 11 | @testable import LocalizationEditor 12 | 13 | class LocalizationProviderParsingTests: XCTestCase { 14 | 15 | func testSingleLanguageParsing() { 16 | let provider = LocalizationProvider() 17 | let groups = provider.getLocalizations(url: createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")])) 18 | 19 | XCTAssertEqual(groups.count, 1) 20 | XCTAssertEqual(groups[0].name, "LocalizableStrings.strings") 21 | XCTAssertEqual(groups[0].localizations.count, 1) 22 | XCTAssertEqual(groups[0].localizations[0].language, "Base") 23 | XCTAssertEqual(groups[0].localizations[0].translations.count, 18) 24 | } 25 | 26 | func testMultipleLanguagesParsing() { 27 | let provider = LocalizationProvider() 28 | let groups = provider.getLocalizations(url: createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")])) 29 | 30 | XCTAssertEqual(groups.count, 1) 31 | XCTAssertEqual(groups[0].name, "LocalizableStrings.strings") 32 | XCTAssertEqual(groups[0].localizations.count, 2) 33 | XCTAssertEqual(groups[0].localizations[0].language, "Base") 34 | XCTAssertEqual(groups[0].localizations[1].language, "sk") 35 | XCTAssertEqual(groups[0].localizations[0].translations.count, 18) 36 | XCTAssertEqual(groups[0].localizations[1].translations.count, 18) 37 | } 38 | 39 | func testMultipleLanguageParsingWithMissingTranslations() { 40 | let provider = LocalizationProvider() 41 | let groups = provider.getLocalizations(url: createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk-missing.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj")])) 42 | 43 | XCTAssertEqual(groups.count, 1) 44 | XCTAssertEqual(groups[0].name, "LocalizableStrings.strings") 45 | XCTAssertEqual(groups[0].localizations.count, 2) 46 | XCTAssertEqual(groups[0].localizations[0].language, "Base") 47 | XCTAssertEqual(groups[0].localizations[1].language, "sk") 48 | XCTAssertEqual(groups[0].localizations[0].translations.count, 18) 49 | XCTAssertEqual(groups[0].localizations[1].translations.count, 15) 50 | } 51 | 52 | func testMultipleGroupsAndLanguagesParsing() { 53 | let provider = LocalizationProvider() 54 | let groups = provider.getLocalizations(url: createTestingDirectory(with: [TestFile(originalFileName: "LocalizableStrings-en.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "LocalizableStrings-sk.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "sk.lproj"),TestFile(originalFileName: "InfoPlist-en.strings", destinationFileName: "InfoPlist.strings", destinationFolder: "Base.lproj"), TestFile(originalFileName: "InfoPlist-sk.strings", destinationFileName: "InfoPlist.strings", destinationFolder: "sk.lproj")])) 55 | 56 | XCTAssertEqual(groups.count, 2) 57 | 58 | XCTAssertEqual(groups[0].name, "InfoPlist.strings") 59 | XCTAssertEqual(groups[0].localizations.count, 2) 60 | XCTAssertEqual(groups[0].localizations[0].language, "Base") 61 | XCTAssertEqual(groups[0].localizations[1].language, "sk") 62 | XCTAssertEqual(groups[0].localizations[0].translations.count, 2) 63 | XCTAssertEqual(groups[0].localizations[1].translations.count, 2) 64 | 65 | XCTAssertEqual(groups[1].name, "LocalizableStrings.strings") 66 | XCTAssertEqual(groups[1].localizations.count, 2) 67 | XCTAssertEqual(groups[1].localizations[0].language, "Base") 68 | XCTAssertEqual(groups[1].localizations[1].language, "sk") 69 | XCTAssertEqual(groups[1].localizations[0].translations.count, 18) 70 | XCTAssertEqual(groups[1].localizations[1].translations.count, 18) 71 | } 72 | 73 | func testQuotesParsing() { 74 | let provider = LocalizationProvider() 75 | let groups = provider.getLocalizations(url: createTestingDirectory(with: [TestFile(originalFileName: "Special.strings", destinationFileName: "LocalizableStrings.strings", destinationFolder: "Base.lproj")])) 76 | 77 | XCTAssertEqual(groups[0].localizations[0].translations.first(where: {$0.key == "quoted"})?.value, "\"http://\" or \"https://\"") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/ParserIsolationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserIsolationTests.swift 3 | // LocalizationEditor 4 | // 5 | // Created by Andreas Neusüß on 30.12.18. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import LocalizationEditor 12 | 13 | class LocalizationEditor: XCTestCase { 14 | 15 | func testInputValidNoMessage() { 16 | let inputString = 17 | """ 18 | "ART_AND_CULTURE" = "Kunst und Kultur"; 19 | 20 | "BACK" = "Zurück"; 21 | 22 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 23 | """ 24 | let parser = Parser(input: inputString) 25 | let result = try! parser.parse() 26 | 27 | XCTAssertEqual(result.count, 3) 28 | XCTAssertEqual(result[0].key, "ART_AND_CULTURE") 29 | XCTAssertEqual(result[1].key, "BACK") 30 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 31 | 32 | XCTAssertEqual(result[0].value, "Kunst und Kultur") 33 | XCTAssertEqual(result[1].value, "Zurück") 34 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 35 | } 36 | 37 | func testInputValidNoMessageNoEscapingNeeded() { 38 | let inputString = 39 | """ 40 | "ART_"AND"_CULTURE" = "Kunst "und" Kultur"; 41 | 42 | "BACK" = "Zurück"; 43 | 44 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 45 | """ 46 | 47 | let escapingParser = Parser(input: inputString) 48 | let escapingResult = try! escapingParser.parse() 49 | 50 | XCTAssertEqual(escapingResult.count, 3) 51 | XCTAssertEqual(escapingResult[0].key, "ART_\"AND\"_CULTURE") 52 | XCTAssertEqual(escapingResult[1].key, "BACK") 53 | XCTAssertEqual(escapingResult[2].key, "BIRTHDAY_SELECT") 54 | 55 | XCTAssertEqual(escapingResult[0].value, "Kunst \"und\" Kultur") 56 | XCTAssertEqual(escapingResult[1].value, "Zurück") 57 | XCTAssertEqual(escapingResult[2].value, "Bitte wähle deinen Geburtstag aus") 58 | 59 | XCTAssertNil(escapingResult[0].message) 60 | XCTAssertNil(escapingResult[1].message) 61 | XCTAssertNil(escapingResult[2].message) 62 | } 63 | 64 | func testInputValidWithMultilineMessage() { 65 | let inputString = 66 | """ 67 | /* The string for "the art and culture category */ 68 | "ART_"AND"_CULTURE" = "Kunst "und" Kultur"; 69 | 70 | /* String for back operation */ 71 | "BACK" = "Zurück"; 72 | 73 | /* Select your birhtday */ 74 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 75 | """ 76 | let parser = Parser(input: inputString) 77 | let result = try! parser.parse() 78 | 79 | XCTAssertEqual(result.count, 3) 80 | XCTAssertEqual(result[0].key, "ART_\"AND\"_CULTURE") 81 | XCTAssertEqual(result[1].key, "BACK") 82 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 83 | 84 | XCTAssertEqual(result[0].value, "Kunst \"und\" Kultur") 85 | XCTAssertEqual(result[1].value, "Zurück") 86 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 87 | 88 | XCTAssertEqual(result[0].message, "The string for \"the art and culture category") 89 | XCTAssertEqual(result[1].message, "String for back operation") 90 | XCTAssertEqual(result[2].message, "Select your birhtday") 91 | } 92 | 93 | func testInputValidWithSinglelineMessageTrailing() { 94 | let inputString = 95 | """ 96 | "Start %@" = "Empieza a %@"; // e.g., "Start bouldering", "Start Top Range" 97 | 98 | "BACK" = "Zurück"; // String for "back operation" 99 | 100 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; // Select your birhtday 101 | """ 102 | let parser = Parser(input: inputString) 103 | let result = try! parser.parse() 104 | 105 | XCTAssertEqual(result.count, 3) 106 | XCTAssertEqual(result[0].key, "Start %@") 107 | XCTAssertEqual(result[1].key, "BACK") 108 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 109 | 110 | XCTAssertEqual(result[0].value, "Empieza a %@") 111 | XCTAssertEqual(result[1].value, "Zurück") 112 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 113 | 114 | XCTAssertEqual(result[0].message, "e.g., \"Start bouldering\", \"Start Top Range\"") 115 | XCTAssertEqual(result[1].message, "String for \"back operation\"") 116 | XCTAssertEqual(result[2].message, "Select your birhtday") 117 | } 118 | 119 | func testInputValidWithSinglelineMessage() { 120 | let inputString = 121 | """ 122 | // e.g., "Start bouldering", "Start Top Range" 123 | "Start %@" = "Empieza a %@"; 124 | 125 | // String for "back operation" 126 | "BACK" = "Zurück"; 127 | 128 | // Select your birhtday 129 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 130 | """ 131 | let parser = Parser(input: inputString) 132 | let result = try! parser.parse() 133 | 134 | XCTAssertEqual(result.count, 3) 135 | XCTAssertEqual(result[0].key, "Start %@") 136 | XCTAssertEqual(result[1].key, "BACK") 137 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 138 | 139 | XCTAssertEqual(result[0].value, "Empieza a %@") 140 | XCTAssertEqual(result[1].value, "Zurück") 141 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 142 | 143 | XCTAssertEqual(result[0].message, "e.g., \"Start bouldering\", \"Start Top Range\"") 144 | XCTAssertEqual(result[1].message, "String for \"back operation\"") 145 | XCTAssertEqual(result[2].message, "Select your birhtday") 146 | } 147 | 148 | func testInputValidWithMessageContainingGarbage() { 149 | let inputString = 150 | """ 151 | garbage garbage... 152 | /* The string for "the art and culture category */ 153 | garbage garbage... 154 | "ART_"AND"_CULTURE" = "Kunst \"und\" Kultur"; 155 | 156 | garbage garbage... 157 | /* String for back operation */ 158 | "BACK" = "Zurück"; 159 | 160 | /* Select your birhtday */ 161 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 162 | """ 163 | let parser = Parser(input: inputString) 164 | let result = try! parser.parse() 165 | 166 | XCTAssertEqual(result.count, 3) 167 | XCTAssertEqual(result[0].key, "ART_\"AND\"_CULTURE") 168 | XCTAssertEqual(result[1].key, "BACK") 169 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 170 | 171 | XCTAssertEqual(result[0].value, "Kunst \"und\" Kultur") 172 | XCTAssertEqual(result[1].value, "Zurück") 173 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 174 | 175 | XCTAssertEqual(result[0].message, "The string for \"the art and culture category") 176 | XCTAssertEqual(result[1].message, "String for back operation") 177 | XCTAssertEqual(result[2].message, "Select your birhtday") 178 | } 179 | 180 | func testInputValidWithMessageContainingLicenseHeader() { 181 | let inputString = 182 | """ 183 | // 184 | // ParserIsolationTests.swift 185 | // LocalizationEditor 186 | // 187 | // Created by Andreas Neusüß on 30.12.18. 188 | // Copyright © 2018 Igor Kulman. All rights reserved. 189 | // 190 | /* Another header */ 191 | 192 | /* The string for "the art and culture category */ 193 | "ART_"AND"_CULTURE" = "Kunst "und" Kultur"; 194 | 195 | /* String for back operation */ 196 | "BACK" = "Zurück"; 197 | 198 | /* Select your birhtday */ 199 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; 200 | """ 201 | let parser = Parser(input: inputString) 202 | let result = try! parser.parse() 203 | 204 | XCTAssertEqual(result.count, 3) 205 | XCTAssertEqual(result[0].key, "ART_\"AND\"_CULTURE") 206 | XCTAssertEqual(result[1].key, "BACK") 207 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 208 | 209 | XCTAssertEqual(result[0].value, "Kunst \"und\" Kultur") 210 | XCTAssertEqual(result[1].value, "Zurück") 211 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 212 | 213 | XCTAssertEqual(result[0].message, "The string for \"the art and culture category") 214 | XCTAssertEqual(result[1].message, "String for back operation") 215 | XCTAssertEqual(result[2].message, "Select your birhtday") 216 | } 217 | 218 | func testInputValidWithTrailingMessage() { 219 | let inputString = 220 | """ 221 | // 222 | // ParserIsolationTests.swift 223 | // LocalizationEditor 224 | // 225 | // Created by Andreas Neusüß on 30.12.18. 226 | // Copyright © 2018 Igor Kulman. All rights reserved. 227 | // 228 | 229 | "ART_"AND"_CUL\nTURE" = "Kunst "und" Kultur"; /* The string for "the art and culture category */ 230 | 231 | "BACK" = "Zurück"; /* String for back operation */ 232 | 233 | "BIRTHDAY_SELECT" = "Bitte wähle deinen Geburtstag aus"; /* Select your birhtday */ 234 | """ 235 | 236 | let parser = Parser(input: inputString) 237 | let result = try! parser.parse() 238 | 239 | XCTAssertEqual(result.count, 3) 240 | XCTAssertEqual(result[0].key, "ART_\"AND\"_CUL\nTURE") 241 | XCTAssertEqual(result[1].key, "BACK") 242 | XCTAssertEqual(result[2].key, "BIRTHDAY_SELECT") 243 | 244 | XCTAssertEqual(result[0].value, "Kunst \"und\" Kultur") 245 | XCTAssertEqual(result[1].value, "Zurück") 246 | XCTAssertEqual(result[2].value, "Bitte wähle deinen Geburtstag aus") 247 | 248 | XCTAssertEqual(result[0].message, "The string for \"the art and culture category") 249 | XCTAssertEqual(result[1].message, "String for back operation") 250 | XCTAssertEqual(result[2].message, "Select your birhtday") 251 | } 252 | 253 | func testMalformattedInput() { 254 | let inputString1 = 255 | """ 256 | "ART_"AND"_CULTURE" = "Kunst "und" Kultur" 257 | """ 258 | let parser1 = Parser(input: inputString1) 259 | let result1 = try? parser1.parse() 260 | XCTAssertNil(result1) 261 | 262 | let inputString2 = 263 | """ 264 | "ART_"AND="_CULTURE" = "Kunst= "und" Kultur" 265 | """ 266 | let parser2 = Parser(input: inputString2) 267 | let result2 = try? parser2.parse() 268 | XCTAssertNil(result2) 269 | 270 | 271 | let inputString3 = 272 | """ 273 | "ART_"AND"_CULTURE" = ; 274 | """ 275 | let parser3 = Parser(input: inputString3) 276 | let result3 = try? parser3.parse() 277 | XCTAssertNil(result3) 278 | 279 | } 280 | 281 | } 282 | -------------------------------------------------------------------------------- /sources/LocalizationEditorTests/TestFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestFile.swift 3 | // LocalizationEditorTests 4 | // 5 | // Created by Igor Kulman on 16/12/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct TestFile { 12 | let originalFileName: String 13 | let destinationFileName: String 14 | let destinationFolder: String 15 | } 16 | --------------------------------------------------------------------------------