344 lines
11 KiB
Elixir
344 lines
11 KiB
Elixir
defmodule Mix.Tasks.Gettext.Merge do
|
|
use Mix.Task
|
|
@recursive true
|
|
|
|
@shortdoc "Merge template files into message files"
|
|
|
|
@moduledoc """
|
|
Merges PO/POT files with PO files.
|
|
|
|
This task is used when messages in the source code change: when they do,
|
|
`mix gettext.extract` is usually used to extract the new messages to POT
|
|
files. At this point, developers or translators can use this task to "sync"
|
|
the newly-updated POT files with the existing locale-specific PO files. All
|
|
the metadata for each message (like position in the source code, comments,
|
|
and so on) is taken from the newly-updated POT file; the only things taken
|
|
from the PO file are the actual translated strings.
|
|
|
|
#### Fuzzy Matching
|
|
|
|
Messages in the updated PO/POT file that have an exact match (a
|
|
message with the same `msgid`) in the old PO file are merged as described
|
|
above. When a message in the updated PO/POT files has no match in the old
|
|
PO file, Gettext attemps a **fuzzy match** for that message. For example, imagine
|
|
we have this POT file:
|
|
|
|
msgid "hello, world!"
|
|
msgstr ""
|
|
|
|
and we merge it with this PO file:
|
|
|
|
# No exclamation point here in the msgid
|
|
msgid "hello, world"
|
|
msgstr "ciao, mondo"
|
|
|
|
Since the two messages are similar, Gettext takes the `msgstr` from the
|
|
existing message over to the new message, which it however
|
|
marks as *fuzzy*:
|
|
|
|
#, fuzzy
|
|
msgid "hello, world!"
|
|
msgstr "ciao, mondo"
|
|
|
|
Generally, a `fuzzy` flag calls for review from a translator.
|
|
|
|
Fuzzy matching can be configured (for example, the threshold for message
|
|
similarity can be tweaked) or disabled entirely. Look at the
|
|
["Options" section](#module-options).
|
|
|
|
## Usage
|
|
|
|
```bash
|
|
mix gettext.merge OLD_FILE UPDATED_FILE [OPTIONS]
|
|
mix gettext.merge DIR [OPTIONS]
|
|
```
|
|
|
|
If two files are given as arguments, `OLD_FILE` must be a `.po` file and
|
|
`UPDATE_FILE` must be a `.po`/`.pot` file. The first one is the old PO file,
|
|
while the second one is the last generated one. They are merged and written
|
|
over the first file. For example:
|
|
|
|
```bash
|
|
mix gettext.merge priv/gettext/en/LC_MESSAGES/default.po priv/gettext/default.pot
|
|
```
|
|
|
|
If only one argument is given, then that argument must be a directory
|
|
containing Gettext messages (with `.pot` files at the root level alongside
|
|
locale directories - this is usually a "backend" directory used by a Gettext
|
|
backend, see `Gettext.Backend`). For example:
|
|
|
|
```bash
|
|
mix gettext.merge priv/gettext
|
|
```
|
|
|
|
If the `--locale LOCALE` option is given, then only the PO files in
|
|
`<DIR>/<LOCALE>/LC_MESSAGES` will be merged with the POT files in `DIR`. If no
|
|
options are given, then all the PO files for all locales under `DIR` are
|
|
merged with the POT files in `DIR`.
|
|
|
|
## Plural Forms
|
|
|
|
By default, Gettext will determine the number of plural forms for newly-generated messages
|
|
by checking the value of `nplurals` in the `Plural-Forms` header in the existing `.po` file. If
|
|
a `.po` file doesn't already exist and Gettext is creating a new one or if the `Plural-Forms`
|
|
header is not in the `.po` file, Gettext will use the number of plural forms that
|
|
the plural module (see `Gettext.Plural`) returns for the locale of the file being created.
|
|
The content of the `Plural-Forms` header can be forced through the `--plural-forms-header`
|
|
option (see below).
|
|
|
|
## Options
|
|
|
|
* `--locale` - a string representing a locale. If this is provided, then only the PO
|
|
files in `<DIR>/<LOCALE>/LC_MESSAGES` will be merged with the POT files in `DIR`. This
|
|
option can only be given when a single argument is passed to the task
|
|
(a directory).
|
|
|
|
* `--no-fuzzy` - don't perform fuzzy matching when merging files.
|
|
|
|
* `--fuzzy-threshold` - a float between `0` and `1` which represents the
|
|
minimum Jaro distance needed for two messages to be considered a fuzzy
|
|
match. Overrides the global `:fuzzy_threshold` option (see the docs for
|
|
`Gettext` for more information on this option).
|
|
|
|
* `--plural-forms` - (**deprecated in v0.22.0**) an integer strictly greater than `0`.
|
|
If this is passed, new messages in the target PO files will have this number of empty
|
|
plural forms. This is deprecated in favor of passing the `--plural-forms-header`,
|
|
which contains the whole plural-forms specification. See the "Plural forms" section above.
|
|
|
|
* `--plural-forms-header` - the content of the `Plural-Forms` header as a string.
|
|
If this is passed, new messages in the target PO files will use this content
|
|
to determine the number of plurals. See the ["Plural Forms" section](#module-plural-forms).
|
|
|
|
* `--on-obsolete` - controls what happens when **obsolete** messages are found.
|
|
If `mark_as_obsolete`, messages are kept and marked as obsolete.
|
|
If `delete`, obsolete messages are deleted. Defaults to `delete`.
|
|
|
|
* `--store-previous-message-on-fuzzy-match` - controls if the previous
|
|
messages are recorded on fuzzy matches. Is off by default.
|
|
|
|
"""
|
|
|
|
alias Expo.PO
|
|
alias Gettext.Merger
|
|
|
|
@default_fuzzy_threshold 0.8
|
|
|
|
@switches [
|
|
locale: :string,
|
|
fuzzy: :boolean,
|
|
fuzzy_threshold: :float,
|
|
plural_forms_header: :string,
|
|
on_obsolete: :string,
|
|
store_previous_message_on_fuzzy_match: :boolean,
|
|
|
|
# TODO: remove in v0.24.0
|
|
plural_forms: :integer
|
|
]
|
|
|
|
@impl true
|
|
def run(args) do
|
|
Mix.Task.run("loadpaths")
|
|
|
|
_ = Mix.Project.get!()
|
|
gettext_config = Mix.Project.config()[:gettext] || []
|
|
|
|
case OptionParser.parse!(args, switches: @switches) do
|
|
{opts, [po_file, reference_file]} ->
|
|
merge_two_files(po_file, reference_file, opts, gettext_config)
|
|
|
|
{opts, [messages_dir]} ->
|
|
merge_messages_dir(messages_dir, opts, gettext_config)
|
|
|
|
{_opts, _args} ->
|
|
Mix.raise(
|
|
"You can only pass one or two arguments to the \"gettext.merge\" task. " <>
|
|
"Use `mix help gettext.merge` to see the usage of this task"
|
|
)
|
|
end
|
|
|
|
Mix.Task.reenable("gettext.merge")
|
|
end
|
|
|
|
defp merge_two_files(po_file, reference_file, opts, gettext_config) do
|
|
merging_opts = validate_merging_opts!(opts, gettext_config)
|
|
|
|
if Path.extname(po_file) == ".po" and Path.extname(reference_file) in [".po", ".pot"] do
|
|
ensure_file_exists!(po_file)
|
|
ensure_file_exists!(reference_file)
|
|
locale = locale_from_path(po_file)
|
|
|
|
{contents, stats} =
|
|
merge_files(po_file, reference_file, locale, merging_opts, gettext_config)
|
|
|
|
write_file(po_file, contents, stats)
|
|
else
|
|
Mix.raise("Arguments must be a PO file and a PO/POT file")
|
|
end
|
|
end
|
|
|
|
defp merge_messages_dir(dir, opts, gettext_config) do
|
|
ensure_dir_exists!(dir)
|
|
merging_opts = validate_merging_opts!(opts, gettext_config)
|
|
|
|
if locale = opts[:locale] do
|
|
merge_locale_dir(dir, locale, merging_opts, gettext_config)
|
|
else
|
|
merge_all_locale_dirs(dir, merging_opts, gettext_config)
|
|
end
|
|
end
|
|
|
|
defp merge_locale_dir(pot_dir, locale, opts, gettext_config) do
|
|
locale_dir = locale_dir(pot_dir, locale)
|
|
create_missing_locale_dir(locale_dir)
|
|
merge_dirs(locale_dir, pot_dir, locale, opts, gettext_config)
|
|
end
|
|
|
|
defp merge_all_locale_dirs(pot_dir, opts, gettext_config) do
|
|
for locale <- File.ls!(pot_dir), File.dir?(Path.join(pot_dir, locale)) do
|
|
merge_dirs(locale_dir(pot_dir, locale), pot_dir, locale, opts, gettext_config)
|
|
end
|
|
end
|
|
|
|
def locale_dir(pot_dir, locale) do
|
|
Path.join([pot_dir, locale, "LC_MESSAGES"])
|
|
end
|
|
|
|
defp merge_dirs(po_dir, pot_dir, locale, opts, gettext_config) do
|
|
merger = fn pot_file ->
|
|
po_file = find_matching_po(pot_file, po_dir)
|
|
{contents, stats} = merge_or_create(pot_file, po_file, locale, opts, gettext_config)
|
|
write_file(po_file, contents, stats)
|
|
end
|
|
|
|
pot_dir
|
|
|> Path.join("*.pot")
|
|
|> Path.wildcard()
|
|
|> Task.async_stream(merger, ordered: false, timeout: :infinity)
|
|
|> Stream.run()
|
|
|
|
warn_for_po_without_pot(po_dir, pot_dir)
|
|
end
|
|
|
|
defp find_matching_po(pot_file, po_dir) do
|
|
domain = Path.basename(pot_file, ".pot")
|
|
Path.join(po_dir, "#{domain}.po")
|
|
end
|
|
|
|
defp merge_or_create(pot_file, po_file, locale, opts, gettext_config) do
|
|
if File.regular?(po_file) do
|
|
merge_files(po_file, pot_file, locale, opts, gettext_config)
|
|
else
|
|
{new_po, stats} = Merger.new_po_file(po_file, pot_file, locale, opts)
|
|
|
|
{new_po
|
|
|> Merger.prune_references(gettext_config)
|
|
|> PO.compose(), stats}
|
|
end
|
|
end
|
|
|
|
defp merge_files(po_file, pot_file, locale, opts, gettext_config) do
|
|
{merged, stats} =
|
|
Merger.merge(
|
|
PO.parse_file!(po_file),
|
|
PO.parse_file!(pot_file),
|
|
locale,
|
|
opts,
|
|
gettext_config
|
|
)
|
|
|
|
{merged
|
|
|> Merger.prune_references(gettext_config)
|
|
|> PO.compose(), stats}
|
|
end
|
|
|
|
defp write_file(path, contents, stats) do
|
|
File.mkdir_p!(Path.dirname(path))
|
|
File.write!(path, contents)
|
|
Mix.shell().info("Wrote #{path} (#{format_stats(stats)})")
|
|
end
|
|
|
|
# Warns for every PO file that has no matching POT file.
|
|
defp warn_for_po_without_pot(po_dir, pot_dir) do
|
|
po_dir
|
|
|> Path.join("*.po")
|
|
|> Path.wildcard()
|
|
|> Enum.reject(&po_has_matching_pot?(&1, pot_dir))
|
|
|> Enum.each(fn po_file ->
|
|
Mix.shell().info("Warning: PO file #{po_file} has no matching POT file in #{pot_dir}")
|
|
end)
|
|
end
|
|
|
|
defp po_has_matching_pot?(po_file, pot_dir) do
|
|
domain = Path.basename(po_file, ".po")
|
|
pot_path = Path.join(pot_dir, "#{domain}.pot")
|
|
File.exists?(pot_path)
|
|
end
|
|
|
|
defp ensure_file_exists!(path) do
|
|
unless File.regular?(path), do: Mix.raise("No such file: #{path}")
|
|
end
|
|
|
|
defp ensure_dir_exists!(path) do
|
|
unless File.dir?(path), do: Mix.raise("No such directory: #{path}")
|
|
end
|
|
|
|
defp create_missing_locale_dir(dir) do
|
|
unless File.dir?(dir) do
|
|
File.mkdir_p!(dir)
|
|
Mix.shell().info("Created directory #{dir}")
|
|
end
|
|
end
|
|
|
|
defp validate_merging_opts!(opts, gettext_config) do
|
|
opts =
|
|
opts
|
|
|> Keyword.take([
|
|
:fuzzy,
|
|
:fuzzy_threshold,
|
|
:plural_forms,
|
|
:plural_forms_header,
|
|
:on_obsolete,
|
|
:store_previous_message_on_fuzzy_match
|
|
])
|
|
|> Keyword.put_new(:store_previous_message_on_fuzzy_match, false)
|
|
|> Keyword.put_new(:fuzzy, true)
|
|
|> Keyword.put_new_lazy(:fuzzy_threshold, fn ->
|
|
gettext_config[:fuzzy_threshold] || @default_fuzzy_threshold
|
|
end)
|
|
|> Keyword.update(:on_obsolete, :delete, &cast_on_obsolete/1)
|
|
|
|
threshold = opts[:fuzzy_threshold]
|
|
|
|
unless threshold >= 0.0 and threshold <= 1.0 do
|
|
Mix.raise("The :fuzzy_threshold option must be a float >= 0.0 and <= 1.0")
|
|
end
|
|
|
|
opts
|
|
end
|
|
|
|
defp locale_from_path(path) do
|
|
parts = Path.split(path)
|
|
index = Enum.find_index(parts, &(&1 == "LC_MESSAGES"))
|
|
Enum.at(parts, index - 1)
|
|
end
|
|
|
|
defp format_stats(stats) do
|
|
pluralized = if stats.new == 1, do: "message", else: "messages"
|
|
|
|
"#{stats.new} new #{pluralized}, #{stats.removed} removed, " <>
|
|
"#{stats.exact_matches} unchanged, #{stats.fuzzy_matches} reworded (fuzzy), " <>
|
|
"#{stats.marked_as_obsolete} marked as obsolete"
|
|
end
|
|
|
|
defp cast_on_obsolete("delete" = _on_obsolete), do: :delete
|
|
defp cast_on_obsolete("mark_as_obsolete" = _on_obsolete), do: :mark_as_obsolete
|
|
|
|
defp cast_on_obsolete(on_obsolete) do
|
|
Mix.raise("""
|
|
An invalid value was provided for the option `on_obsolete`.
|
|
Value: #{inspect(on_obsolete)}
|
|
Valid Choices: "delete" / "mark_as_obsolete"
|
|
""")
|
|
end
|
|
end
|