├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
└── Sources
└── LocoBuddy
├── Commands
├── Search.swift
└── Translate.swift
├── Extensions
├── Collection-SingleElement.swift
├── Print-Colors.swift
├── String-Initials.swift
├── XMLElement-OnlyChild.swift
└── XMLNode-OnlyChildText.swift
├── Internal
├── ANSIColors.swift
└── Parser.swift
├── Models
├── CustomError.swift
└── Entry.swift
└── main.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/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 julian@schiavo.me. 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 | # LocoBuddy Contribution Guide
2 |
3 | *Adapted from the [Plot Contribution Guide](https://github.com/JohnSundell/Plot/blob/master/CONTRIBUTING.md)*
4 |
5 | Welcome to the **LocoBuddy Contribution Guide**, which aims to give you all the information you need to contribute to LocoBuddy. Thank you for your interest in contributing to this project!
6 |
7 | ## Bugs, feature requests and support
8 |
9 | ### I found a bug, how do I report it?
10 |
11 | If you find a bug, such as an issue in parsing glossaries, please file a detailed Github Issue containing all of the information necessary to reproduce the bug. This helps us to quickly understand the bug and triage it.
12 |
13 | ### I have an idea for a feature request
14 |
15 | Awesome! You can either create a Github Issue describing your idea and how it would improve the project, or create a Pull Request with your feature. Creating a Pull Request contributes to the project and makes it more likely your idea will be added!
16 |
17 | ### I have a question
18 |
19 | Please make sure you read the documentation (inline in the source files) and [README](README.md) carefully. If your question is still not answered, file a Github Issue detailing your question and what steps you took already.
20 |
21 | ## Project structure
22 |
23 | LocoBuddy is structured in the default way for Swift Packages. Source code is in the `Source/LocoBuddy` directory, with each type in a separate file.
24 |
25 | ## Conclusion
26 |
27 | Hopefully this document helped you better understand how LocoBuddy is structured and the best way to get help or contribute to the project. Thanks again for the interest!
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Julian Schiavo
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 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "swift-argument-parser",
6 | "repositoryURL": "https://github.com/apple/swift-argument-parser",
7 | "state": {
8 | "branch": null,
9 | "revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc",
10 | "version": "0.3.2"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "LocoBuddy",
8 | platforms: [
9 | .macOS(.v10_14)
10 | ],
11 | products: [
12 | .executable(name: "LocoBuddy", targets: ["LocoBuddy"])
13 | ],
14 | dependencies: [
15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.2")
16 | ],
17 | targets: [
18 | .target(name: "LocoBuddy", dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")])
19 | ]
20 | )
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🗣 LocoBuddy
2 |
3 | **LocoBuddy** is a Swift command-line tool that makes it easier to use and search Apple's `AppleGlot` [translation glossaries](https://developer.apple.com/download/more/), which are exports of translations used in Apple's system apps, such as Maps and Home, for all system languages.
4 |
5 | However, these translation glossaries are provided in `lg` file formats, and separated per each system app. LocoBuddy handles parsing these glossaries to search for specific terms and translate a found term into any number of given languages.
6 |
7 | This package is heavily inspired by and uses code adapted from [Douglas' in depth blog post on parsing these glossaries](https://douglashill.co/localisation-using-apples-glossaries/).
8 |
9 |
10 |
11 | ## Requirements
12 |
13 | **LocoBuddy** requires **macOS 11+** and is used directly on the command-line (Terminal). The command-line interface is created through Apple's [Swift Argument Parser](https://github.com/apple/swift-argument-parser) library, which is added through Swift Package Manager.
14 |
15 | *SF Symbols are used in output, so it is recommended to use an Apple SF font, such as SF Mono, on Terminal, but if not the tool will work and display unicode unknown symbols instead.*
16 |
17 |
18 |
19 | ## Installation
20 |
21 | You can download the latest build of **LocoBuddy**, or build it from the source in this repository.
22 |
23 | ### Prebuilt Install
24 |
25 | Download the `LocoBuddy` binary in the [latest release](https://github.com/julianschiavo/LocoBuddy/releases/latest), then run it from Terminal.
26 |
27 | ### Manual Build
28 |
29 | Clone this repository, then run `swift build` in the source folder using Terminal, and find the binary in the `.build/debug` or `.build/release` folder.
30 |
31 |
32 |
33 | ## Usage
34 |
35 | This tool requires Apple's translation glossaries to be downloaded and mounted; a valid Apple Developer account is required to download the glossaries. [Download](https://developer.apple.com/download/more/) the glossary languages you want from Apple's website by searching for glossaries, then mount the `dmg` disk images by double-clicking on each one.
36 |
37 | After mounting the disk images, search for terms in the glossaries you downloaded. For example, to search for "Book" in the Simplified Chinese glossary:
38 |
39 | ```bash
40 | LocoBuddy search --language "Simplified Chinese" --match contains "Book"
41 | ```
42 | ```
43 | Found 12 strings matching or containing `Cancel Ride`!
44 |
45 | Add to Bookmarks
46 | 加入書籤
47 | URL_ACTION_BOOKMARK
48 | ContactsUI.lg
49 | ...
50 | ```
51 |
52 | You can then translate a specific key (e.g. `URL_ACTION_BOOKMARK`) into one or more languages, using the initials or full names of the glossaries, at the same time:
53 |
54 | (SC is Simplified Chinese, TC is Traditional Chinese)
55 |
56 | ```bash
57 | LocoBuddy translate --key "URL_ACTION_BOOKMARK" "SC" "TC"
58 | ```
59 |
60 | ```
61 | 加入書籤
62 | ContactsUI.lg
63 | Traditional Chinese
64 |
65 | 添加到书签
66 | ContactsUI.lg
67 | Simplified Chinese
68 | ...
69 | ```
70 |
71 |
72 |
73 | ## Contributing
74 |
75 | Contributions and pull requests are welcomed by anyone! If you find an issue with **LocoBuddy**, file a Github Issue, or, if you know how to fix it, submit a pull request.
76 |
77 | Please review our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contribution Guidelines](CONTRIBUTING.md) before making a contribution.
78 |
79 |
80 |
81 | ## Credit
82 |
83 | **LocoBuddy** was created by [Julian Schiavo](https://twitter.com/julianschiavo), and published under the [MIT License](LICENSE). The original code is adapted from Douglas Hill's post [*Localisation using Apple’s glossaries*](https://douglashill.co/localisation-using-apples-glossaries/) under the MIT License.
84 |
85 |
86 |
87 | ## License
88 |
89 | Available under the MIT License. See the [License](LICENSE) for more info.
90 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Commands/Search.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | /// A command that searches for translations for a term in a language.
5 | struct Search: ParsableCommand {
6 | public static let configuration = CommandConfiguration(abstract: "Search for translations for a term.")
7 |
8 | /// A text matching type
9 | enum Match: String, ExpressibleByArgument {
10 | /// Match exact matches
11 | case exact
12 | /// Match terms that contain the text
13 | case contains
14 | /// Match terms that contain each word in the text
15 | case containsAll
16 | }
17 |
18 | /// The language to find translations in.
19 | @Option(name: .shortAndLong, help: "The language to find translations in.")
20 | var language: String
21 |
22 | /// The text matching type, defaults to `contains`.
23 | @Option(name: .shortAndLong, help: "The text matching type, defaults to `contains`.")
24 | var match: Match = .contains
25 |
26 | /// The text to find.
27 | @Argument(help: "The text to find.")
28 | var text: String
29 |
30 | /// Runs the command
31 | /// - Throws: When there is an error in glossary parsing
32 | func run() throws {
33 | print(" Reading Glossaries...")
34 | let parser = Parser()
35 |
36 | let entries = try parser.parse(language: language)
37 | print(" Found \(entries.count) localization entries!")
38 |
39 | let keyedEntries = Dictionary(grouping: entries, by: \.base)
40 | print(" Found \(keyedEntries.count) unique strings!")
41 |
42 | let options = keyedEntries
43 | .filter { entry in
44 | switch match {
45 | case .exact: return entry.key.lowercased() == text
46 | case .contains: return entry.key.lowercased().contains(text.lowercased())
47 | case .containsAll:
48 | let textWords = text.split(separator: " ")
49 | return entry.key.split(separator: " ").allSatisfy { textWords.contains($0) }
50 | }
51 | }
52 | .flatMap(\.value)
53 | print(" Found \(options.count) strings matching or containing `\(text)`!")
54 |
55 | if options.isEmpty {
56 | print(" Failed to find any translations containing \(text).", color: .red)
57 | return
58 | }
59 |
60 | print("")
61 | for option in options {
62 | print(" \(option.base)", color: .cyan)
63 | print(" \(option.translation)", color: .green)
64 | if let comment = option.comment {
65 | print(" \(comment)", color: .magenta)
66 | }
67 | print(" \(option.key)", color: .white)
68 | print(" \(option.fileURL.lastPathComponent)", color: .white)
69 | print("")
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Commands/Translate.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | /// A command to translate a term into another language, using a key obtained from the `search` command.
5 | struct Translate: ParsableCommand {
6 | public static let configuration = CommandConfiguration(abstract: "Translate a term into another language, using a key obtained from the `search` command.")
7 |
8 | /// The languages to translate the term to.
9 | @Argument(help: "The languages to translate the term to.")
10 | var languages: [String]
11 |
12 | /// The key for the entry.
13 | @Option(name: .shortAndLong, help: "The key for the entry.")
14 | var key: String
15 |
16 | /// Runs the command
17 | /// - Throws: When there is an error in glossary parsing
18 | func run() throws {
19 | print(" Reading Glossaries...")
20 | let parser = Parser()
21 |
22 | let entries = try parser.parse(languages: languages)
23 | print(" Found \(entries.count) localization entries!")
24 |
25 | let keyedEntries = Dictionary(grouping: entries, by: \.key)
26 | print(" Found \(keyedEntries.count) unique strings!")
27 |
28 | let options = keyedEntries
29 | .filter {
30 | $0.key.lowercased() == key.lowercased()
31 | }
32 | .flatMap(\.value)
33 |
34 | if options.isEmpty {
35 | print(" Failed to find any translations for key \(key).", color: .red)
36 | return
37 | }
38 |
39 | print("")
40 | print(" \(options.first?.base ?? key)", color: .cyan)
41 | for option in options {
42 | print(" \(option.translation)", color: .green)
43 | print(" \(option.fileURL.lastPathComponent)", color: .white)
44 | print(" \(option.language)", color: .white)
45 | print("")
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Extensions/Collection-SingleElement.swift:
--------------------------------------------------------------------------------
1 | extension Collection {
2 | /// The only element in the collection, or `nil` if there are multiple or zero elements.
3 | var single: Element? { count == 1 ? first : nil }
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Extensions/Print-Colors.swift:
--------------------------------------------------------------------------------
1 | /// Writes the textual representations of the given items into the standard
2 | /// output.
3 | func print(_ text: String, color: ANSIColor) {
4 | print(color + text)
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Extensions/String-Initials.swift:
--------------------------------------------------------------------------------
1 | extension String {
2 | // Adapted from https://stackoverflow.com/a/35286740
3 | var initials: String {
4 | components(separatedBy: " ")
5 | .reduce("") {
6 | $0.firstOrEmpty + $1.firstOrEmpty
7 | }
8 | }
9 |
10 | private var firstOrEmpty: String {
11 | if let first = first {
12 | return String(first)
13 | }
14 | return ""
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Extensions/XMLElement-OnlyChild.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension XMLElement {
4 | func onlyChild(named name: String) -> XMLElement? {
5 | elements(forName: name).single
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Extensions/XMLNode-OnlyChildText.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension XMLNode {
4 | var textOfOnlyChild: String? {
5 | guard let onlyChild = children?.single,
6 | onlyChild.kind == .text
7 | else {
8 | return nil
9 | }
10 | return onlyChild.stringValue
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Internal/ANSIColors.swift:
--------------------------------------------------------------------------------
1 | // Adapted from https://stackoverflow.com/a/30454802
2 | enum ANSIColor: String {
3 | case black = "\u{001B}[0;30m"
4 | case red = "\u{001B}[0;31m"
5 | case green = "\u{001B}[0;32m"
6 | case yellow = "\u{001B}[0;33m"
7 | case blue = "\u{001B}[0;34m"
8 | case magenta = "\u{001B}[0;35m"
9 | case cyan = "\u{001B}[0;36m"
10 | case white = "\u{001B}[0;37m"
11 | }
12 |
13 | func +(left: ANSIColor, right: String) -> String {
14 | return left.rawValue + right
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Internal/Parser.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class Parser {
4 |
5 | // MARK: - Public
6 |
7 | func parse(language: String) throws -> [Entry] {
8 | guard let volumes = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) else {
9 | throw CustomError("Failed to mount disk image volumes.")
10 | }
11 |
12 | let languageVolumes = volumes.filter { fileURL -> Bool in
13 | fileURL.lastPathComponent.contains(language)
14 | }
15 |
16 | guard !languageVolumes.isEmpty else {
17 | throw CustomError("Failed to find volumes for \(language). Download the glossaries from https://developer.apple.com/download/more/ and mount the disk images, then try again.")
18 | }
19 |
20 | return try languageVolumes.flatMap(parseVolume)
21 | }
22 |
23 | func parse(languages: [String]) throws -> [Entry] {
24 | guard let volumes = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) else {
25 | throw CustomError("Failed to mount disk image volumes.")
26 | }
27 |
28 | let languageVolumes = volumes
29 | .filter { !$0.lastPathComponent.contains("System") }
30 | .filter { fileURL in
31 | for language in languages {
32 | if fileURL.lastPathComponent.lowercased() == language.lowercased() ||
33 | fileURL.lastPathComponent.initials.lowercased() == language.lowercased() {
34 | return true
35 | }
36 | }
37 | return false
38 | }
39 |
40 | guard !languageVolumes.isEmpty else {
41 | throw CustomError("Failed to find volumes for any language in \(languages). Download the glossaries from https://developer.apple.com/download/more/ and mount the disk images, then try again.")
42 | }
43 |
44 | return try languageVolumes.flatMap(parseVolume)
45 | }
46 |
47 | // MARK: - Private
48 |
49 | private func parseVolume(at url: URL) throws -> [Entry] {
50 | let glossaryURLs = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
51 | return try glossaryURLs.flatMap { try parseDocument(at: $0, language: url.lastPathComponent) }
52 | }
53 |
54 | private func parseDocument(at url: URL, language: String) throws -> [Entry] {
55 | let document = try XMLDocument(contentsOf: url, options: [.nodePreserveWhitespace])
56 | guard let root = document.rootElement() else {
57 | throw CustomError("Invalid Glossary Format.")
58 | }
59 |
60 | return try root.elements(forName: "File").flatMap { file in
61 | try file.elements(forName: "TextItem").compactMap { element in
62 | try parseElement(element, at: url, language: language)
63 | }
64 | }
65 | }
66 |
67 | private func parseElement(_ element: XMLElement, at url: URL, language: String) throws -> Entry? {
68 | guard let translationSet = element.onlyChild(named: "TranslationSet"),
69 | let base = translationSet.onlyChild(named: "base")?.textOfOnlyChild,
70 | let translation = translationSet.onlyChild(named: "tran")?.textOfOnlyChild
71 | else {
72 | return nil
73 | }
74 | let comment = element.onlyChild(named: "Description")?.textOfOnlyChild
75 | let key = element.onlyChild(named: "Position")?.textOfOnlyChild ?? ""
76 |
77 | return Entry(fileURL: url, comment: comment, key: key, base: base, translation: translation, language: language)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Models/CustomError.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct CustomError: LocalizedError {
4 | let errorDescription: String?
5 |
6 | init(_ errorDescription: String) {
7 | self.errorDescription = errorDescription
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/Models/Entry.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Entry {
4 | /// The file the entry was read from.
5 | let fileURL: URL
6 | /// The usage description for the entry.
7 | let comment: String?
8 | /// The key identifying this string. This can be because some Apple strings files use just whitespace as a key and `NSXMLDocument` cannot read whitespace-only text elements.
9 | let key: String
10 | /// The original text.
11 | let base: String
12 | /// The localised text.
13 | let translation: String
14 | /// The language of the translated term.
15 | let language: String
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/LocoBuddy/main.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 |
3 | struct LocoBuddy: ParsableCommand {
4 | static let configuration = CommandConfiguration(
5 | abstract: "A command-line tool to parse and traverse AppleGlot Localization Glossaries",
6 | subcommands: [Search.self, Translate.self])
7 |
8 | init() { }
9 | }
10 |
11 | LocoBuddy.main()
12 |
--------------------------------------------------------------------------------