← Back to Blog

Convert Google Sheets & CSV to Flutter ARB Files Safely

flutteri18narbcsvlocalizationgen-l10n

Convert Google Sheets & CSV to Flutter ARB Files Safely

Every Flutter team that works with outside translators hits the same wall: you can't email someone an app_de.arb file and expect it back intact. So the strings go into a Flutter translations spreadsheet, the translator fills in a column, and someone spends an afternoon hand-merging cells back into app_<locale>.arb files — until flutter gen-l10n fails with something like ICU Syntax Error: Expected "identifier" but found "}" and nobody knows which of 400 rows broke.

This post gives you a dependency-free Dart script to convert CSV to ARB files and back. It keeps every @-metadata block intact and validates placeholders and ICU plural blocks before writing anything. Then we'll walk through exactly where the Google Sheets Flutter localization workflow corrupts strings, because those are the bugs behind most mysterious gen-l10n failures.

What a safe round-trip must preserve

A gen-l10n message is two entries: the string itself and an @-metadata block that defines its placeholders:

{
  "inboxCount": "{count, plural, =0{No messages} =1{One message} other{{count} messages}}",
  "@inboxCount": {
    "description": "Badge label on the inbox tab",
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}

Three things must survive the trip through a spreadsheet:

  1. @-metadata. Placeholder types (int, DateTime), number formats like compact, and translator descriptions. gen-l10n reads this from your template ARB, and losing it changes the generated method signatures.
  2. Placeholder tokens. {userName} has to come back as exactly {userName} — not {nomUtilisateur}, not {username}.
  3. ICU plural and select blocks. The nested-brace syntax, including the mandatory other branch and language-specific categories like few and many.

A naive converter that rebuilds ARB files from spreadsheet columns destroys the first item entirely. The script below never rebuilds a file: it edits values in place in your existing ARB maps, so metadata, key order, and @@locale are untouched.

The script: CSV ↔ ARB in one file

Save this as tool/arb_csv.dart. It assumes gen-l10n's default app_<locale>.arb naming and uses no packages, so it's genuinely copy-paste. Export writes one row per key with a description column so translators get context; import merges the columns back and prints a warning for every suspicious cell.

// tool/arb_csv.dart
// Round-trips Flutter ARB files <-> CSV without touching @-metadata.
//
//   Export:  dart tool/arb_csv.dart export lib/l10n translations.csv
//   Import:  dart tool/arb_csv.dart import translations.csv lib/l10n
import 'dart:convert';
import 'dart:io';

const templateLocale = 'en'; // must match template-arb-file in l10n.yaml

void main(List<String> args) {
  if (args.length != 3 || !['export', 'import'].contains(args[0])) {
    stderr.writeln('Usage: dart tool/arb_csv.dart export <arbDir> <csv>\n'
        '       dart tool/arb_csv.dart import <csv> <arbDir>');
    exit(64);
  }
  if (args[0] == 'export') {
    exportCsv(args[1], args[2]);
  } else {
    importCsv(args[1], args[2]);
  }
}

Map<String, dynamic> readArb(File f) =>
    jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;

void exportCsv(String arbDir, String csvPath) {
  final arbs = <String, Map<String, dynamic>>{};
  for (final f in Directory(arbDir).listSync().whereType<File>()) {
    final m = RegExp(r'app_(\w+)\.arb$').firstMatch(f.path);
    if (m != null) arbs[m.group(1)!] = readArb(f);
  }
  final others = arbs.keys.where((l) => l != templateLocale).toList()..sort();
  final locales = [templateLocale, ...others];
  final template = arbs[templateLocale]!;
  final keys = template.keys.where((k) => !k.startsWith('@')).toList();

  final out = StringBuffer()..writeln(_row(['key', 'description', ...locales]));
  for (final key in keys) {
    final meta = template['@$key'] as Map<String, dynamic>?;
    out.writeln(_row([
      key,
      (meta?['description'] ?? '').toString(),
      for (final l in locales) (arbs[l]?[key] ?? '').toString(),
    ]));
  }
  File(csvPath).writeAsStringSync(out.toString());
  print('Exported ${keys.length} keys, ${locales.length} locales.');
}

void importCsv(String csvPath, String arbDir) {
  final rows = _parseCsv(File(csvPath).readAsStringSync());
  final locales = rows.first.sublist(2);
  final template = readArb(File('$arbDir/app_$templateLocale.arb'));
  var warnings = 0;

  for (var i = 0; i < locales.length; i++) {
    final file = File('$arbDir/app_${locales[i]}.arb');
    final arb = file.existsSync()
        ? readArb(file)
        : <String, dynamic>{'@@locale': locales[i]};
    for (final row in rows.skip(1)) {
      if (row.length <= i + 2) continue;
      final key = row[0];
      final value = row[i + 2];
      final source = template[key];
      if (value.isEmpty || source is! String) continue;
      warnings += _check(locales[i], key, source, value);
      arb[key] = value; // in place: @-metadata and key order survive
    }
    file.writeAsStringSync(
        '${const JsonEncoder.withIndent('  ').convert(arb)}\n');
  }
  print(warnings == 0
      ? 'Merged cleanly. Now run: flutter gen-l10n'
      : '$warnings warning(s) above -- fix them, then run flutter gen-l10n.');
}

// Simple placeholders like {userName}. The lookbehind skips braces glued
// to a word, so ICU branch text such as male{he} is not miscounted.
final _placeholder = RegExp(r'(?<!\w)\{([a-zA-Z][a-zA-Z0-9_]*)\}');
final _icu = RegExp(r'\{\s*\w+\s*,\s*(plural|select)\s*,');

int _check(String locale, String key, String source, String translated) {
  var n = 0;
  void warn(String msg) {
    stderr.writeln('  [$locale] $key: $msg');
    n++;
  }

  final expected = _placeholder.allMatches(source).map((m) => m[1]!).toSet();
  final actual = _placeholder.allMatches(translated).map((m) => m[1]!).toSet();
  if (expected.difference(actual).isNotEmpty) {
    warn('lost placeholder(s): ${expected.difference(actual).join(', ')}');
  }
  if (actual.difference(expected).isNotEmpty) {
    warn('unknown placeholder(s): ${actual.difference(expected).join(', ')}'
        ' -- renamed in translation?');
  }
  final curly = RegExp('[\u2018\u2019\u201C\u201D]');
  if (curly.hasMatch(translated) && !curly.hasMatch(source)) {
    warn('curly quotes added by the spreadsheet -- check ICU escaping');
  }
  if (_icu.hasMatch(source) && !RegExp(r'other\s*\{').hasMatch(translated)) {
    warn('plural/select lost its required "other" branch');
  }
  return n;
}

String _row(List<String> cells) => cells.map((c) {
      final escaped = c.replaceAll('"', '""');
      return RegExp(r'[",\r\n]').hasMatch(c) ? '"$escaped"' : c;
    }).join(',');

List<List<String>> _parseCsv(String text) {
  final rows = <List<String>>[];
  var row = <String>[];
  final cell = StringBuffer();
  var inQuotes = false;
  for (var i = 0; i < text.length; i++) {
    final ch = text[i];
    if (inQuotes) {
      if (ch == '"' && i + 1 < text.length && text[i + 1] == '"') {
        cell.write('"');
        i++;
      } else if (ch == '"') {
        inQuotes = false;
      } else {
        cell.write(ch);
      }
    } else if (ch == '"') {
      inQuotes = true;
    } else if (ch == ',') {
      row.add(cell.toString());
      cell.clear();
    } else if (ch == '\n' || ch == '\r') {
      if (ch == '\r' && i + 1 < text.length && text[i + 1] == '\n') i++;
      row.add(cell.toString());
      cell.clear();
      if (row.any((c) => c.isNotEmpty)) rows.add(row);
      row = <String>[];
    } else {
      cell.write(ch);
    }
  }
  if (cell.isNotEmpty || row.isNotEmpty) {
    row.add(cell.toString());
    rows.add(row);
  }
  return rows;
}

Export your files, share the CSV (or upload it to Google Sheets and later use File → Download → Comma Separated Values), then merge back:

dart tool/arb_csv.dart export lib/l10n translations.csv
# ...translator edits the spreadsheet...
dart tool/arb_csv.dart import translations.csv lib/l10n
flutter gen-l10n

A few deliberate design choices:

  • Import only overwrites message values. Every @-key in an existing ARB file survives, and rows whose key no longer exists in the template are skipped rather than inserted.
  • The description column is ignored on import. The ARB file is the source of truth for metadata, so a translator "fixing" a description can't corrupt your placeholders.
  • The checks are heuristics, not a full ICU parser. They catch the corruption patterns below; flutter gen-l10n remains the final gate. Add untranslated-messages-file: untranslated.json to your l10n.yaml to list remaining gaps — see our l10n.yaml configuration guide.

Where the spreadsheet silently corrupts your ARB files

These are the failure modes the _check function exists for. All of them are invisible in a spreadsheet cell.

1. Smart quotes break ICU escaping

Google Sheets and Excel autocorrect straight quotes (') into curly ones (). In ARB, the straight single quote is ICU's escape character — with use-escaping: true in l10n.yaml, '{...}' means literal braces. When autocorrect swaps those quotes, your escaped braces silently become live ICU syntax again, or you're left with a lone straight quote and get ICU Lexing Error: Unmatched single quotes — an error class reported repeatedly against gen-l10n. The difference between ' and is nearly impossible to spot by eye in a cell.

2. Translators translate the placeholder names

Hello {userName} comes back as Hola {nombreDeUsuario}. It's a reasonable thing for a translator to do — the token looks like text. But gen-l10n generates a method whose parameters come from the template's placeholder definitions, so the renamed token either fails generation or ships as literal text. Select messages have a subtler version: ICU select matching is case-sensitive, so a translation using Male where your code passes male silently falls through to the other branch with no error at all.

3. Plural categories your template never showed

This one produces no error — it's absence, not corruption. English needs only one and other, so that's all your spreadsheet shows. But Polish and Russian also require few and many, and Arabic uses all six CLDR categories (zero, one, two, few, many, other). A translator working row by row translates the branches they can see, gen-l10n compiles happily, and Russian users get broken plural grammar at runtime. A spreadsheet can't know that a cell is incomplete for a language. This is exactly what FlutterLocalisation's ICU plural-syntax validation is for: it flags any locale that's missing a plural category the language actually requires.

4. The spreadsheet "helps" in other ways

A cell starting with =, +, or - is a formula to Google Sheets; trailing whitespace gets trimmed; a translator pressing Enter inside a cell produces a real newline (which the script round-trips correctly as \n in JSON), while typing a literal backslash-n produces two characters that render verbatim in your UI. None of this shows up in a visual scan of 400 rows.

The workflow that makes the round-trip unnecessary

The script hardens the pipeline, but it can't fix the structural problem: a spreadsheet has no idea what an ICU message is, so every export/import cycle is another chance for silent damage.

The alternative is to stop converting formats and manage ARB translation files in a tool that speaks ARB natively. FlutterLocalisation's ARB editor lets you and your translators edit app_<locale>.arb content in a UI instead of raw JSON, manage every locale side by side, and get ICU plural validation that catches the dropped few/many categories from section 3 before they ship. There's a free tier, so the whole CSV round-trip — script included — becomes something you keep only for archival exports.

Try FlutterLocalisation free and retire the spreadsheet merge for good.