multilocale 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module Multilocale
8
+ # Reads and writes the locale files on disk.
9
+ #
10
+ # The path is a template containing `%lang%`, the same convention the npm CLI
11
+ # uses in multilocale.json:
12
+ #
13
+ # config/locales/%lang%.yml -> config/locales/en.yml, config/locales/es.yml, …
14
+ #
15
+ # YAML is written the way the i18n gem expects it: a single top-level locale
16
+ # key, nested below it. Two differences from `npx multilocale download` are
17
+ # deliberate and both matter to Ruby:
18
+ #
19
+ # * no injected "locale" entry. The CLI writes one into every dictionary it
20
+ # generates (only its Swift writer strips it again); in a Rails locale
21
+ # file it would surface as the translation `t("locale")`.
22
+ # * dotted keys are nested, because i18n resolves them by walking hashes.
23
+ class LocaleFile
24
+ FORMATS = %i[yaml json].freeze
25
+ PLACEHOLDER = "%lang%"
26
+
27
+ attr_reader :path_template, :format, :nested, :header, :base_dir
28
+
29
+ def initialize(path_template:, format: nil, nested: true, header: nil, base_dir: Dir.pwd)
30
+ unless path_template.to_s.include?(PLACEHOLDER)
31
+ raise ConfigurationError, "Path #{path_template.inspect} must contain #{PLACEHOLDER}, e.g. " \
32
+ "config/locales/#{PLACEHOLDER}.yml"
33
+ end
34
+
35
+ @path_template = path_template.to_s
36
+ @format = (format || self.class.infer_format(@path_template)).to_sym
37
+
38
+ unless FORMATS.include?(@format)
39
+ raise ConfigurationError,
40
+ "Unsupported format #{@format.inspect} (supported: #{FORMATS.join(', ')}). " \
41
+ "The npm CLI's cjs/esm/js/swift writers have no equivalent here."
42
+ end
43
+
44
+ @nested = nested
45
+ @header = header
46
+ @base_dir = base_dir
47
+ end
48
+
49
+ def self.infer_format(path)
50
+ case File.extname(path).downcase
51
+ when ".json" then :json
52
+ else :yaml
53
+ end
54
+ end
55
+
56
+ def path_for(language)
57
+ File.expand_path(path_template.gsub(PLACEHOLDER, language.to_s), base_dir)
58
+ end
59
+
60
+ # Writes one language and returns the absolute path written.
61
+ def write(dictionary)
62
+ path = path_for(dictionary.language)
63
+ FileUtils.mkdir_p(File.dirname(path))
64
+ File.write(path, render(dictionary))
65
+ path
66
+ end
67
+
68
+ def render(dictionary)
69
+ body = nested ? dictionary.nested : dictionary.to_h
70
+
71
+ case format
72
+ when :yaml
73
+ # line_width: -1 keeps long sentences on one line; wrapped YAML is
74
+ # valid but re-wraps on every unrelated edit and ruins the diff.
75
+ "#{yaml_header}#{{ dictionary.language.to_s => body }.to_yaml(line_width: -1)}"
76
+ else
77
+ # JSON has no comment syntax, so `header` is deliberately dropped here
78
+ # rather than written as an invalid first line.
79
+ "#{JSON.pretty_generate(body)}\n"
80
+ end
81
+ end
82
+
83
+ # Reads one language file back into a Dictionary, flattening the nesting.
84
+ # Returns nil when the file does not exist.
85
+ def read(language)
86
+ path = path_for(language)
87
+ return nil unless File.exist?(path)
88
+
89
+ raw = File.read(path)
90
+ document =
91
+ case format
92
+ when :yaml then YAML.safe_load(raw) || {}
93
+ else JSON.parse(raw)
94
+ end
95
+
96
+ # A locale file is `{ "en" => { … } }`; anything else is either already
97
+ # flat or someone else's file, and is taken as it comes.
98
+ body =
99
+ if document.is_a?(Hash) && document.size == 1 && document.key?(language.to_s)
100
+ document[language.to_s]
101
+ else
102
+ document
103
+ end
104
+
105
+ Dictionary.new(language.to_s, Dictionary.flatten(body))
106
+ end
107
+
108
+ private
109
+
110
+ def yaml_header
111
+ return "" if header.nil? || header.to_s.empty?
112
+
113
+ header.to_s.lines.map { |line| line.start_with?("#") ? line : "# #{line}" }.join.then do |comment|
114
+ comment.end_with?("\n") ? comment : "#{comment}\n"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # One `{key, value, language}` row. A key translated into 12 locales is 12
5
+ # phrases, not one phrase with 12 values — every API call and every error
6
+ # message counts rows, not keys.
7
+ #
8
+ # A row can belong to several projects at once (`projects`). Editing it edits
9
+ # it for all of them, and deleting a key deletes every row it matches
10
+ # including the shared ones.
11
+ class Phrase
12
+ attr_reader :to_h
13
+
14
+ def initialize(attributes)
15
+ @to_h = attributes.is_a?(Phrase) ? attributes.to_h : (attributes || {})
16
+ end
17
+
18
+ def id
19
+ @to_h["_id"]
20
+ end
21
+
22
+ def key
23
+ @to_h["key"]
24
+ end
25
+
26
+ def value
27
+ @to_h["value"]
28
+ end
29
+
30
+ def language
31
+ @to_h["language"]
32
+ end
33
+
34
+ def projects
35
+ @to_h["projects"] || []
36
+ end
37
+
38
+ def projects_ids
39
+ @to_h["projectsIds"] || []
40
+ end
41
+
42
+ def organization_id
43
+ @to_h["organizationId"]
44
+ end
45
+
46
+ # `googleTranslate` is the pre-2024 spelling and is still the only
47
+ # provenance flag on older rows, so a reader that only looks at
48
+ # `machineTranslated` reports human-written text that never was.
49
+ def machine_translated?
50
+ value = @to_h.fetch("machineTranslated") { @to_h["googleTranslate"] }
51
+ !!value
52
+ end
53
+
54
+ # Which engine produced it, when it was machine translated.
55
+ def model
56
+ @to_h["model"]
57
+ end
58
+
59
+ def creation_time
60
+ @to_h["creationTime"]
61
+ end
62
+
63
+ def last_edit_time
64
+ @to_h["lastEditTime"]
65
+ end
66
+
67
+ def [](key)
68
+ @to_h[key.to_s]
69
+ end
70
+
71
+ def ==(other)
72
+ other.is_a?(Phrase) && other.to_h == to_h
73
+ end
74
+ alias eql? ==
75
+
76
+ def hash
77
+ to_h.hash
78
+ end
79
+
80
+ def inspect
81
+ "#<Multilocale::Phrase key=#{key.inspect} language=#{language.inspect} value=#{value.inspect}>"
82
+ end
83
+
84
+ ATTRIBUTE_NAMES = {
85
+ id: "_id",
86
+ key: "key",
87
+ value: "value",
88
+ language: "language",
89
+ projects: "projects",
90
+ projects_ids: "projectsIds",
91
+ machine_translated: "machineTranslated",
92
+ model: "model"
93
+ }.freeze
94
+
95
+ def self.to_api(attributes)
96
+ return attributes.to_h if attributes.is_a?(Phrase)
97
+
98
+ attributes.each_with_object({}) do |(key, value), document|
99
+ document[ATTRIBUTE_NAMES[key] || key.to_s] = value
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # /api/phrases. Reads need the `phrases:read` scope, writes `phrases:write`.
5
+ class Phrases
6
+ # Server-side caps, mirrored here so a bad page size fails locally with a
7
+ # useful message instead of being silently clamped.
8
+ MAX_LIMIT = 2001
9
+ MAX_SKIP = 10_000
10
+ SORT_FIELDS = %w[_id key language].freeze
11
+
12
+ # POST accepts an array, but a few thousand rows in one body is how you
13
+ # meet a timeout. 200 is comfortably under it.
14
+ DEFAULT_BATCH_SIZE = 200
15
+
16
+ def initialize(client)
17
+ @client = client
18
+ end
19
+
20
+ # Omitting `limit` returns every matching row — the server treats a missing
21
+ # limit as unbounded, which is what a full dictionary download wants. Pass
22
+ # `limit`/`skip` only when you actually want a page.
23
+ def list(project: nil, language: nil, key: nil, fields: nil, limit: nil, skip: nil, sort_field: nil,
24
+ sort_direction: nil)
25
+ if limit && (limit.to_i < 1 || limit.to_i > MAX_LIMIT)
26
+ raise ArgumentError, "limit must be between 1 and #{MAX_LIMIT} (got #{limit})"
27
+ end
28
+
29
+ if skip && (skip.to_i.negative? || skip.to_i > MAX_SKIP)
30
+ raise ArgumentError, "skip must be between 0 and #{MAX_SKIP} (got #{skip})"
31
+ end
32
+
33
+ if sort_field && !SORT_FIELDS.include?(sort_field.to_s)
34
+ raise ArgumentError, "sort_field must be one of #{SORT_FIELDS.join(', ')} (got #{sort_field})"
35
+ end
36
+
37
+ parameters = {
38
+ "project" => project,
39
+ "language" => language,
40
+ "key" => (key && Encoding.phrase_key(key)),
41
+ "fields" => (fields && Array(fields).join(",")),
42
+ "limit" => limit,
43
+ "skip" => skip,
44
+ "sortField" => sort_field,
45
+ "sortDirection" => sort_direction
46
+ }
47
+
48
+ Array(@client.get("phrases", params: parameters)).map { |attributes| Phrase.new(attributes) }
49
+ end
50
+
51
+ # Every row of one key, one per language.
52
+ def find_by_key(key, project: nil)
53
+ list(key: key, project: project)
54
+ end
55
+
56
+ # Creates or overwrites rows. A row with an `_id` that already exists is
57
+ # replaced wholesale (it is an upsert, not a patch): send the whole row.
58
+ #
59
+ # phrases.upsert(key: "cart.title", value: "Cart", language: "en", projects: ["website"])
60
+ #
61
+ # `projects` must name the key's project, otherwise the row is created but
62
+ # no download will ever include it.
63
+ def upsert(phrases = nil, batch_size: DEFAULT_BATCH_SIZE, **attributes)
64
+ rows =
65
+ if phrases.nil?
66
+ attributes.empty? ? [] : [attributes]
67
+ else
68
+ phrases.is_a?(Array) ? phrases : [phrases]
69
+ end
70
+ return [] if rows.empty?
71
+
72
+ rows.each_slice(batch_size).flat_map do |batch|
73
+ payload = batch.map { |row| Phrase.to_api(row) }
74
+ # The API answers with a bare object for a single-element body and an
75
+ # array otherwise, so both shapes have to be handled.
76
+ result = @client.post("phrases", body: payload.size == 1 ? payload.first : payload)
77
+ (result.is_a?(Array) ? result : [result]).map { |attributes| Phrase.new(attributes) }
78
+ end
79
+ end
80
+
81
+ def update(id, attributes)
82
+ Phrase.new(@client.put("phrases/#{Encoding.percent_encode(id)}", body: Phrase.to_api(attributes)))
83
+ end
84
+
85
+ # Deletes EVERY language of `key` in `project` — a 30-locale key is 30 rows
86
+ # gone in one call. Rows shared with other projects are deleted too, not
87
+ # detached from this one. Returns the deleted rows.
88
+ def delete(key:, project:)
89
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
90
+ raise ArgumentError, "project is required" if project.nil? || project.to_s.empty?
91
+
92
+ result = @client.delete("phrases", params: { "key" => Encoding.phrase_key(key), "project" => project })
93
+ Array(result).map { |attributes| Phrase.new(attributes) }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # A project: the unit an API key is scoped to, and the thing that owns the
5
+ # locale list every download is written from.
6
+ #
7
+ # The wire format is the API's camelCase document; the readers are the Ruby
8
+ # names. `#to_h` always returns the untouched document, so a round-trip
9
+ # through this class never drops a field the API added yesterday.
10
+ class Project
11
+ attr_reader :to_h
12
+
13
+ def initialize(attributes)
14
+ @to_h = attributes.is_a?(Project) ? attributes.to_h : (attributes || {})
15
+ end
16
+
17
+ def id
18
+ @to_h["_id"]
19
+ end
20
+
21
+ def name
22
+ @to_h["name"]
23
+ end
24
+
25
+ def organization_id
26
+ @to_h["organizationId"]
27
+ end
28
+
29
+ def default_locale
30
+ @to_h["defaultLocale"]
31
+ end
32
+
33
+ # The complete locale list. Updating it REPLACES it: a project update that
34
+ # omits a locale removes it, it does not merge.
35
+ def locales
36
+ @to_h["locales"] || []
37
+ end
38
+
39
+ # Free-text translation context, prepended to every machine translation.
40
+ def context
41
+ @to_h["context"]
42
+ end
43
+
44
+ # Server-side download paths. The npm CLI resolves `project.paths ||
45
+ # config.paths`, so a value set here wins over the local multilocale.json.
46
+ def paths
47
+ @to_h["paths"]
48
+ end
49
+
50
+ def creation_time
51
+ @to_h["creationTime"]
52
+ end
53
+
54
+ def last_edit_time
55
+ @to_h["lastEditTime"]
56
+ end
57
+
58
+ def [](key)
59
+ @to_h[key.to_s]
60
+ end
61
+
62
+ def ==(other)
63
+ other.is_a?(Project) && other.to_h == to_h
64
+ end
65
+ alias eql? ==
66
+
67
+ def hash
68
+ to_h.hash
69
+ end
70
+
71
+ def inspect
72
+ "#<Multilocale::Project id=#{id.inspect} name=#{name.inspect} locales=#{locales.size}>"
73
+ end
74
+
75
+ ATTRIBUTE_NAMES = {
76
+ id: "_id",
77
+ name: "name",
78
+ default_locale: "defaultLocale",
79
+ locales: "locales",
80
+ context: "context",
81
+ paths: "paths"
82
+ }.freeze
83
+
84
+ # Snake_case keyword arguments -> the camelCase document the API stores.
85
+ # String keys are passed through untouched so anything not modelled here
86
+ # can still be sent.
87
+ def self.to_api(attributes)
88
+ return attributes.to_h if attributes.is_a?(Project)
89
+
90
+ attributes.each_with_object({}) do |(key, value), document|
91
+ document[ATTRIBUTE_NAMES[key] || key.to_s] = value
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # /api/projects. Reads need the `projects:read` scope, writes `projects:write`.
5
+ class Projects
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Every project the credential can see. A project-scoped API key sees
11
+ # exactly one — the server filters the list, it does not 403.
12
+ def list
13
+ Array(@client.get("projects")).map { |attributes| Project.new(attributes) }
14
+ end
15
+
16
+ # Accepts an id (24 hex characters) or a name. Names are unique per
17
+ # organization, not globally, and the credential supplies the organization.
18
+ def find(id_or_name)
19
+ raise ConfigurationError, "Project id or name required" if id_or_name.nil? || id_or_name.to_s.empty?
20
+
21
+ Project.new(@client.get("projects/#{Encoding.percent_encode(id_or_name)}"))
22
+ end
23
+
24
+ # Returns nil instead of raising when there is no such project.
25
+ def find_by(id_or_name)
26
+ find(id_or_name)
27
+ rescue NotFoundError
28
+ nil
29
+ end
30
+
31
+ # create(name: "website", default_locale: "en", locales: %w[en es fr])
32
+ def create(attributes)
33
+ Project.new(@client.post("projects", body: Project.to_api(attributes)))
34
+ end
35
+
36
+ # `locales:` is a REPLACEMENT, not a merge. Read the project first and send
37
+ # the full list, or you will silently drop the locales you left out.
38
+ def update(id, attributes)
39
+ Project.new(@client.put("projects/#{Encoding.percent_encode(id)}", body: Project.to_api(attributes)))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # Moves phrases between multilocale.com and the locale files in a repository.
5
+ #
6
+ # sync = Multilocale::Sync.new(client: client, config: Multilocale::ConfigFile.discover)
7
+ # sync.pull # API -> config/locales/*.yml
8
+ # sync.push # files -> API
9
+ #
10
+ # Pull is the one to wire into a rake task or CI job; push exists so a
11
+ # developer who edited a locale file by hand can send it back rather than
12
+ # retyping it in the dashboard.
13
+ class Sync
14
+ # What a pull did, so a CLI or a rake task can report it without guessing.
15
+ Result = Struct.new(:project, :files, :languages, :phrases, :empty_locales, keyword_init: true)
16
+
17
+ attr_reader :client, :config, :base_dir
18
+
19
+ def initialize(client:, config: nil, project: nil, paths: nil, nested: nil, header: nil, base_dir: nil)
20
+ @client = client
21
+ @config = config
22
+ @project = project
23
+ @paths = paths
24
+ @nested = nested
25
+ @header = header
26
+ @base_dir = base_dir || config&.directory || Dir.pwd
27
+ end
28
+
29
+ def project
30
+ @project || config&.project || client.project ||
31
+ raise(ConfigurationError, <<~MESSAGE)
32
+ No project. Pass project: to Sync.new, set "projectId" in #{ConfigFile::FILENAME},
33
+ or export MULTILOCALE_PROJECT.
34
+ MESSAGE
35
+ end
36
+
37
+ def paths
38
+ value = @paths || config&.paths
39
+ value = Array(value)
40
+ return value unless value.empty?
41
+
42
+ raise ConfigurationError, <<~MESSAGE
43
+ No output paths. Pass paths: to Sync.new or add them to #{ConfigFile::FILENAME}:
44
+
45
+ { "paths": ["config/locales/#{LocaleFile::PLACEHOLDER}.yml"] }
46
+ MESSAGE
47
+ end
48
+
49
+ # Downloads every phrase of the project and rewrites the locale files.
50
+ #
51
+ # Locales the project declares but has no phrases for are reported in
52
+ # `Result#empty_locales` and left alone: writing an empty file for them
53
+ # would make i18n treat a missing translation as an empty string.
54
+ def pull(languages: nil)
55
+ # Local configuration is validated before the first request: a missing
56
+ # path template should not cost a round trip to find out about.
57
+ writers
58
+
59
+ resolved = resolve_project
60
+ rows = client.phrases.list(project: resolved.name)
61
+ dictionaries = Dictionary.from_phrases(rows)
62
+
63
+ wanted = languages || config&.locales || resolved.locales
64
+ wanted = dictionaries.keys if wanted.nil? || wanted.empty?
65
+
66
+ files = []
67
+ empty = []
68
+
69
+ wanted.sort.each do |language|
70
+ dictionary = dictionaries[language]
71
+
72
+ if dictionary.nil? || dictionary.empty?
73
+ empty << language
74
+ next
75
+ end
76
+
77
+ writers.each { |writer| files << writer.write(dictionary) }
78
+ end
79
+
80
+ Result.new(
81
+ project: resolved,
82
+ files: files,
83
+ languages: wanted.sort - empty,
84
+ phrases: rows.size,
85
+ empty_locales: empty
86
+ )
87
+ end
88
+
89
+ # Reads the locale files back and upserts them as phrase rows.
90
+ #
91
+ # Every row is sent with `projects: [name]`; a row created without it exists
92
+ # in the organization but belongs to no project, and no download will ever
93
+ # include it again.
94
+ def push(languages: nil)
95
+ resolved = resolve_project
96
+ writer = writers.first
97
+ wanted = languages || config&.locales || resolved.locales
98
+
99
+ rows = wanted.flat_map do |language|
100
+ dictionary = writer.read(language)
101
+ next [] if dictionary.nil?
102
+
103
+ dictionary.map do |key, value|
104
+ { key: key, value: value, language: language, projects: [resolved.name] }
105
+ end
106
+ end
107
+
108
+ client.phrases.upsert(rows)
109
+ end
110
+
111
+ def resolve_project
112
+ client.projects.find(project)
113
+ rescue NotFoundError
114
+ raise NotFoundError.new(
115
+ "No project #{project.inspect} in this organization. `multilocale-ruby projects` lists the ones " \
116
+ "this credential can see.",
117
+ status: 404
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ def writers
124
+ @writers ||= paths.map do |path|
125
+ LocaleFile.new(
126
+ path_template: path,
127
+ format: config&.format,
128
+ nested: @nested.nil? ? (config.nil? || config.nested?) : @nested,
129
+ header: @header || config&.header,
130
+ base_dir: base_dir
131
+ )
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "multilocale/version"
4
+ require_relative "multilocale/errors"
5
+ require_relative "multilocale/encoding"
6
+ require_relative "multilocale/project"
7
+ require_relative "multilocale/phrase"
8
+ require_relative "multilocale/dictionary"
9
+ require_relative "multilocale/locale_file"
10
+ require_relative "multilocale/config_file"
11
+ require_relative "multilocale/projects"
12
+ require_relative "multilocale/phrases"
13
+ require_relative "multilocale/client"
14
+ require_relative "multilocale/sync"
15
+
16
+ # Ruby client for the Multilocale translation API (https://www.multilocale.com).
17
+ #
18
+ # client = Multilocale.client # reads MULTILOCALE_API_KEY
19
+ # client.dictionary(project: "website", language: "es").to_h
20
+ # #=> { "cart.title" => "Carrito", … }
21
+ #
22
+ # The gem talks to the REST API and writes i18n-shaped locale files; the i18n
23
+ # gem renders them. It deliberately does not wrap I18n itself — a translation
24
+ # backend that makes a network call per lookup is how a marketing page ends up
25
+ # depending on someone else's uptime.
26
+ module Multilocale
27
+ class << self
28
+ # Builds a client from the environment, or from explicit keywords.
29
+ def client(**options)
30
+ Client.new(**options)
31
+ end
32
+
33
+ # Convenience for the common one-liner: one language of one project.
34
+ def dictionary(project:, language:, **options)
35
+ client(**options).dictionary(project: project, language: language)
36
+ end
37
+ end
38
+ end