Skip to content

Dictionaries: management commands and the translation round-trip - #44

Open
mateuscardosodeveloper wants to merge 7 commits into
feat/action-management-commandsfrom
feat/dictionary-management-commands
Open

mateuscardosodeveloper wants to merge 7 commits into
feat/action-management-commandsfrom
feat/dictionary-management-commands

Conversation

@mateuscardosodeveloper

Copy link
Copy Markdown
Collaborator

Summary

Adds a dict-* command family covering Dictionaries, the multi-language string store behind TagoRUN portals and dashboards. A translation could previously only be edited in the web admin, one string at a time, so translation work was unversionable: there was no way to export a language, hand it to a translator, review the diff in git, and import it back.

Six commands: dict-list, dict-info, dict-create, dict-edit, dict-delete, and dict-lang for the language content inside a dictionary. All carry the family conventions established by PR #43: --json with parseable error codes, --silent for non-interactive callers, an interactive picker when the ID is omitted, and confirmation on destructive paths.

Stacked on #43 — that branch is its base, since both touch src/index.ts and the man snapshot.

The translation round-trip

tagoio dict-lang <id> pt-BR --json > pt-BR.json   # export
# translate the file
tagoio dict-lang <id> pt-BR --file pt-BR.json     # import

dict-lang is one command with mutually-exclusive mode flags rather than three separate commands, following entity-data and entity-schema: separate commands are the convention for top-level resources, and a language lives inside a dictionary.

Writing replaces the whole language, mirroring the API's PUT. --merge keeps the keys a payload omits. Without it, a replace that would drop keys confirms with the counts first, so a stray --set ONE_KEY=x cannot silently wipe a translation.

API constraints encoded

Three rules absent from the SDK types and the docs, each found by probing a live profile and now checked offline:

  • a slug is uppercase alphanumeric, at most 7 characters
  • a content key is uppercase letters, digits and underscores, at least 2 characters
  • a locale is well-formed (shape only; a closed list would need product input)

The API reports the key rule without naming the offending key. Since a translation file can hold hundreds of entries, the offline check names it.

Notes on the SDK

dictionaries.create resolves { dictionary: "<id>" } — the third distinct id key in this codebase, after devices' { device_id } and actions' { action }. dictionaries.info omits id entirely, unlike actions and devices, so dict-info --json fills it in from the id that was requested.

Reading a locale the API has never seen fails, and a language only exists once languageEdit writes to it, so the first write to any new locale necessarily reads a missing one. dict-lang swallows that specific failure; without it --merge and the replace diff could not work on a new language.

Shared helper extracted

entity-data.ts and entity-schema.ts each carried a private, identical copy of parseJSON. Rather than add a third, it moves to src/lib/parse-json-flag.ts and both migrate to it. Their call sites and the json_parse_failed code are unchanged.

Test plan

  • 1067 unit tests pass (npm test); commands/dictionaries above 90% on every file
  • npx tsc --noEmit clean, npm run linter 0 errors, oxfmt --check clean
  • Entity suites green after the parseJSON extraction (79 assertions, unchanged)
  • Man snapshot regenerated; diff reviewed as purely additive (six new .SS entries, no removals, no Header leak). Regression guard extended with dict-list
  • 55 functional cases against a live profile, all six commands
  • Live: full round-trip — export, edit, import, re-read matches
  • Live: --merge preserves keys absent from the payload; without it a key-dropping replace prompts with the counts and declining changes nothing
  • Live: first write to a language that does not exist yet succeeds
  • Live: --slug read route returns the same content as the id route
  • Live: --inactive marks a language inactive and dict-info reflects it
  • Live: the API refuses to delete a dictionary's fallback language
  • Live: dict-info and dict-lang write nothing to stdout in human mode
  • Every test dictionary removed; profile verified back to its exact baseline by id

Risk (CIA)

Likelihood: 🟢 Low | Impact: 🟡 Medium | Exposure: 🟢 Low

Warning

