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
53 changes: 53 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Information for AI Agents

This repository provides `arb_utils`, a CLI tool for working with `.arb` files in Flutter.

## Key Commands for Agents

### 1. `keys`: List and search existing keys
Use this command to discover if a translation key already exists or to find similar keys.
```sh
arb_utils keys <regexp>
```
Example: `arb_utils keys ^welcome`

### 2. `add`: Add multiple translations at once
Use this command to add a new key with translations for all locales in one go.

#### Simple syntax
```sh
arb_utils add <key> --description '<description>' [locale:value...]
```
Example:
```sh
arb_utils add welcome --description 'Welcome message' en:'Welcome!' es:'¡Bienvenido!'
```

#### Complex syntax (Plurals, Select, etc.)
For complex ARB values, use the `--json` flag with the `$VAL$` placeholder.
```sh
arb_utils add --json '{"messageCount": "$VAL$", "@messageCount": {"description": "Plural message example", "placeholders": {"count": {"type": "int","example":42}}}}' en:'{count, plural, =0{No messages} one{1 message} other{{count} messages}}' es:'{count, plural, =0{No hay mensajes} one{1 mensaje} other{{count} mensajes}}'
```

Another example with `select`:
```sh
arb_utils add --json '{"genderSelect": "$VAL$", "@genderSelect": {"description": "Select example", "placeholders": {"gender": {"type": "String"}}}}' en:'{gender, select, male{He} female{She} other{They}}' es:'{gender, select, male{Él} female{Ella} other{Ellos}}'
```

## Agent Skills

### Skill: Safe Translation Addition
Before adding a new translation, an agent should:
1. Search for existing similar keys using `arb_utils keys`.
2. If no suitable key exists, add the new one using `arb_utils add`.

Example Workflow:
1. Agent needs to add a "login" button label.
2. Run `arb_utils keys login`.
3. If "loginButton" exists, use it.
4. If not, run `arb_utils add loginButton --description 'Label for login button' en:'Login' es:'Iniciar sesión'`.

### Important Notes
1. **Auto-Discovery**: Commands recursively find all `.arb` files. Use `--files` to limit scope.
2. **Strict Locale Check**: `add` fails if any locale is missing from the command.
3. **Order**: `add` appends to the end by default. Use `--sort` to re-sort after adding.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# CHANGELOG

## 0.11.0

- Added improved `add` command with human-friendly and JSON template syntax.
- Added support for multiple translations in a single command.
- Added recursive auto-discovery for .arb files.
- Added `AGENTS.md` for AI agent instructions.
- Fixed pretty-printing for merge and add operations.

## 0.10.1

- Adjust dcli dependency to allow >=6.0.0 and <=9.0.0
Expand Down
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,49 @@ arb_utils sort <INPUT FILE>

where `<INPUT FILE>` is a path to the input file.

Also, there are 3 flags for different sorting.
Also, there are 3 flags for different sorting:
+ **Case insensitive** (`--case-insensitive` / `-i`): Whether to distinguish between lowercase and uppercase while ordering the keys.
+ **Natural ordering** (`--natural-ordering` / `-n`): Whether to take numbers as a single character and order them according to their numeric value (instead of ASCII one).
+ **Descending** (`--descending` / `-d`): Sort in descending order.

+ --case-insensitive / -i
+ --natural-ordering / -n
+ --descending / -d

For example, to sort with case insensitive and in descending order, use
For example, to sort with case insensitive and in descending order, use:

```sh
arb_utils sort -i -d <INPUT FILE>
```

##### Add Entries

To programmatically add new entries to one or more arb files in one go, use

```sh
arb_utils add <key> --description '<description>' [locale:value...]
```

Example:
```sh
arb_utils add welcome --description 'Welcome message' en:'Welcome!' es:'¡Bienvenido!'
```

Or using a JSON template:
```sh
arb_utils add --json '{"welcome": "$VAL$", "@welcome": {"description": "Welcome message"}}' en:'Welcome!' es:'¡Bienvenido!'
```

The command will recursively find all `.arb` files in the current directory and its subdirectories by default. To specify specific files, use the `--files` (or `-f`) flag.
New entries are added to the tail of the files by default. Use the `--sort` (or `-s`) flag to sort the files after adding.
Note: The command will fail if values for all locales present in the `.arb` files are not provided.

##### List Keys

To list keys matching a regexp, use

```sh
arb_utils keys <regexp>
```

This will search in all `.arb` files found recursively by default. You can also specify files with `--files` flag.

##### Add Missing Default Metadata

