Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- `lingo sync` no longer destroys hand-edit protection: it now only refreshes
each entry's `source_hash` and preserves `target_hash` and `manual: true`
(the pre-extraction `bin/translate` behavior). Previously a single sync wiped
both fields for every key, blinding the `manual_edits` validator and causing
`manual` flags to flip back and forth across branches.
- `translate --force-key` on a `manual`-flagged key keeps the flag. The value is
still retranslated on explicit request, but no command removes `manual: true`
anymore — unprotecting a key requires editing the state JSON by hand.
- State files whose content is unchanged are no longer rewritten, so operations
never touch `.i18n-state/` files for unrelated namespaces.

### Changed
- Unscoped `lingo accept-edits` now accepts only the keys the `manual_edits`
validator flags (actually hand-edited), instead of stamping `manual: true` on
every key of the locale. Use the new `--key KEY` (repeatable) for surgical
accepts, or `--all` for the old blanket behavior (initial adoption).
- Validator suggestions are scoped and safe to follow verbatim: `manual_edit`
violations suggest `accept-edits --locale <l> --key <key>`, and `outdated`
violations on manual keys tell you to update the value by hand and re-accept
it rather than force-translating over curated text.

### Added
- `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider
credentials as Strings or lazy callables, for apps whose keys don't live in
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ lingo validate --strict # CI gate (exit 1 on strict-tier issues)
lingo validate --strict-all # stricter CI gate
lingo quality --ai # quality linting (+ optional AI pass)
lingo fix-quality --locale en # auto-fix fixable issues
lingo accept-edits --locale de # mark current translations as intentional
lingo sync # rebuild drift state from current files
lingo accept-edits --locale de # protect hand-edited translations (drifted keys only)
lingo accept-edits --key a.b.c # protect a specific key
lingo accept-edits --all # protect everything (initial adoption)
lingo sync # refresh drift state (preserves manual flags)
lingo hash # source-translation fingerprint
```

Expand Down
24 changes: 18 additions & 6 deletions docs/app/views/docs/pages/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def translate

- `--locale de` limits to one locale.
- `--force` re-translates everything (respecting protected manual edits).
- `--force-key a.b.c` re-translates specific keys.
- `--force-key a.b.c` re-translates specific keys — including
manual-flagged ones, whose `manual` flag survives the rewrite.
- `--dry-run` shows the plan without writing.

On success it runs the configured `after_translate` hooks. Requires
Expand Down Expand Up @@ -73,9 +74,17 @@ def quality_cmds
def accept_edits
DocsUI::Section("accept-edits") do
md <<~'MD'
When the `manual_edits` validator is enabled, `accept-edits` records the
current target values as intentional so they are protected from being
When the `manual_edits` validator is enabled, `accept-edits` records
hand-edited target values as intentional so they are protected from being
overwritten by the next `translate` and no longer flagged as hand-edited.

- Unscoped, it accepts exactly the keys the `manual_edits` validator
flags — nothing else changes.
- `--key a.b.c` (repeatable) accepts specific keys, drifted or not.
- `--all` marks every translated key as manual — the blanket form, for
adopting locallingo on an app whose translations were all hand-made.
- `--locale de` limits any of the above to one locale.

See [Drift & state](/docs/drift-state).
MD
end
Expand All @@ -84,9 +93,12 @@ def accept_edits
def sync
DocsUI::Section("sync") do
md <<~'MD'
Rebuilds the `.i18n-state/` drift state from the current locale files —
for initial setup on an existing app, or after manual edits, so `validate`
doesn't report spurious "outdated" keys.
Refreshes the `.i18n-state/` drift state from the current locale files —
for initial setup on an existing app, or after editing source strings, so
`validate` doesn't report spurious "outdated" keys. It updates each key's
`source_hash` and prunes entries whose keys were removed; `target_hash`
and `manual` flags are always preserved, so hand-edit protection never
depends on when sync last ran.
MD
end
end
Expand Down
28 changes: 22 additions & 6 deletions docs/app/views/docs/pages/drift_state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def content
the_files
outdated
manual_edits
flag_permanence
syncing
end

Expand Down Expand Up @@ -66,19 +67,34 @@ def manual_edits
With `validators.manual_edits` enabled, state entries also carry a
`target_hash` and a `manual` flag. If someone hand-tunes a translation, its
target hash no longer matches and `validate` flags a `manual_edit`.
Running `lingo accept-edits` stamps the current values as intentional
(setting `manual: true`), and `translate --force` then leaves them alone.
Running `lingo accept-edits` stamps the flagged values as intentional
(setting `manual: true`) — add `--key a.b.c` to accept one key surgically.
Protected keys are skipped by `translate` and `--force`; only an explicit
`--force-key` rewrites one, and even then the `manual` flag survives.
MD
end
end

