Skip to content

Commit 410ab2e

Browse files
fix: auto approve script
1 parent 00a7f94 commit 410ab2e

2 files changed

Lines changed: 72 additions & 144 deletions

File tree

src/sync-crowdin-en-us.js

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
*
66
* The script is fully idempotent – each run will:
77
* • Fetch all source strings for each configured project.
8-
* • Fetch all existing en_US approvals and translations for the project
9-
* upfront (two API calls) and skip strings that are already fully approved,
8+
* • Fetch all already-approved en_US translations for the project upfront
9+
* (one API call) and skip strings that are already fully approved,
1010
* significantly reducing per-string API usage.
1111
* • For strings that need attention: check whether the en_US translation
1212
* already exists and matches the source text (including plurals).
@@ -101,28 +101,17 @@ async function fetchApprovals(projectId, stringId) {
101101
}
102102

103103
/**
104-
* Fetches all existing en_US approvals for an entire project in one call.
104+
* Fetches all already-approved en_US translations for an entire project in
105+
* one call, using the approvedOnly filter so no separate approvals request
106+
* is needed.
105107
*
106108
* @param {string|number} projectId
107-
* @returns {Promise<object[]>} Unwrapped Approval data objects.
108-
*/
109-
async function fetchProjectApprovals(projectId) {
110-
const response = await crowdin.stringTranslationsApi
111-
.withFetchAll()
112-
.listTranslationApprovals(projectId, { languageId: EN_US });
113-
return (response.data ?? []).map((item) => item.data);
114-
}
115-
116-
/**
117-
* Fetches all existing en_US translations for an entire project in one call.
118-
*
119-
* @param {string|number} projectId
120-
* @returns {Promise<object[]>} Unwrapped translation data objects.
109+
* @returns {Promise<object[]>} Unwrapped translation data objects (approved only).
121110
*/
122-
async function fetchProjectTranslations(projectId) {
111+
async function fetchApprovedProjectTranslations(projectId) {
123112
const response = await crowdin.stringTranslationsApi
124113
.withFetchAll()
125-
.listLanguageTranslations(projectId, EN_US);
114+
.listLanguageTranslations(projectId, EN_US, { approvedOnly: 1 });
126115
return (response.data ?? []).map((item) => item.data);
127116
}
128117

@@ -233,21 +222,15 @@ async function ensureTranslation(projectId, stringId, expectedText, pluralCatego
233222
* Determines whether all plural forms (or the single plain form) of a source
234223
* string already have an approved en_US translation.
235224
*
236-
* @param {object} sourceString
237-
* @param {Set<number>} approvedTranslationIds
238-
* @param {Map<number, Array<{translationId: number, pluralCategoryName: string|null}>>} stringTranslationMap
239-
* Maps stringId → array of {translationId, pluralCategoryName} entries for
240-
* that string's en_US translations.
225+
* @param {object} sourceString
226+
* @param {Map<number, Set<string|null>>} approvedCategoriesByStringId
227+
* Maps stringId → Set of pluralCategoryName values (or null for plain strings)
228+
* that already have an approved en_US translation.
241229
* @returns {boolean}
242230
*/
243-
function isFullyApproved(sourceString, approvedTranslationIds, stringTranslationMap) {
231+
function isFullyApproved(sourceString, approvedCategoriesByStringId) {
244232
const expectedEntries = normalisedTextEntries(sourceString.text);
245-
const translations = stringTranslationMap.get(sourceString.id) ?? [];
246-
const approvedCategories = new Set(
247-
translations
248-
.filter((t) => approvedTranslationIds.has(t.translationId))
249-
.map((t) => t.pluralCategoryName),
250-
);
233+
const approvedCategories = approvedCategoriesByStringId.get(sourceString.id) ?? new Set();
251234
return expectedEntries.every(({ pluralCategoryName }) =>
252235
approvedCategories.has(pluralCategoryName),
253236
);
@@ -307,33 +290,24 @@ async function syncStringTranslation(projectId, sourceString) {
307290
async function syncProject(projectId) {
308291
console.log(`\n── Project ${projectId} ──`);
309292

310-
// Fetch source strings and all en_US approvals/translations in parallel.
311-
const [strings, projectApprovals, projectTranslations] = await Promise.all([
293+
// Fetch source strings and all approved en_US translations in parallel.
294+
const [strings, approvedTranslations] = await Promise.all([
312295
fetchSourceStrings(projectId),
313-
fetchProjectApprovals(projectId),
314-
fetchProjectTranslations(projectId),
296+
fetchApprovedProjectTranslations(projectId),
315297
]);
316298
console.log(` ${strings.length} source string(s) found.`);
317299

318-
// Build a set of approved translation IDs.
319-
const approvedTranslationIds = new Set(projectApprovals.map((a) => a.translationId));
320-
321-
// Build a map: stringId → [{translationId, pluralCategoryName}] for all
322-
// en_US translations in the project, so isFullyApproved can check coverage
323-
// per-string without cross-string contamination.
324-
const stringTranslationMap = new Map();
325-
for (const t of projectTranslations) {
300+
// Build a map: stringId → Set<pluralCategoryName|null> of approved categories.
301+
const approvedCategoriesByStringId = new Map();
302+
for (const t of approvedTranslations) {
326303
const sid = t.stringId;
327-
if (!stringTranslationMap.has(sid)) stringTranslationMap.set(sid, []);
328-
stringTranslationMap.get(sid).push({
329-
translationId: t.translationId,
330-
pluralCategoryName: t.pluralCategoryName ?? null,
331-
});
304+
if (!approvedCategoriesByStringId.has(sid)) approvedCategoriesByStringId.set(sid, new Set());
305+
approvedCategoriesByStringId.get(sid).add(t.pluralCategoryName ?? null);
332306
}
333307

334308
let skipped = 0;
335309
for (const string of strings) {
336-
if (isFullyApproved(string, approvedTranslationIds, stringTranslationMap)) {
310+
if (isFullyApproved(string, approvedCategoriesByStringId)) {
337311
skipped++;
338312
continue;
339313
}
@@ -370,8 +344,7 @@ if (_isMain) {
370344
export {
371345
EN_US,
372346
fetchSourceStrings,
373-
fetchProjectApprovals,
374-
fetchProjectTranslations,
347+
fetchApprovedProjectTranslations,
375348
fetchTranslations,
376349
fetchApprovals,
377350
addTranslation,

tests/sync-crowdin-en-us.test.js

Lines changed: 48 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ import { Client as CrowdinClient } from '@crowdin/crowdin-api-client';
4242
import {
4343
EN_US,
4444
fetchSourceStrings,
45-
fetchProjectApprovals,
46-
fetchProjectTranslations,
45+
fetchApprovedProjectTranslations,
4746
fetchTranslations,
4847
fetchApprovals,
4948
addTranslation,
@@ -535,39 +534,9 @@ describe('syncStringTranslation', () => {
535534
});
536535
});
537536

538-
// fetchProjectApprovals
537+
// fetchApprovedProjectTranslations
539538

540-
describe('fetchProjectApprovals', () => {
541-
beforeEach(() => jest.clearAllMocks());
542-
543-
it('returns unwrapped approval data objects', async () => {
544-
const raw = [
545-
{ data: makeApproval({ id: 30, translationId: 20 }) },
546-
{ data: makeApproval({ id: 31, translationId: 21 }) },
547-
];
548-
mockListTranslationApprovals.mockResolvedValue({ data: raw });
549-
550-
const result = await fetchProjectApprovals('42');
551-
expect(result).toHaveLength(2);
552-
expect(result[0].id).toBe(30);
553-
expect(result[1].id).toBe(31);
554-
});
555-
556-
it('returns an empty array when response.data is null/undefined', async () => {
557-
mockListTranslationApprovals.mockResolvedValue({});
558-
expect(await fetchProjectApprovals('42')).toEqual([]);
559-
});
560-
561-
it('calls listTranslationApprovals with projectId and EN_US only (no stringId)', async () => {
562-
mockListTranslationApprovals.mockResolvedValue({ data: [] });
563-
await fetchProjectApprovals('42');
564-
expect(mockListTranslationApprovals).toHaveBeenCalledWith('42', { languageId: EN_US });
565-
});
566-
});
567-
568-
// fetchProjectTranslations
569-
570-
describe('fetchProjectTranslations', () => {
539+
describe('fetchApprovedProjectTranslations', () => {
571540
beforeEach(() => jest.clearAllMocks());
572541

573542
it('returns unwrapped translation data objects', async () => {
@@ -577,22 +546,22 @@ describe('fetchProjectTranslations', () => {
577546
];
578547
mockListLanguageTranslations.mockResolvedValue({ data: raw });
579548

580-
const result = await fetchProjectTranslations('42');
549+
const result = await fetchApprovedProjectTranslations('42');
581550
expect(result).toHaveLength(2);
582551
expect(result[0].translationId).toBe(10);
583552
expect(result[1].translationId).toBe(11);
584553
});
585554

586555
it('returns an empty array when response.data is null/undefined', async () => {
587556
mockListLanguageTranslations.mockResolvedValue({});
588-
expect(await fetchProjectTranslations('42')).toEqual([]);
557+
expect(await fetchApprovedProjectTranslations('42')).toEqual([]);
589558
});
590559

591-
it('calls listLanguageTranslations with projectId and EN_US', async () => {
560+
it('calls listLanguageTranslations with projectId, EN_US, and approvedOnly:1', async () => {
592561
mockListLanguageTranslations.mockResolvedValue({ data: [] });
593-
await fetchProjectTranslations('42');
562+
await fetchApprovedProjectTranslations('42');
594563
expect(mockTranslationsWithFetchAll).toHaveBeenCalled();
595-
expect(mockListLanguageTranslations).toHaveBeenCalledWith('42', EN_US);
564+
expect(mockListLanguageTranslations).toHaveBeenCalledWith('42', EN_US, { approvedOnly: 1 });
596565
});
597566
});
598567

@@ -601,61 +570,45 @@ describe('fetchProjectTranslations', () => {
601570
describe('isFullyApproved', () => {
602571
it('returns true for a plain string with an approved translation', () => {
603572
const string = makeSourceString({ id: 1, text: 'Hello' });
604-
const approvedIds = new Set([100]);
605-
const stringTranslationMap = new Map([[1, [{ translationId: 100, pluralCategoryName: null }]]]);
606-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(true);
573+
const map = new Map([[1, new Set([null])]]);
574+
expect(isFullyApproved(string, map)).toBe(true);
607575
});
608576

609577
it('returns false for a plain string with no entry in the map', () => {
610578
const string = makeSourceString({ id: 1, text: 'Hello' });
611-
const approvedIds = new Set();
612-
const stringTranslationMap = new Map();
613-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(false);
579+
expect(isFullyApproved(string, new Map())).toBe(false);
614580
});
615581

616-
it('returns false for a plain string whose translation is not approved', () => {
582+
it('returns false for a plain string whose category is not in the approved set', () => {
617583
const string = makeSourceString({ id: 1, text: 'Hello' });
618-
const approvedIds = new Set([999]); // different ID
619-
const stringTranslationMap = new Map([[1, [{ translationId: 100, pluralCategoryName: null }]]]);
620-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(false);
584+
// Map has the string but an empty set – no category approved
585+
const map = new Map([[1, new Set()]]);
586+
expect(isFullyApproved(string, map)).toBe(false);
621587
});
622588

623589
it('returns true for a plural string when all forms are approved', () => {
624590
const string = makeSourceString({ id: 2, text: { one: 'One', other: 'Other' } });
625-
const approvedIds = new Set([10, 11]);
626-
const stringTranslationMap = new Map([[2, [
627-
{ translationId: 10, pluralCategoryName: 'one' },
628-
{ translationId: 11, pluralCategoryName: 'other' },
629-
]]]);
630-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(true);
591+
const map = new Map([[2, new Set(['one', 'other'])]]);
592+
expect(isFullyApproved(string, map)).toBe(true);
631593
});
632594

633595
it('returns false for a plural string when only some forms are approved', () => {
634596
const string = makeSourceString({ id: 2, text: { one: 'One', other: 'Other' } });
635-
const approvedIds = new Set([10]); // only 'one' approved
636-
const stringTranslationMap = new Map([[2, [
637-
{ translationId: 10, pluralCategoryName: 'one' },
638-
{ translationId: 11, pluralCategoryName: 'other' },
639-
]]]);
640-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(false);
597+
const map = new Map([[2, new Set(['one'])]]);
598+
expect(isFullyApproved(string, map)).toBe(false);
641599
});
642600

643601
it('returns false for a plural string when no forms are approved', () => {
644602
const string = makeSourceString({ id: 2, text: { one: 'One', other: 'Other' } });
645-
const approvedIds = new Set();
646-
const stringTranslationMap = new Map([[2, [
647-
{ translationId: 10, pluralCategoryName: 'one' },
648-
{ translationId: 11, pluralCategoryName: 'other' },
649-
]]]);
650-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(false);
603+
const map = new Map([[2, new Set()]]);
604+
expect(isFullyApproved(string, map)).toBe(false);
651605
});
652606

653-
it('does not consider translations belonging to other strings', () => {
607+
it('does not consider approvals belonging to other strings', () => {
654608
const string = makeSourceString({ id: 1, text: 'Hello' });
655-
const approvedIds = new Set([200]);
656-
// Only string 2 has translation 200; string 1 has no entry
657-
const stringTranslationMap = new Map([[2, [{ translationId: 200, pluralCategoryName: null }]]]);
658-
expect(isFullyApproved(string, approvedIds, stringTranslationMap)).toBe(false);
609+
// Only string 2 has an approved null category; string 1 has no entry
610+
const map = new Map([[2, new Set([null])]]);
611+
expect(isFullyApproved(string, map)).toBe(false);
659612
});
660613
});
661614

@@ -672,9 +625,9 @@ describe('syncProject', () => {
672625
it('logs the project and string count', async () => {
673626
const strings = [makeSourceString({ id: 1 }), makeSourceString({ id: 2 })];
674627
mockListProjectStrings.mockResolvedValue({ data: strings.map((s) => ({ data: s })) });
675-
mockListTranslationApprovals.mockResolvedValue({ data: [] });
676628
mockListLanguageTranslations.mockResolvedValue({ data: [] });
677629
mockListStringTranslations.mockResolvedValue({ data: [] });
630+
mockListTranslationApprovals.mockResolvedValue({ data: [] });
678631
mockAddTranslation.mockResolvedValue({ data: { id: 50 } });
679632
mockAddApproval.mockResolvedValue({ data: { id: 200 } });
680633

@@ -687,50 +640,45 @@ describe('syncProject', () => {
687640
it('processes strings that are not fully approved', async () => {
688641
const strings = [makeSourceString({ id: 10 }), makeSourceString({ id: 11 })];
689642
mockListProjectStrings.mockResolvedValue({ data: strings.map((s) => ({ data: s })) });
690-
// No project-level approvals → both strings need processing
691-
mockListTranslationApprovals.mockResolvedValue({ data: [] });
643+
// No approved translations upfront → both strings need processing
692644
mockListLanguageTranslations.mockResolvedValue({ data: [] });
693645
mockListStringTranslations.mockResolvedValue({ data: [] });
646+
mockListTranslationApprovals.mockResolvedValue({ data: [] });
694647
mockAddTranslation.mockResolvedValue({ data: { id: 50 } });
695648
mockAddApproval.mockResolvedValue({ data: { id: 200 } });
696649

697650
await syncProject('42');
698651

699-
// One pair of fetchTranslations+fetchApprovals per string that needs processing
652+
// One fetchTranslations call per string that needs processing
700653
expect(mockListStringTranslations).toHaveBeenCalledTimes(2);
701654
});
702655

703656
it('skips strings that are already fully approved', async () => {
704657
const string = makeSourceString({ id: 5, text: 'Hello' });
705658
mockListProjectStrings.mockResolvedValue({ data: [{ data: string }] });
706-
mockListTranslationApprovals.mockResolvedValue({
707-
data: [{ data: makeApproval({ translationId: 77 }) }],
708-
});
659+
// approvedOnly response includes stringId=5, category=null → fully covered
709660
mockListLanguageTranslations.mockResolvedValue({
710661
data: [{ data: { translationId: 77, stringId: 5, pluralCategoryName: null } }],
711662
});
712663

713664
await syncProject('42');
714665

715-
// No per-string API calls should have been made
716666
expect(mockListStringTranslations).not.toHaveBeenCalled();
717667
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('1 string(s) skipped'));
718668
});
719669

720-
it('processes only the strings that are not fully approved, skipping the rest', async () => {
670+
it('processes only unapproved strings, skipping the rest', async () => {
721671
const approvedString = makeSourceString({ id: 5, text: 'Hello' });
722672
const unapprovedString = makeSourceString({ id: 6, text: 'World' });
723673
mockListProjectStrings.mockResolvedValue({
724674
data: [{ data: approvedString }, { data: unapprovedString }],
725675
});
726-
mockListTranslationApprovals.mockResolvedValue({
727-
data: [{ data: makeApproval({ translationId: 77 }) }],
728-
});
676+
// Only string 5 is approved
729677
mockListLanguageTranslations.mockResolvedValue({
730678
data: [{ data: { translationId: 77, stringId: 5, pluralCategoryName: null } }],
731679
});
732-
// Per-string calls for unapproved string
733680
mockListStringTranslations.mockResolvedValue({ data: [] });
681+
mockListTranslationApprovals.mockResolvedValue({ data: [] });
734682
mockAddTranslation.mockResolvedValue({ data: { id: 50 } });
735683
mockAddApproval.mockResolvedValue({ data: { id: 200 } });
736684

@@ -744,12 +692,6 @@ describe('syncProject', () => {
744692
it('skips a plural string only when all its forms are approved', async () => {
745693
const string = makeSourceString({ id: 8, text: { one: 'One', other: 'Other' } });
746694
mockListProjectStrings.mockResolvedValue({ data: [{ data: string }] });
747-
mockListTranslationApprovals.mockResolvedValue({
748-
data: [
749-
{ data: makeApproval({ translationId: 80 }) },
750-
{ data: makeApproval({ translationId: 81 }) },
751-
],
752-
});
753695
// Two entries share the same stringId=8 — exercises the map-accumulation branch
754696
mockListLanguageTranslations.mockResolvedValue({
755697
data: [
@@ -765,8 +707,22 @@ describe('syncProject', () => {
765707
});
766708

767709
it('does not log skipped message when no strings are skipped', async () => {
768-
mockListProjectStrings.mockResolvedValue({ data: [] });
710+
const string = makeSourceString({ id: 7, text: 'Test' });
711+
mockListProjectStrings.mockResolvedValue({ data: [{ data: string }] });
712+
mockListLanguageTranslations.mockResolvedValue({ data: [] });
713+
mockListStringTranslations.mockResolvedValue({ data: [] });
769714
mockListTranslationApprovals.mockResolvedValue({ data: [] });
715+
mockAddTranslation.mockResolvedValue({ data: { id: 50 } });
716+
mockAddApproval.mockResolvedValue({ data: { id: 200 } });
717+
718+
await syncProject('42');
719+
720+
const logs = console.log.mock.calls.map((c) => c[0]);
721+
expect(logs.some((m) => m.includes('skipped'))).toBe(false);
722+
});
723+
724+
it('handles a project with no source strings', async () => {
725+
mockListProjectStrings.mockResolvedValue({ data: [] });
770726
mockListLanguageTranslations.mockResolvedValue({ data: [] });
771727

772728
await syncProject('99');
@@ -802,7 +758,6 @@ describe('main', () => {
802758
});
803759

804760
mockListProjectStrings.mockResolvedValue({ data: [] });
805-
mockListTranslationApprovals.mockResolvedValue({ data: [] });
806761
mockListLanguageTranslations.mockResolvedValue({ data: [] });
807762

808763
await freshMain();

0 commit comments

Comments
 (0)