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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/exe/multilocale-ruby +6 -0
- data/lib/multilocale/cli.rb +215 -0
- data/lib/multilocale/client.rb +256 -0
- data/lib/multilocale/config_file.rb +130 -0
- data/lib/multilocale/dictionary.rb +94 -0
- data/lib/multilocale/encoding.rb +45 -0
- data/lib/multilocale/errors.rb +79 -0
- data/lib/multilocale/locale_file.rb +118 -0
- data/lib/multilocale/phrase.rb +103 -0
- data/lib/multilocale/phrases.rb +96 -0
- data/lib/multilocale/project.rb +95 -0
- data/lib/multilocale/projects.rb +42 -0
- data/lib/multilocale/sync.rb +135 -0
- data/lib/multilocale/version.rb +5 -0
- data/lib/multilocale.rb +38 -0
- metadata +68 -0
|
@@ -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
|
data/lib/multilocale.rb
ADDED
|
@@ -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
|