stringsPutHandler function

Future<Response> stringsPutHandler(
  1. DialectProject project,
  2. Request req,
  3. String key
)

PUT /api/strings/<key> — update a translation (or source) value.

Body shape:

{
  "locale": "es",
  "value": "Reservar ahora",
  "locked": true,           // optional; only meaningful when locale != source
  "glossary_exempt": false  // optional; only meaningful when locale == source
}

Side effects:

  • The target ARB file at dialect/translations/<locale>.arb (or dialect/source/<locale>.arb for the source locale) is rewritten in canonical form via ArbWriter. The file mtime updates only when the desired bytes differ from disk (M5 idempotency contract).
  • Locking writes @key.source_hash from the current source value per dialect/spec/source_hash.md. Unlocking clears it.
  • For the source locale, the request may set glossary_exempt on the @key block.

Implementation

Future<Response> stringsPutHandler(
  DialectProject project,
  Request req,
  String key,
) async {
  final bodyText = await req.readAsString();
  Map<String, Object?> body;
  try {
    final decoded = jsonDecode(bodyText);
    if (decoded is! Map<String, Object?>) {
      return _jsonError(400, 'Body must be a JSON object.');
    }
    body = decoded;
  } on FormatException catch (e) {
    return _jsonError(400, 'Body is not valid JSON: ${e.message}');
  }

  final locale = body['locale'];
  if (locale is! String || locale.isEmpty) {
    return _jsonError(400, '`locale` is required and must be a string.');
  }
  final value = body['value'];
  if (value is! String) {
    return _jsonError(400, '`value` is required and must be a string.');
  }
  final lockedReq = body['locked'];
  if (lockedReq != null && lockedReq is! bool) {
    return _jsonError(400, '`locked` must be a boolean when provided.');
  }
  final exemptReq = body['glossary_exempt'];
  if (exemptReq != null && exemptReq is! bool) {
    return _jsonError(
      400,
      '`glossary_exempt` must be a boolean when provided.',
    );
  }

  final isSource = locale == project.config.sourceLocale;
  final isTarget = project.config.targetLocales.contains(locale);
  if (!isSource && !isTarget) {
    return _jsonError(404, 'Locale `$locale` is not configured.');
  }

  // Look up the source entry by key. PUT may create a missing
  // translation but not a new source key (that's `dialect import`'s job).
  final sourceEntry = project.source.entryFor(key);
  if (sourceEntry == null) {
    return _jsonError(404, 'Unknown source key `$key`.');
  }

  final updatedArb = isSource
      ? _applySourceUpdate(
          project,
          key: key,
          value: value,
          glossaryExempt: exemptReq as bool?,
        )
      : _applyTranslationUpdate(
          project,
          locale: locale,
          key: key,
          value: value,
          sourceValue: sourceEntry.value,
          locked: lockedReq as bool?,
        );

  // Write back via ArbWriter for canonical formatting.
  final path = _arbPath(project, locale: locale, isSource: isSource);
  final desired = ArbWriter.encode(updatedArb);
  final file = File(path);
  file.parent.createSync(recursive: true);
  final currentBytes = file.existsSync() ? file.readAsStringSync() : null;
  if (currentBytes != desired) {
    file.writeAsStringSync(desired);
  }

  return Response.ok(
    jsonEncode({'key': key, 'locale': locale, 'value': value}),
    headers: const {'content-type': 'application/json; charset=utf-8'},
  );
}