def flag_permanence
DocsUI::Section("The flag is permanent") do
md <<~'MD'
No command removes `manual: true` — not `sync`, not `translate`, not
`--force-key`. The only way to unprotect a key is to delete the flag from
its `.i18n-state/` entry by hand. That makes state diffs boring: a PR only
touches the entries for keys it actually changed.
MD
end
end

def syncing
DocsUI::Section("Rebuilding state") do
md <<~'MD'
Adopting locallingo on an existing app, or making a batch of manual edits,
can leave the state out of step with the files. `lingo sync` rewrites the
state from the current translations so nothing reads as spuriously
outdated.
Adopting locallingo on an existing app, or editing a batch of source
strings, can leave the state out of step with the files. `lingo sync`
refreshes each key's `source_hash` from the current translations and
prunes entries for removed keys, so nothing reads as spuriously outdated.
It never touches `target_hash` or `manual` — hand-edit drift stays
visible until you resolve it with `accept-edits`.
MD
DocsUI::Callout(:note) do
plain "Run "
Expand Down
9 changes: 7 additions & 2 deletions docs/app/views/docs/pages/validators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ def outdated
current English value — i.e. the source text changed after the translation
was made, so the translation may no longer be accurate. See
[Drift & state](/docs/drift-state) for how this is tracked. The fix is
`lingo translate` (or `--force-key` for a single key).
`lingo translate` (or `--force-key` for a single key). For keys flagged
`manual` the suggestion differs: update the hand-curated value yourself,
then `accept-edits --key` it — machine translation is never pushed onto
protected keys.
MD
end
end
Expand All @@ -76,7 +79,9 @@ def manual_edits
source hash. If a target value's current hash no longer matches — someone
hand-edited it — this validator surfaces it so the next `translate`
doesn't silently overwrite the edit. Confirm the edit with
`lingo accept-edits` to protect it. See [Drift & state](/docs/drift-state).
`lingo accept-edits --locale <l> --key <key>` to protect that key (or run
it unscoped to accept every flagged edit). See
[Drift & state](/docs/drift-state).
MD
end
end
Expand Down
22 changes: 20 additions & 2 deletions lib/locallingo/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ def build_parser
opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
(@options[:force_keys] ||= []) << v
end
opts.on("--key KEY", "accept-edits: accept a specific key (repeatable)") do |v|
(@options[:keys] ||= []) << v
end
opts.on("--all", "accept-edits: mark every translated key as manual") { @options[:all] = true }
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
opts.on("-n", "--dry-run", "Show what would be done without changing files") { @options[:dry_run] = true }
opts.on("--strict", "Fail on strict-tier issues (for CI)") { @options[:strict] = true }
Expand Down Expand Up @@ -185,11 +189,25 @@ def cmd_accept_edits(config, options)
warn "manual_edits validator is disabled in .locallingo.yml — nothing to accept."
return
end
manager(config, options).accept_edits!(locale: options[:locale])
puts "✅ Marked current translations as intentional."

results = manager(config, options).accept_edits!(
locale: options[:locale], keys: options[:keys] || [], all: options.fetch(:all, false)
)
report_accepted(results)
puts "(dry run - no changes made)" if options[:dry_run]
end

def report_accepted(results)
total = results.values.sum(&:size)
if total.zero?
puts "Nothing to accept — no hand-edited translations found."
return
end

results.each { |locale, keys| puts " #{locale}: #{keys.size} key(s)" if keys.any? }
puts "✅ Marked #{total} translation(s) as intentional."
end

def cmd_hash(config, options)
hash = manager(config, options).source_hash
options[:format] == :json ? puts(JSON.generate({ hash: })) : puts(hash)
Expand Down
89 changes: 66 additions & 23 deletions lib/locallingo/manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,27 +97,35 @@ def translate!(locale: nil, force: false, force_keys: [])
locales_to_process.each { |target_locale| translate_locale(source, target_locale, force:, force_keys:) }
end

# Mark every current target value as intentional (source_hash + target_hash +
# manual flag) so the manual-edits validator stops flagging it.
def accept_edits!(locale: nil)
# Mark hand-edited target values as intentional (source_hash + target_hash +
# manual flag) so the manual-edits validator stops flagging them and
# translate won't overwrite them. Unscoped, it accepts exactly the keys the
# manual-edits validator flags; `keys:` accepts the named keys regardless of
# drift; `all: true` marks every translated key (initial adoption). Returns
# `{ locale => accepted_keys }`.
def accept_edits!(locale: nil, keys: [], all: false)
source = load_source_translations
locales = locale ? [locale] : config.target_locales

locales.each do |target_locale|
plans = locales.map do |target_locale|
target = load_locale_translations(target_locale)
locale_state = @state.load(target_locale)
accepted = keys_to_accept(source, target, locale_state, keys:, all:)
[target_locale, target, locale_state, accepted]
end

