@_linked/translation 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/esm/archive.d.ts +12 -3
- package/lib/esm/archive.js +23 -9
- package/lib/esm/archive.js.map +1 -1
- package/lib/esm/formats/csv.d.ts +1 -1
- package/lib/esm/formats/csv.js +11 -6
- package/lib/esm/formats/csv.js.map +1 -1
- package/lib/esm/formats/json.js +19 -14
- package/lib/esm/formats/json.js.map +1 -1
- package/lib/esm/formats/xliff.js +47 -31
- package/lib/esm/formats/xliff.js.map +1 -1
- package/lib/esm/index.d.ts +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/ontologies/translation.js +0 -3
- package/lib/esm/ontologies/translation.js.map +1 -1
- package/lib/esm/ontologies/translation.register.d.ts +1 -0
- package/lib/esm/ontologies/translation.register.js +17 -0
- package/lib/esm/ontologies/translation.register.js.map +1 -0
- package/lib/esm/shapes/TranslationProvider.d.ts +1 -1
- package/lib/esm/shapes/TranslationProvider.js +1 -1
- package/package.json +1 -1
- package/src/archive.ts +31 -11
- package/src/formats/csv.ts +14 -6
- package/src/formats/json.ts +28 -19
- package/src/formats/xliff.ts +54 -30
- package/src/index.ts +1 -1
- package/src/ontologies/translation.register.ts +17 -0
- package/src/ontologies/translation.ts +0 -3
- package/src/shapes/TranslationProvider.ts +1 -1
package/src/formats/json.ts
CHANGED
|
@@ -13,7 +13,11 @@ import { canonicalLanguageTag } from '../key-version.js';
|
|
|
13
13
|
|
|
14
14
|
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
15
15
|
const textEncoder = new TextEncoder();
|
|
16
|
-
const SIDECAR_PATH = '
|
|
16
|
+
const SIDECAR_PATH = 'linked.exchange.json';
|
|
17
|
+
/** Pre-0.3.0 sidecar path and format id — read, never written. */
|
|
18
|
+
const LEGACY_SIDECAR_PATH = 'create-now.exchange.json';
|
|
19
|
+
const SIDECAR_FORMAT = 'linked-i18next-zip';
|
|
20
|
+
const LEGACY_SIDECAR_FORMAT = 'create-now-i18next-zip';
|
|
17
21
|
const ZIP_LOCAL_FILE_SIGNATURE = 0x04034b50;
|
|
18
22
|
const ZIP_EMPTY_SIGNATURE = 0x06054b50;
|
|
19
23
|
const ZIP_SPANNED_SIGNATURE = 0x08074b50;
|
|
@@ -55,9 +59,9 @@ export interface TranslationJsonSerializeOptions {
|
|
|
55
59
|
limits?: Partial<TranslationJsonArchiveLimits>;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
interface
|
|
62
|
+
interface TranslationJsonSidecar {
|
|
59
63
|
schemaVersion: 1;
|
|
60
|
-
format:
|
|
64
|
+
format: typeof SIDECAR_FORMAT | typeof LEGACY_SIDECAR_FORMAT;
|
|
61
65
|
exchange: TranslationExchangeDocument;
|
|
62
66
|
files: Record<string, string>;
|
|
63
67
|
}
|
|
@@ -356,21 +360,24 @@ function parseStandalone(
|
|
|
356
360
|
});
|
|
357
361
|
}
|
|
358
362
|
|
|
359
|
-
function sidecarFromBytes(
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
function sidecarFromBytes(
|
|
364
|
+
input: Uint8Array,
|
|
365
|
+
path: string,
|
|
366
|
+
): TranslationJsonSidecar {
|
|
367
|
+
const value = parseJsonObject(input, path) as unknown as Partial<
|
|
368
|
+
TranslationJsonSidecar
|
|
362
369
|
>;
|
|
363
370
|
if (
|
|
364
371
|
value.schemaVersion !== 1 ||
|
|
365
|
-
value.format !==
|
|
372
|
+
(value.format !== SIDECAR_FORMAT && value.format !== LEGACY_SIDECAR_FORMAT) ||
|
|
366
373
|
!value.exchange ||
|
|
367
374
|
!value.files ||
|
|
368
375
|
typeof value.files !== 'object' ||
|
|
369
376
|
Array.isArray(value.files)
|
|
370
377
|
) {
|
|
371
|
-
throw new Error('
|
|
378
|
+
throw new Error('Translation JSON ZIP sidecar is invalid.');
|
|
372
379
|
}
|
|
373
|
-
return value as
|
|
380
|
+
return value as TranslationJsonSidecar;
|
|
374
381
|
}
|
|
375
382
|
|
|
376
383
|
function assertMapsEqual(
|
|
@@ -384,12 +391,12 @@ function assertMapsEqual(
|
|
|
384
391
|
actualKeys.length !== expectedKeys.length ||
|
|
385
392
|
actualKeys.some((key, index) => key !== expectedKeys[index])
|
|
386
393
|
) {
|
|
387
|
-
throw new Error(`${label} keys do not match the
|
|
394
|
+
throw new Error(`${label} keys do not match the exchange sidecar.`);
|
|
388
395
|
}
|
|
389
396
|
for (const key of expectedKeys) {
|
|
390
397
|
if (actual[key] !== expected[key]) {
|
|
391
398
|
throw new Error(
|
|
392
|
-
`${label} value for "${key}" does not match the
|
|
399
|
+
`${label} value for "${key}" does not match the exchange sidecar.`,
|
|
393
400
|
);
|
|
394
401
|
}
|
|
395
402
|
}
|
|
@@ -400,11 +407,13 @@ function parseArchive(
|
|
|
400
407
|
options: TranslationJsonParseOptions,
|
|
401
408
|
): ParsedTranslationExchangeDocument {
|
|
402
409
|
const files = safeUnzipTranslationZip(input, options.limits);
|
|
403
|
-
const
|
|
410
|
+
const sidecarPath =
|
|
411
|
+
files[SIDECAR_PATH] !== undefined ? SIDECAR_PATH : LEGACY_SIDECAR_PATH;
|
|
412
|
+
const sidecarBytes = files[sidecarPath];
|
|
404
413
|
if (!sidecarBytes) {
|
|
405
414
|
return parseTolgeeArchive(files, options);
|
|
406
415
|
}
|
|
407
|
-
const sidecar = sidecarFromBytes(sidecarBytes);
|
|
416
|
+
const sidecar = sidecarFromBytes(sidecarBytes, sidecarPath);
|
|
408
417
|
const exchange = validateTranslationExchangeDocument(sidecar.exchange);
|
|
409
418
|
const expectedLanguages = [
|
|
410
419
|
exchange.sourceLanguage,
|
|
@@ -418,22 +427,22 @@ function parseArchive(
|
|
|
418
427
|
.sort()
|
|
419
428
|
.some((language, index) => language !== fileLanguages[index])
|
|
420
429
|
) {
|
|
421
|
-
throw new Error('
|
|
430
|
+
throw new Error('Translation JSON ZIP language manifest is incomplete.');
|
|
422
431
|
}
|
|
423
432
|
const usedPaths = new Set<string>();
|
|
424
433
|
for (const language of expectedLanguages) {
|
|
425
434
|
const path = sidecar.files[language];
|
|
426
435
|
if (typeof path !== 'string') {
|
|
427
|
-
throw new Error(`
|
|
436
|
+
throw new Error(`Translation JSON ZIP has no file for ${language}.`);
|
|
428
437
|
}
|
|
429
438
|
assertSafeArchivePath(path);
|
|
430
439
|
if (usedPaths.has(path)) {
|
|
431
|
-
throw new Error(`
|
|
440
|
+
throw new Error(`Translation JSON ZIP reuses language file "${path}".`);
|
|
432
441
|
}
|
|
433
442
|
usedPaths.add(path);
|
|
434
443
|
const bytes = files[path];
|
|
435
444
|
if (!bytes) {
|
|
436
|
-
throw new Error(`
|
|
445
|
+
throw new Error(`Translation JSON ZIP is missing language file "${path}".`);
|
|
437
446
|
}
|
|
438
447
|
const actual = flattenJsonObject(parseJsonObject(bytes, path));
|
|
439
448
|
const expected = languageValues(exchange, language);
|
|
@@ -534,9 +543,9 @@ export function serializeI18nextJsonZip(
|
|
|
534
543
|
languageFiles[language] = path;
|
|
535
544
|
files[path] = serializeLanguage(exchange, language, layout);
|
|
536
545
|
}
|
|
537
|
-
const sidecar:
|
|
546
|
+
const sidecar: TranslationJsonSidecar = {
|
|
538
547
|
schemaVersion: 1,
|
|
539
|
-
format:
|
|
548
|
+
format: SIDECAR_FORMAT,
|
|
540
549
|
exchange,
|
|
541
550
|
files: languageFiles,
|
|
542
551
|
};
|
package/src/formats/xliff.ts
CHANGED
|
@@ -10,7 +10,27 @@ import {
|
|
|
10
10
|
import type { TranslationState } from '../records.js';
|
|
11
11
|
|
|
12
12
|
const UTF8 = new TextEncoder();
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Our own metadata namespace, a sibling of the translation vocabulary
|
|
15
|
+
* (`https://id.linked.cm/translation/vocab#`). Files exported before 0.3.0
|
|
16
|
+
* used a Create Now URI and a `cn:` prefix; the reader below still accepts
|
|
17
|
+
* both, so those files keep importing.
|
|
18
|
+
*/
|
|
19
|
+
const EXCHANGE_NAMESPACE = 'https://id.linked.cm/translation/exchange/1';
|
|
20
|
+
const PREFIX = 'lt';
|
|
21
|
+
const LEGACY_PREFIX = 'cn';
|
|
22
|
+
/** `from`/`category` on a `<note>`, in current and pre-0.3.0 spelling. */
|
|
23
|
+
const NOTE_ROLES = {
|
|
24
|
+
description: ['linked-description', 'create-now-description'],
|
|
25
|
+
target: ['linked-target', 'create-now-target'],
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
function isNoteRole(
|
|
29
|
+
value: string | undefined,
|
|
30
|
+
role: keyof typeof NOTE_ROLES,
|
|
31
|
+
): boolean {
|
|
32
|
+
return value !== undefined && (NOTE_ROLES[role] as readonly string[]).includes(value);
|
|
33
|
+
}
|
|
14
34
|
const XLIFF_12_NAMESPACE = 'urn:oasis:names:tc:xliff:document:1.2';
|
|
15
35
|
const XLIFF_20_NAMESPACE = 'urn:oasis:names:tc:xliff:document:2.0';
|
|
16
36
|
const XML_MEDIA_TYPE = 'application/xliff+xml';
|
|
@@ -301,7 +321,11 @@ function plainText(node: XmlNode | undefined): string | undefined {
|
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
function metadata(node: XmlNode, name: string): string | undefined {
|
|
304
|
-
return
|
|
324
|
+
return (
|
|
325
|
+
node.attributes[`${PREFIX}:${name}`] ??
|
|
326
|
+
node.attributes[`${LEGACY_PREFIX}:${name}`] ??
|
|
327
|
+
attribute(node, name)
|
|
328
|
+
);
|
|
305
329
|
}
|
|
306
330
|
|
|
307
331
|
function recognizedState(value: string | undefined): TranslationState | undefined {
|
|
@@ -419,12 +443,12 @@ function parse12(
|
|
|
419
443
|
if (language) targetLanguages.add(language);
|
|
420
444
|
const notes = children(unit, 'note');
|
|
421
445
|
const description = plainText(
|
|
422
|
-
notes.find((note) => attribute(note, 'from')
|
|
446
|
+
notes.find((note) => isNoteRole(attribute(note, 'from'), 'description')),
|
|
423
447
|
);
|
|
424
448
|
const targetNote = plainText(
|
|
425
449
|
notes.find(
|
|
426
450
|
(note) =>
|
|
427
|
-
attribute(note, 'from')
|
|
451
|
+
isNoteRole(attribute(note, 'from'), 'target') &&
|
|
428
452
|
(!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
|
|
429
453
|
),
|
|
430
454
|
);
|
|
@@ -475,13 +499,13 @@ function parse20(
|
|
|
475
499
|
const notes = notesContainer ? children(notesContainer, 'note') : [];
|
|
476
500
|
const description = plainText(
|
|
477
501
|
notes.find(
|
|
478
|
-
(note) => attribute(note, 'category')
|
|
502
|
+
(note) => isNoteRole(attribute(note, 'category'), 'description'),
|
|
479
503
|
),
|
|
480
504
|
);
|
|
481
505
|
const targetNote = plainText(
|
|
482
506
|
notes.find(
|
|
483
507
|
(note) =>
|
|
484
|
-
attribute(note, 'category')
|
|
508
|
+
isNoteRole(attribute(note, 'category'), 'target') &&
|
|
485
509
|
(!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
|
|
486
510
|
),
|
|
487
511
|
);
|
|
@@ -545,14 +569,14 @@ function state20(state: TranslationState | undefined): string {
|
|
|
545
569
|
|
|
546
570
|
function metadataAttributes(entry: TranslationExchangeEntry): string {
|
|
547
571
|
return [
|
|
548
|
-
[
|
|
549
|
-
[
|
|
550
|
-
[
|
|
551
|
-
[
|
|
552
|
-
[
|
|
553
|
-
[
|
|
554
|
-
[
|
|
555
|
-
[
|
|
572
|
+
[`${PREFIX}:key`, entry.key],
|
|
573
|
+
[`${PREFIX}:namespace`, entry.namespace],
|
|
574
|
+
[`${PREFIX}:kind`, entry.kind],
|
|
575
|
+
[`${PREFIX}:format`, entry.format],
|
|
576
|
+
[`${PREFIX}:key-version-id`, entry.keyVersionId],
|
|
577
|
+
[`${PREFIX}:source-hash`, entry.sourceHash],
|
|
578
|
+
[`${PREFIX}:contract-hash`, entry.contractHash],
|
|
579
|
+
[`${PREFIX}:argument-signature`, entry.argumentSignature],
|
|
556
580
|
]
|
|
557
581
|
.filter((pair): pair is [string, string] => pair[1] !== undefined)
|
|
558
582
|
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
@@ -561,12 +585,12 @@ function metadataAttributes(entry: TranslationExchangeEntry): string {
|
|
|
561
585
|
|
|
562
586
|
function rootMetadata(document: TranslationExchangeDocument): string {
|
|
563
587
|
return [
|
|
564
|
-
[
|
|
565
|
-
[
|
|
566
|
-
[
|
|
567
|
-
[
|
|
568
|
-
[
|
|
569
|
-
[
|
|
588
|
+
[`${PREFIX}:schema-version`, String(document.schemaVersion)],
|
|
589
|
+
[`${PREFIX}:app-id`, document.appId],
|
|
590
|
+
[`${PREFIX}:branch-id`, document.branchId],
|
|
591
|
+
[`${PREFIX}:exported-at`, document.exportedAt],
|
|
592
|
+
[`${PREFIX}:revision-watermark`, document.revisionWatermark],
|
|
593
|
+
[`${PREFIX}:contract-set-hash`, document.contractSetHash],
|
|
570
594
|
]
|
|
571
595
|
.filter((pair): pair is [string, string] => pair[1] !== undefined)
|
|
572
596
|
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
@@ -611,25 +635,25 @@ function serialize12(document: TranslationExchangeDocument): string {
|
|
|
611
635
|
const id = deterministicId(entry, index);
|
|
612
636
|
const notes = [
|
|
613
637
|
entry.description
|
|
614
|
-
? `<note from="
|
|
638
|
+
? `<note from="${NOTE_ROLES.description[0]}">${escapeText(entry.description)}</note>`
|
|
615
639
|
: '',
|
|
616
640
|
target?.note
|
|
617
|
-
? `<note from="
|
|
641
|
+
? `<note from="${NOTE_ROLES.target[0]}" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
|
|
618
642
|
: '',
|
|
619
643
|
].join('');
|
|
620
644
|
return `<trans-unit id="${escapeAttribute(id)}" resname="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}><source>${validatedMixedContent(entry.sourceText, INLINE_12)}</source>${
|
|
621
645
|
language && target
|
|
622
|
-
? `<target xml:lang="${escapeAttribute(language)}" state="${state12(target?.state)}"
|
|
646
|
+
? `<target xml:lang="${escapeAttribute(language)}" state="${state12(target?.state)}" ${PREFIX}:state="${escapeAttribute(target?.state ?? 'untranslated')}">${validatedMixedContent(target?.text ?? '', INLINE_12)}</target>`
|
|
623
647
|
: ''
|
|
624
648
|
}${notes}</trans-unit>`;
|
|
625
649
|
})
|
|
626
650
|
.join('');
|
|
627
|
-
return `<file id="f${fileIndex + 1}" original="
|
|
651
|
+
return `<file id="f${fileIndex + 1}" original="linked" datatype="plaintext" source-language="${escapeAttribute(document.sourceLanguage)}"${
|
|
628
652
|
language ? ` target-language="${escapeAttribute(language)}"` : ''
|
|
629
653
|
}><body>${units}</body></file>`;
|
|
630
654
|
})
|
|
631
655
|
.join('');
|
|
632
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_12_NAMESPACE}" xmlns
|
|
656
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_12_NAMESPACE}" xmlns:${PREFIX}="${EXCHANGE_NAMESPACE}" version="1.2"${rootMetadata(document)}>${files}</xliff>\n`;
|
|
633
657
|
}
|
|
634
658
|
|
|
635
659
|
function serialize20(document: TranslationExchangeDocument): string {
|
|
@@ -643,15 +667,15 @@ function serialize20(document: TranslationExchangeDocument): string {
|
|
|
643
667
|
entry.description || target?.note
|
|
644
668
|
? `<notes>${
|
|
645
669
|
entry.description
|
|
646
|
-
? `<note category="
|
|
670
|
+
? `<note category="${NOTE_ROLES.description[0]}">${escapeText(entry.description)}</note>`
|
|
647
671
|
: ''
|
|
648
672
|
}${
|
|
649
673
|
target?.note
|
|
650
|
-
? `<note category="
|
|
674
|
+
? `<note category="${NOTE_ROLES.target[0]}" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
|
|
651
675
|
: ''
|
|
652
676
|
}</notes>`
|
|
653
677
|
: '';
|
|
654
|
-
return `<unit id="${escapeAttribute(id)}" name="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}>${notes}<segment id="s1" state="${state20(target?.state)}"
|
|
678
|
+
return `<unit id="${escapeAttribute(id)}" name="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}>${notes}<segment id="s1" state="${state20(target?.state)}" ${PREFIX}:state="${escapeAttribute(target?.state ?? 'untranslated')}"><source>${validatedMixedContent(entry.sourceText, INLINE_20)}</source>${
|
|
655
679
|
language && target
|
|
656
680
|
? `<target xml:lang="${escapeAttribute(language)}">${validatedMixedContent(target?.text ?? '', INLINE_20)}</target>`
|
|
657
681
|
: ''
|
|
@@ -659,7 +683,7 @@ function serialize20(document: TranslationExchangeDocument): string {
|
|
|
659
683
|
})
|
|
660
684
|
.join('');
|
|
661
685
|
return `<file id="f${fileIndex + 1}"${
|
|
662
|
-
language ? `
|
|
686
|
+
language ? ` ${PREFIX}:target-language="${escapeAttribute(language)}"` : ''
|
|
663
687
|
}>${units}</file>`;
|
|
664
688
|
})
|
|
665
689
|
.join('');
|
|
@@ -667,7 +691,7 @@ function serialize20(document: TranslationExchangeDocument): string {
|
|
|
667
691
|
document.targetLanguages.length === 1
|
|
668
692
|
? ` trgLang="${escapeAttribute(document.targetLanguages[0])}"`
|
|
669
693
|
: '';
|
|
670
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_20_NAMESPACE}" xmlns
|
|
694
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_20_NAMESPACE}" xmlns:${PREFIX}="${EXCHANGE_NAMESPACE}" version="2.0" srcLang="${escapeAttribute(document.sourceLanguage)}"${singleTarget}${rootMetadata(document)}>${files}</xliff>\n`;
|
|
671
695
|
}
|
|
672
696
|
|
|
673
697
|
function sniffVersion(
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Isomorphic barrel (Plan 014 AD-G): side-effect imports register the ontology
|
|
2
2
|
// + shapes into the LINKED tree. Safe on client and server — no backend/Node
|
|
3
3
|
// code here (that lives in ./backend.ts). Ontology FIRST, then shapes.
|
|
4
|
-
import './ontologies/translation.js';
|
|
4
|
+
import './ontologies/translation.register.js';
|
|
5
5
|
import './shapes/TranslationKey.js';
|
|
6
6
|
import './shapes/TranslationKeyVersion.js';
|
|
7
7
|
import './shapes/TranslationRelease.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers this ontology.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of `translation.ts` because registration needs that module's whole export
|
|
5
|
+
* namespace, and a module cannot import itself once a bundler is involved: Rollup
|
|
6
|
+
* treats a static self-reference as a circular import and elides it, so the binding is
|
|
7
|
+
* undefined at runtime and the consuming app dies at boot with `_this is not
|
|
8
|
+
* defined`. `tsc` preserves it, which is why the pattern survived for as long as
|
|
9
|
+
* packages were built with `tsc` alone.
|
|
10
|
+
*
|
|
11
|
+
* From a sibling module the same import is ordinary and survives.
|
|
12
|
+
*/
|
|
13
|
+
import * as terms from './translation.js';
|
|
14
|
+
import {loadData, ns} from './translation.js';
|
|
15
|
+
import {linkedOntology} from '../package.js';
|
|
16
|
+
|
|
17
|
+
linkedOntology(terms, ns, 'translation', loadData, '../data/translation.json');
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { createNameSpace } from '@_linked/core/utils/NameSpace';
|
|
2
|
-
import { linkedOntology } from '../package.js';
|
|
3
|
-
import * as _this from './translation.js';
|
|
4
2
|
|
|
5
3
|
/** Lazy-load the (currently empty) serialized ontology graph. */
|
|
6
4
|
export var loadData = () => {
|
|
@@ -231,4 +229,3 @@ export const tr = {
|
|
|
231
229
|
inventoryObservedAt,
|
|
232
230
|
};
|
|
233
231
|
|
|
234
|
-
linkedOntology(_this, ns, 'translation', loadData, '../data/translation.json');
|
|
@@ -1426,7 +1426,7 @@ export class TranslationProvider extends ShapeProvider {
|
|
|
1426
1426
|
|
|
1427
1427
|
/**
|
|
1428
1428
|
* Authoring gate. Reads `linkedAuth` off the request the LINKED server already
|
|
1429
|
-
* populates — framework-level, so this stays portable (no
|
|
1429
|
+
* populates — framework-level, so this stays portable (no Create Now
|
|
1430
1430
|
* import, AD-G). Anonymous callers can no longer read or write an app's
|
|
1431
1431
|
* translation content via `/call`. Fine-grained "is translation enabled for
|
|
1432
1432
|
* this app + is the caller a project member" gating is a Create Now concern and
|