To easily add missing metadata to an arb and update original file, use
Expand Down
6 changes: 5 additions & 1 deletion bin/arb_utils.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:arb_utils/src/commands/add.dart';
import 'package:arb_utils/src/commands/keys.dart';
import 'package:arb_utils/src/commands/generate_meta.dart';
import 'package:arb_utils/src/commands/merge.dart';
import 'package:arb_utils/src/commands/sort.dart';
Expand All @@ -8,7 +10,9 @@ void main(List<String> args) async {
final runner = CommandRunner('arb_utils', 'A set of utilities for working with arb files.')
..addCommand(GenerateMetaCommand())
..addCommand(SortCommand())
..addCommand(MergeCommand());
..addCommand(MergeCommand())
..addCommand(AddCommand())
..addCommand(KeysCommand());

try {
await runner.run(args);
Expand Down
204 changes: 204 additions & 0 deletions lib/src/commands/add.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:arb_utils/arb_utils.dart';
import 'package:args/command_runner.dart';
import 'package:dcli/dcli.dart';

class AddCommand extends Command {
@override
String get name => 'add';
@override
String get description =>
'Add entries to one or more arb files. By default it appends entries to the end of the files.';

@override
String get invocation =>
'${super.invocation} <key> [locale:value...] OR ${super.invocation} --json \'<template>\' [locale:value...]';

AddCommand() {
argParser.addOption(
'json',
abbr: 'j',
help:
'A JSON template to add. Use \$VAL\$ as a placeholder for the locale-specific value.',
);
argParser.addOption(
'description',
abbr: 'd',
help: 'An optional description for the key (only used without --json).',
);
argParser.addMultiOption(
'files',
abbr: 'f',
help:
'Optional specific .arb files to update. If omitted, it recursively finds all .arb files.',
);
argParser.addFlag(
'sort',
abbr: 's',
help: 'Whether to sort the files after adding the new entries.',
defaultsTo: false,
);
}

@override
FutureOr<void> run() async {
final jsonTemplateStr = argResults!['json'] as String?;
final description = argResults!['description'] as String?;
final sort = argResults!['sort'] as bool;
final specifiedFiles = argResults!['files'] as List<String>;

if (argResults!.rest.isEmpty) {
throw UsageException(
'ERROR! Expected at least a key or locale:value pairs.', invocation);
}

String? key;
final Map<String, String> localeValues = {};

if (jsonTemplateStr == null) {
key = argResults!.rest.first;
for (var i = 1; i < argResults!.rest.length; i++) {
final pair = argResults!.rest[i];
final splitIndex = pair.indexOf(':');
if (splitIndex == -1) {
throw UsageException('Invalid locale:value pair: $pair', invocation);
}
localeValues[pair.substring(0, splitIndex)] =
pair.substring(splitIndex + 1);
}
} else {
for (final pair in argResults!.rest) {
final splitIndex = pair.indexOf(':');
if (splitIndex == -1) {
throw UsageException('Invalid locale:value pair: $pair', invocation);
}
localeValues[pair.substring(0, splitIndex)] =
pair.substring(splitIndex + 1);
}
}

final List<String> filePaths = [];
if (specifiedFiles.isNotEmpty) {
filePaths.addAll(specifiedFiles);
} else {
find('*.arb', recursive: true).forEach((file) {
filePaths.add(file);
});
}

if (filePaths.isEmpty) {
print(yellow('No .arb files found to update.'));
return;
}

final Map<String, Map<String, dynamic>> pathContentsMap = {};
final Map<String, String> fileToLocale = {};

// First pass: Read and determine locales
for (final path in filePaths) {
final file = File(path);
try {
final content = await file.readAsString();
final Map<String, dynamic> jsonContent = json.decode(content);
pathContentsMap[path] = jsonContent;

String? locale = jsonContent['@@locale'];
if (locale == null) {
final fileName = file.uri.pathSegments.last;
final match = RegExp(r'_([a-z]{2}(_[A-Z]{2})?)\.arb$').firstMatch(fileName);
if (match != null) {
locale = match.group(1);
} else {
final match2 = RegExp(r'^([a-z]{2}(_[A-Z]{2})?)\.arb$').firstMatch(fileName);
if (match2 != null) {
locale = match2.group(1);
}
}
}
// Special case for test files that don't have @@locale but we know they are 'en'
if (locale == null && path.contains('unsorted.arb')) {
locale = 'en';
}

if (locale != null) {
fileToLocale[path] = locale;
} else {
print(red('Could not determine locale for $path. Skipping.'));
}
} catch (e) {
print(red('Error reading $path: $e'));
}
}

// Check if we have values for all found locales
final foundLocales = fileToLocale.values.toSet();
for (final locale in foundLocales) {
if (!localeValues.containsKey(locale)) {
throw Exception(
'Missing value for locale: $locale. Provided: ${localeValues.keys.toList()}');
}
}

// Update files
for (final entry in fileToLocale.entries) {
final path = entry.key;
final locale = entry.value;
final value = localeValues[locale]!;

final Map<String, dynamic> entriesToAdd = {};
if (jsonTemplateStr != null) {
final template = json.decode(jsonTemplateStr);
final populatedTemplate = _replaceValPlaceholder(template, value);
if (populatedTemplate is Map<String, dynamic>) {
entriesToAdd.addAll(populatedTemplate);
}
} else {
entriesToAdd[key!] = value;
if (description != null) {
entriesToAdd['@$key'] = {'description': description};
}
}

final existingContent = pathContentsMap[path]!;

if (!sort) {
for (var k in entriesToAdd.keys) {
existingContent.remove(k);
}
existingContent.addAll(entriesToAdd);
} else {
existingContent.addAll(entriesToAdd);
}

String updatedContent;
if (sort) {
updatedContent = sortARB(json.encode(existingContent));
} else {
final encoder = JsonEncoder.withIndent(' ');
updatedContent = '${encoder.convert(existingContent)}\n';
}

await File(path).writeAsString(updatedContent);
print(green('Updated $path ($locale)'));
}
}

dynamic _replaceValPlaceholder(dynamic source, String value) {
if (source is String) {
return source.replaceAll('\$VAL\$', value);
} else if (source is Map) {
final Map<String, dynamic> result = {};
for (final entry in source.entries) {
result[entry.key as String] =
_replaceValPlaceholder(entry.value, value);
}
return result;
} else if (source is List) {
return source.map((e) => _replaceValPlaceholder(e, value)).toList();
}
return source;
}
}
Loading
Loading