target.each do |key, value|
next unless source[key]
ensure_keys_matched!(keys, plans)

plans.each_with_object({}) do |(target_locale, target, locale_state, accepted), results|
accepted.each do |key|
locale_state[key] = {
"source_hash" => @state.hash(source[key]),
"target_hash" => @state.hash(value),
"target_hash" => @state.hash(target[key]),
"manual" => true
}
end

@state.save(target_locale, locale_state) unless dry_run
results[target_locale] = accepted
end
end

Expand All @@ -126,8 +134,11 @@ def source_hash
format("%08x", Zlib.crc32(load_source_translations.to_json))
end

# Rewrite state from the current translation files (initial setup / after
# manual edits). Returns the combined state.
# Refresh source hashes from the current translation files and prune state
# for keys that no longer exist. Existing `target_hash`/`manual` fields are
# preserved — hand-edit protection is never dropped by a sync; use
# `accept_edits!` to resolve hand-edit drift explicitly. Returns the
# combined state.
def sync_state!
source = load_source_translations

Expand All @@ -136,18 +147,7 @@ def sync_state!
en_state.each_key { |key| en_state.delete(key) unless source.key?(key) }
@state.save(config.source_locale, en_state) unless dry_run

config.target_locales.each do |locale|
target = load_locale_translations(locale)
locale_state = @state.load(locale)

target.each_key do |key|
next unless source[key]

locale_state[key] = { "source_hash" => @state.hash(source[key]) }
end
locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
@state.save(locale, locale_state) unless dry_run
end
config.target_locales.each { |locale| sync_locale_state(source, locale) }

combined = { config.source_locale => @state.load(config.source_locale) }
config.target_locales.each { |locale| combined[locale] = @state.load(locale) }
Expand All @@ -167,6 +167,44 @@ def run_after_translate_hooks
def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:)
def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:)

def sync_locale_state(source, locale)
target = load_locale_translations(locale)
locale_state = @state.load(locale)

target.each_key do |key|
next unless source[key]

existing = locale_state[key]
entry = existing.is_a?(Hash) ? existing.dup : {}
entry["source_hash"] = @state.hash(source[key])
locale_state[key] = entry
end
locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
@state.save(locale, locale_state) unless dry_run
end

def keys_to_accept(source, target, locale_state, keys:, all:)
return keys.select { |key| source.key?(key) && target.key?(key) } if keys.any?
return target.keys.select { |key| source.key?(key) } if all

target.keys.select do |key|
entry = locale_state[key]
next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"]

entry["target_hash"] && entry["target_hash"] != @state.hash(target[key])
end
end

def ensure_keys_matched!(keys, plans)
return if keys.empty?

matched = plans.flat_map { |_, _, _, accepted| accepted }
missing = keys - matched
return if missing.empty?

raise Error, "accept-edits: key(s) not found in any target locale: #{missing.join(", ")}"
end

def translate_locale(source, target_locale, force:, force_keys:)
log("Processing #{target_locale}...")

Expand Down Expand Up @@ -301,10 +339,15 @@ def update_locale_state(source, locale_state, translations)
translations.each_key do |key|
next unless source[key]

locale_state[key] = {
existing = locale_state[key]
entry = {
"source_hash" => @state.hash(source[key]),
"target_hash" => @state.hash(translations[key])
}
# A force-keyed retranslation may overwrite a manual value on explicit
# request, but the protection flag itself must survive.
entry["manual"] = true if existing.is_a?(Hash) && existing["manual"]
locale_state[key] = entry
end
end

Expand Down
10 changes: 7 additions & 3 deletions lib/locallingo/state_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ def load(locale)
"This would cause state loss. Fix the JSON manually or restore from git."
end

# Save a locale's state, split back into per-namespace files. Namespace files
# that no longer have keys are removed.
# Save a locale's state, split back into per-namespace files. Files whose
# content is unchanged are left untouched so unrelated namespaces never
# churn in diffs. Namespace files that no longer have keys are removed.
def save(locale, locale_state)
by_namespace = locale_state.each_with_object({}) do |(key, value), groups|
namespace = key.split(".").first
Expand All @@ -50,7 +51,10 @@ def save(locale, locale_state)

by_namespace.each do |namespace, keys|
state_file = File.join(state_dir, "#{namespace}.#{locale}.json")
File.write(state_file, JSON.pretty_generate(keys.sort.to_h))
content = JSON.pretty_generate(keys.sort.to_h)
next if File.exist?(state_file) && File.read(state_file) == content

File.write(state_file, content)
end

Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
Expand Down
2 changes: 1 addition & 1 deletion lib/locallingo/validators/manual_edits.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def call(target:, locale_state:, locale:)
type: :manual_edit,
locale:,
key:,
suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale}"
suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale} --key #{key}"
}
end
end
Expand Down
Loading
Loading