Impact is Medium because dict-delete removes a dictionary and every translation in it, and a dict-lang write replaces a language wholesale by default. Deletion confirms unless -y or --silent is explicit, naming the language count so the scope is visible. A replace that would drop keys confirms with the added/removed counts, and a declined prompt exits 0 without a request. The parseJSON extraction touches two entity commands, but their call sites, error code and tests are unchanged.

Interactive autocomplete over the profile's dictionaries, mirroring
pickEntityIDFromTagoIO so the upcoming dict-* commands share the prompt
UX of the device, entity and action families.

Labels each choice "name [slug]": a dictionary carries both, and the
slug is the meaningful secondary identifier since it is what
languageInfoBySlug takes. The label builder is exported so it can be
tested directly — the module calls prompts(...) as a function, which a
spy on prompts.prompt never intercepts.
entity-data.ts and entity-schema.ts each carried a private, identical
copy of parseJSON. The dictionary commands need the same logic, so this
extracts it rather than adding a third copy.

The helper also takes the array/object discrimination the action
builders needed, and a code override so the entity call sites keep
reporting json_parse_failed — the code their tests already assert.
Pure assembly and validation for the language payload: reading a JSON
file, parsing repeatable --set pairs, merging the two, and diffing
against the current content to drive the replace confirmation.

Encodes three API constraints that are absent from the SDK types and
from the docs, each verified against a live profile:

  - a slug is uppercase alphanumeric, at most 7 characters
  - a content key is uppercase letters, digits and underscores, at
    least 2 characters
  - a locale is well-formed (shape only; a closed list would need
    product input)

The API reports the key rule without naming the offending key, so the
offline check names it — a translation file can hold hundreds of
entries.

readContentFile guards with existsSync before reading. entity-create.ts
reads a user-supplied path with a bare readFileSync, so a wrong path
escapes as an unhandled ENOENT with a stack trace instead of a CLI
error; that is not repeated here.
Read-only surface. dict-list collapses the languages array to its codes
in human mode, since a table cell renders an object as [object Object].

dict-info renders its human view entirely on stderr. console.table
writes to stdout, which is reserved for --json — the leak that shipped
in action-info and was caught only by a functional test. dict-list keeps
console.table because there the table is the command's data output,
matching device-list and entity-list.

The API omits id from the dictionaries.info payload, unlike actions and
devices, so --json fills it in from the id that was requested.
Otherwise a machine reader has no way to identify what it just read,
and the human view prints [undefined].
dict-create reads the new id from the SDK's { dictionary } response
rather than { id }. This is the third distinct id key in the codebase,
after devices' { device_id } and actions' { action }, and reading the
wrong one yields undefined ids in --json output.

Name, slug and fallback are all required by the API; each prompts when
omitted and fails under --silent. Slug and locale are validated offline,
so a malformed value never costs a request.

dict-edit builds a partial patch and rejects an empty one with
no_changes before calling the API. dict-delete confirms unless -y or
--silent, naming the language count so the scope of the deletion is
visible, and a declined prompt exits 0 without a request.
One command with mutually-exclusive mode flags, following entity-data
and entity-schema: no flag reads, --file/--set writes, --delete removes.
Separate commands are the convention for top-level resources; a language
lives inside a dictionary.

Writing replaces the whole language, mirroring the API's PUT. --merge
reads the current content first and layers the payload on top. Without
it, a replace that would drop keys confirms with the counts, so a
--set ONE_KEY=x cannot silently wipe a translation.

The read path swallows a missing-locale error rather than surfacing it:
a language only exists once languageEdit writes to it, so the first
write to any new locale necessarily reads one the API has never seen.

--fallback defaults off on the id route, so a translation export shows
only what the locale itself owns. The slug route forces it on — the SDK
coerces fallback: false to true there — and --help says so.
Wires the family into the program, which also puts it in the man page
through buildProgram. Regenerates the snapshot and extends the
command-surface regression guard with dict-list.

The --help text carries the behaviours that are invisible from the flags
alone: that writing replaces the whole language and --merge is how to
keep the rest, the export-translate-import round-trip, the slug's
uppercase-alphanumeric rule, and that the --slug read route always
applies the fallback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant