├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------