diff --git a/CHANGELOG.md b/CHANGELOG.md index e0821c5..02dc14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/). While at 0.x, minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI. +## [Unreleased] + +### Added + +- `pushTranslations`, which carries every held catalogue of a supported language + to the platform with its fuzzy flags, trimmed to the template so a stale file + revives nothing, and holding back every answer the platform has settled so a + push never overwrites a reviewer. +- `addLanguage` on the platform seam, and the push adds and fills a supported + language the platform does not list yet. +- Upload pacing, one paced retry when the platform answers its rate refusal. +- A fuzzy aware merge, where a reviewed answer settles a fuzzy one and a fuzzy + export never replaces a settled answer, restored answers keeping their flag. +- `unreviewed`, which names the answers still carrying the fuzzy flag. + +### Changed + +- Clearing a fuzzy flag alone now counts as a meaningful change, so a pull + writes an approval home even when the text did not move. + ## [0.2.0] - 2026-08-24 ### Added diff --git a/src/build.ts b/src/build.ts index 0cc296e..adf69c0 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 export { compileCatalog, serializeCatalog } from './compile.js' -export { mismatched, orphaned, untranslated } from './gates.js' +export { mismatched, orphaned, unreviewed, untranslated } from './gates.js' export { pinnedVersions, resolvedVersions } from './lockfile.js' export { goMessages, goString, messages, pot } from './pot.js' export type { Found, PotOptions } from './pot.js' diff --git a/src/gates.ts b/src/gates.ts index e60d309..fea7493 100644 --- a/src/gates.ts +++ b/src/gates.ts @@ -3,6 +3,7 @@ import { po } from 'gettext-parser' import { METADATA, held, keyOf } from './catalog.js' +import { fuzzyOf } from './merge.js' /** * Returns every key a catalogue carries a filled translation for. @@ -59,6 +60,23 @@ export function orphaned(source: string, template: string): string[] { return carried } +/** + * Returns every message whose answer still carries the fuzzy flag. + * @param source - The catalogue as PO text. + * @returns The keys answered but not yet reviewed. + */ +export function unreviewed(source: string): string[] { + const waiting: string[] = [] + for (const [context, entries] of Object.entries(po.parse(source).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + if (msgid !== METADATA && fuzzyOf(entry) && entry.msgstr.some((form) => form !== '')) { + waiting.push(keyOf(context, msgid)) + } + } + } + return waiting +} + /** NAMED is a placeholder naming what goes into it. */ const NAMED = /%\(([A-Za-z_][A-Za-z0-9_]*)\)[bcdieEfgGosuxX]/g diff --git a/src/merge.ts b/src/merge.ts index 381a566..6354e6c 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -18,6 +18,19 @@ export function localeOf(code: string): string { return region === undefined ? language : `${language}-${region.toUpperCase()}` } +/** + * Returns the code the platform names a language by, bare when its region repeats it. + * @param locale - The language as a catalogue file names it. + * @returns The code as the platform writes it. + */ +export function platformCodeOf(locale: string): string { + const [language, region] = locale.split('-') + if (region === undefined || region.toLowerCase() === language) { + return language + } + return `${language}-${region.toLowerCase()}` +} + /** * Returns the language the site knows a platform code as, if it knows one. * @param code - The language as the platform names it. @@ -117,22 +130,62 @@ export function namedByTemplate(incoming: string, template: string): string { } /** - * Returns the forms a catalogue keeps, taking each answered form over an empty one. - * @param ours - The forms as committed. - * @param theirs - The forms the platform exported. - * @returns The forms, answered wherever either side answers them. + * Reports whether an entry carries the fuzzy flag. + * @param entry - The entry as parsed. + * @returns Whether the entry is fuzzy. */ -function mergedForms(ours: string[], theirs: string[]): string[] { - const held: string[] = [] - for (let at = 0; at < Math.max(ours.length, theirs.length); at += 1) { - const arrived = theirs[at] ?? '' - held.push(arrived === '' ? (ours[at] ?? '') : arrived) +export function fuzzyOf(entry: GetTextTranslation): boolean { + return /(^|,)\s*fuzzy\s*(,|$)/.test(entry.comments?.flag ?? '') +} + +/** + * Writes whether an entry is fuzzy, leaving its other flags standing. + * @param entry - The entry to mark. + * @param fuzzy - Whether the entry is fuzzy. + */ +function flaggedFuzzy(entry: GetTextTranslation, fuzzy: boolean): void { + const others = (entry.comments?.flag ?? '') + .split(',') + .map((flag) => flag.trim()) + .filter((flag) => flag !== '' && flag !== 'fuzzy') + const flags = fuzzy ? [...others, 'fuzzy'] : others + if (flags.length > 0) { + entry.comments = { ...entry.comments, flag: flags.join(', ') } + return + } + if (entry.comments !== undefined) { + delete entry.comments.flag } - return held } /** - * Writes one committed answer into a catalogue that arrived without every form of it. + * Returns the forms a merge keeps and which sides answered them. + * @param preferred - The forms that win wherever they are answered. + * @param fallback - The forms filling what the preferred side holds empty. + * @returns The forms and whether each side supplied any of them. + */ +function mergedForms( + preferred: string[], + fallback: string[], +): { forms: string[], tookPreferred: boolean, tookFallback: boolean } { + const forms: string[] = [] + let tookPreferred = false + let tookFallback = false + for (let at = 0; at < Math.max(preferred.length, fallback.length); at += 1) { + const wanted = preferred[at] ?? '' + if (wanted !== '') { + forms.push(wanted) + tookPreferred = true + continue + } + forms.push(fallback[at] ?? '') + tookFallback = tookFallback || (fallback[at] ?? '') !== '' + } + return { forms, tookPreferred, tookFallback } +} + +/** + * Writes one committed answer into a catalogue that arrived without every settled form of it. * @param held - The catalogue the platform exported, as parsed. * @param context - The context the answer sits under. * @param msgid - The message the answer belongs to. @@ -151,7 +204,33 @@ function restoring( return } arrived.msgid_plural ??= entry.msgid_plural - arrived.msgstr = mergedForms(entry.msgstr, arrived.msgstr) + const settled = settledForms(entry, arrived) + arrived.msgstr = settled.forms + flaggedFuzzy(arrived, settled.fuzzy) +} + +/** + * Returns the merged forms of one answered message and whether they stay fuzzy. + * @param entry - The committed answer. + * @param arrived - The answer the platform exported. + * @returns The forms to keep and the fuzzy state they carry. + */ +function settledForms( + entry: GetTextTranslation, + arrived: GetTextTranslation, +): { forms: string[], fuzzy: boolean } { + const ourFuzzy = fuzzyOf(entry) + const theirFuzzy = fuzzyOf(arrived) + const oursFirst = theirFuzzy && !ourFuzzy + const preferred = oursFirst ? entry.msgstr : arrived.msgstr + const fallback = oursFirst ? arrived.msgstr : entry.msgstr + const merged = mergedForms(preferred, fallback) + const preferredFuzzy = oursFirst ? ourFuzzy : theirFuzzy + const fallbackFuzzy = oursFirst ? theirFuzzy : ourFuzzy + return { + forms: merged.forms, + fuzzy: (merged.tookPreferred && preferredFuzzy) || (merged.tookFallback && fallbackFuzzy), + } } /** @@ -176,13 +255,38 @@ export function keepingAnswers(current: string, incoming: string, template: stri return po.compile(held, COMPILED).toString() } +/** + * Returns a catalogue without the messages an export already answers and nobody may overwrite. + * @param source - The catalogue as committed. + * @param exported - The catalogue the platform exported. + * @returns The catalogue, holding only what the platform has not settled. + */ +export function withoutSettled(source: string, exported: string): string { + const theirs = po.parse(exported).translations + const held = po.parse(source) + for (const [context, entries] of Object.entries(held.translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + const arrived = ownEntry(theirs[context], msgid) + if (msgid === METADATA || arrived === undefined || fuzzyOf(arrived)) { + continue + } + if (arrived.msgstr.length > 0 && arrived.msgstr.every((form) => form !== '')) { + delete held.translations[context][msgid] + continue + } + entry.msgstr = entry.msgstr.map((form, at) => arrived.msgstr[at] || form) + } + } + return po.compile(held, COMPILED).toString() +} + /** Answers is what one language's export carries, keyed by context and message. */ -type Answers = Record> +type Answers = Record> /** * Returns the translations a catalogue holds, without the headers an export restamps. * @param source - The catalogue as PO text. - * @returns The translations, keyed by context and message. + * @returns The translations with their fuzzy state, keyed by context and message. */ function answersOf(source: string): Answers { const held: Answers = {} @@ -190,7 +294,7 @@ function answersOf(source: string): Answers { held[context] = {} for (const [msgid, entry] of Object.entries(entries).sort()) { if (msgid !== METADATA) { - held[context][msgid] = entry.msgstr + held[context][msgid] = { msgstr: entry.msgstr, fuzzy: fuzzyOf(entry) } } } } diff --git a/src/platform.ts b/src/platform.ts index 30e3d86..3b1c170 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -10,15 +10,20 @@ const API = 'https://api.poeditor.com/v2' /** Answer is what the platform answers a request with. */ interface Answer { - response: { status: string, message?: string } + response: { status: string, code?: string, message?: string } result?: { languages?: { code: string }[], url?: string, terms?: { deleted?: number } } } +/** RATE_LIMITED is the refusal code the platform answers hurried uploads with. */ +const RATE_LIMITED = '4048' + /** Poeditor is what a repository reads a translation platform through. */ export interface Poeditor { languages: () => Promise exportPo: (locale: string) => Promise uploadTerms: (source: string) => Promise + uploadTranslations: (locale: string, source: string) => Promise + addLanguage: (locale: string) => Promise } /** Retiring is what a repository retires a platform's absent terms through. */ @@ -36,18 +41,30 @@ export interface PlatformOptions { domain: string /** fetched is how a request is sent, the runtime's own by default. */ fetched?: typeof fetch + /** paced is how many milliseconds separate uploads and precede a rate retry. */ + paced?: number + /** paused is how a wait is spent, the runtime's own timer by default. */ + paused?: (ms: number) => Promise } /** - * Returns the result a platform answer carries, refusing anything that is not a success. + * Returns the platform's answer, refusing a response that never arrived whole. * @param response - The answer as it arrived. - * @returns The result the platform answered with. + * @returns The answer, parsed. */ -async function resultOf(response: Response): Promise> { +async function answerOf(response: Response): Promise { if (!response.ok) { throw new Error(`the translation platform answered ${response.status}`) } - const answered = (await response.json()) as Answer + return (await response.json()) as Answer +} + +/** + * Returns the result an answer carries, refusing anything that is not a success. + * @param answered - The answer, parsed. + * @returns The result the platform answered with. + */ +function resultChecked(answered: Answer): NonNullable { if (answered.response.status !== 'success') { throw new Error( `the translation platform refused: ${answered.response.message ?? 'no reason given'}`, @@ -56,6 +73,15 @@ async function resultOf(response: Response): Promise> { + return resultChecked(await answerOf(response)) +} + /** * Returns the reader and retirer of one translation platform project. * @param options - The credential, the project and the domain to reach it under. @@ -63,6 +89,10 @@ async function resultOf(response: Response): Promise new Promise((resolve) => setTimeout(resolve, ms))) + let uploaded = false /** * Returns the values every call carries. * @returns The credential and the project. @@ -86,6 +116,43 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { signal: AbortSignal.timeout(REQUEST_TIMEOUT), })) } + /** + * Returns an upload form carrying the credentials and the named file. + * @param updating - What the upload changes on the platform. + * @param filename - The name the file travels under. + * @param source - The file's text. + * @returns The form, ready for the extras one upload adds. + */ + function formFor(updating: string, filename: string, source: string): FormData { + const form = new FormData() + form.set('api_token', options.token) + form.set('id', options.project) + form.set('updating', updating) + form.set('file', new Blob([source]), filename) + return form + } + /** + * Sends one upload form, pacing it behind the last and retrying one rate refusal. + * @param form - The upload to send. + * @returns The result the platform answered with. + */ + async function uploadForm(form: FormData): Promise> { + if (uploaded) { + await paused(paced) + } + uploaded = true + const send = () => fetched(`${API}/projects/upload`, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }) + const first = await answerOf(await send()) + if (first.response.code !== RATE_LIMITED) { + return resultChecked(first) + } + await paused(paced) + return resultOf(await send()) + } /** * Sends the template to the platform, saying whether absent terms retire. * @param source - The catalogue template as POT text. @@ -96,19 +163,11 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { source: string, retiring: boolean, ): Promise> { - const form = new FormData() - form.set('api_token', options.token) - form.set('id', options.project) - form.set('updating', 'terms') + const form = formFor('terms', `${options.domain}.pot`, source) if (retiring) { form.set('sync_terms', '1') } - form.set('file', new Blob([source]), `${options.domain}.pot`) - return resultOf(await fetched(`${API}/projects/upload`, { - method: 'POST', - body: form, - signal: AbortSignal.timeout(REQUEST_TIMEOUT), - })) + return uploadForm(form) } return { /** @@ -126,6 +185,26 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { uploadTerms: async (source: string) => { await sendTemplate(source, false) }, + /** + * Tells the platform a language exists, so a catalogue can follow. + * @param locale - The language to add. + */ + addLanguage: async (locale: string) => { + const form = credentials() + form.set('language', locale.toLowerCase()) + await ask('languages/add', form) + }, + /** + * Sends one language's terms and translations together, fuzzy flags preserved. + * @param locale - The language, named as the platform names it. + * @param source - The catalogue as PO text. + */ + uploadTranslations: async (locale: string, source: string) => { + const form = formFor('terms_translations', `${options.domain}.po`, source) + form.set('overwrite', '1') + form.set('language', locale.toLowerCase()) + await uploadForm(form) + }, /** * Deletes from the platform every term the template does not name. * @param source - The catalogue template as POT text. diff --git a/src/sync.ts b/src/sync.ts index ae75a22..47cbe52 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -6,8 +6,10 @@ import { localeFor, meaningfulChange, namedByTemplate, + platformCodeOf, translated, withPluralRuleOf, + withoutSettled, } from './merge.js' import type { Poeditor } from './platform.js' @@ -38,6 +40,113 @@ export interface Synced { kept: string[] } +/** Pushed is what one push did, in words fit for a log. */ +export interface Pushed { + pushed: string[] + skipped: string[] + added: string[] +} + +/** Matched is one language the platform lists that the site answers in. */ +interface Matched { + named: string + locale: string +} + +/** + * Returns the platform's languages the site answers in, and the words for those it does not. + * @param platform - The translation platform to read. + * @param supported - The languages the site answers in. + * @returns The matched languages and the skip lines for the rest. + */ +async function matchedLanguages( + platform: Poeditor, + supported: string[], +): Promise<{ matched: Matched[], skipped: string[] }> { + const matched: Matched[] = [] + const skipped: string[] = [] + for (const named of await platform.languages()) { + const locale = localeFor(named, supported) + if (locale === undefined) { + skipped.push(`${named}, which the site does not answer in`) + continue + } + matched.push({ named, locale }) + } + return { matched, skipped } +} + +/** + * Carries every supported catalogue the platform does not list yet, adding its language first. + * @param platform - The translation platform to write. + * @param absent - The supported languages the platform does not list, holding a catalogue. + * @param held - Where the catalogues live. + * @param template - The catalogue template naming every message the site shows. + * @param skipped - Where the languages passed over are recorded. + * @returns The languages that were added and pushed. + */ +async function pushingAbsent( + platform: Poeditor, + absent: string[], + held: Catalogues, + template: string, + skipped: string[], +): Promise { + const added: string[] = [] + for (const locale of absent) { + const current = held.read(locale) + if (current === undefined) { + skipped.push(`${locale}, which the repository holds no catalogue for`) + continue + } + const named = platformCodeOf(locale) + await platform.addLanguage(named) + await platform.uploadTranslations(named, namedByTemplate(current, template)) + added.push(locale) + } + return added +} + +/** + * Carries every repository catalogue to the platform for the languages it lists. + * @param platform - The translation platform to write. + * @param supported - The languages the site answers in. + * @param held - Where the catalogues live. + * @param template - The catalogue template naming every message the site shows. + * @returns The languages that were pushed and the ones passed over. + */ +export async function pushTranslations( + platform: Poeditor, + supported: string[], + held: Catalogues, + template: string, +): Promise { + const pushed: string[] = [] + await platform.uploadTerms(template) + const { matched, skipped } = await matchedLanguages(platform, supported) + for (const { named, locale } of matched) { + const current = held.read(locale) + if (current === undefined) { + skipped.push(`${named}, which the repository holds no catalogue for`) + continue + } + const unsettled = withoutSettled( + namedByTemplate(current, template), + await platform.exportPo(named), + ) + if (translated(unsettled) === 0) { + skipped.push(`${named}, which the platform has settled every answer of`) + continue + } + await platform.uploadTranslations(named, unsettled) + pushed.push(locale) + } + const listed = matched.map((held) => held.locale) + const absent = supported.filter((locale) => !listed.includes(locale)) + const added = await pushingAbsent(platform, absent, held, template, skipped) + return { pushed: [...pushed, ...added], skipped, added } +} + /** * Returns the catalogue a sync writes and how many committed forms it restored. * @param current - The catalogue as committed, or nothing when none is committed yet. @@ -73,15 +182,10 @@ export async function syncTranslations( template: string, ): Promise { const moved: string[] = [] - const skipped: string[] = [] const kept: string[] = [] await platform.uploadTerms(template) - for (const named of await platform.languages()) { - const locale = localeFor(named, supported) - if (locale === undefined) { - skipped.push(`${named}, which the site does not answer in`) - continue - } + const { matched, skipped } = await matchedLanguages(platform, supported) + for (const { named, locale } of matched) { const exported = namedByTemplate(await platform.exportPo(named), template) if (translated(exported) === 0) { skipped.push(`${named}, which nobody has translated yet`) diff --git a/test/build.test.ts b/test/build.test.ts index 01b36b4..69cf33e 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -13,6 +13,7 @@ import { orphaned, pot, serializeCatalog, + unreviewed, untranslated, } from '../src/build.js' @@ -354,3 +355,49 @@ test('passes over the metadata entry rather than reading it as a message', () => expect(mismatched(CATALOGUE, naming)).toEqual([]) }) + +/** REVIEWING is a catalogue holding one settled answer and two fuzzy ones. */ +const REVIEWING = `msgid "" +msgstr "" + +msgid "Older posts" +msgstr "Entradas anteriores" + +#, fuzzy +msgid "Newer posts" +msgstr "Entradas nuevas" + +#, fuzzy +msgctxt "status" +msgid "Draft" +msgstr "Borrador" +` + +test('keeps a fuzzy answer in the compiled catalogue', () => { + const held = compileCatalog(REVIEWING) + + expect(held['Newer posts']).toEqual(['Entradas nuevas']) +}) + +test('counts a fuzzy answer as answered rather than waiting', () => { + const naming = 'msgid "Newer posts"\nmsgstr ""\n' + + expect(untranslated(REVIEWING, naming)).toEqual([]) +}) + +test('names every answer still waiting for review', () => { + expect(unreviewed(REVIEWING)).toEqual(['Newer posts', 'statusDraft']) +}) + +test('does not read an empty fuzzy entry as waiting for review', () => { + const empty = 'msgid "Older posts"\nmsgstr ""\n\n#, fuzzy\nmsgid "Newer posts"\nmsgstr ""\n' + + expect(unreviewed(empty)).toEqual([]) +}) + +test('sees a fuzzy answer through the mismatched gate', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const fuzzyBroken = '#, fuzzy\nmsgid "%(count)d post"\nmsgstr "%(total)d entrada"\n' + + expect(mismatched(fuzzyBroken, naming)).toEqual(['%(count)d post']) +}) diff --git a/test/sync.test.ts b/test/sync.test.ts index ef801e9..e673873 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -10,6 +10,7 @@ import { meaningfulChange, namedByTemplate, poeditorAt, + pushTranslations, syncTranslations, translated, withPluralRuleOf, @@ -44,6 +45,42 @@ function platformOf(languages: string[], exports: Record): Poedi languages: async () => languages, exportPo: async (named: string) => exports[named] ?? '', uploadTerms: async () => {}, + uploadTranslations: async () => {}, + addLanguage: async () => {}, + } +} + +/** + * Returns a platform recording every catalogue upload and language it receives. + * @param languages - The languages the platform lists. + * @returns The platform, the uploads as name and source pairs, and what else arrived. + */ +function receivingPlatform(languages: string[]): { + platform: Poeditor + uploads: [string, string][] + termsSent: string[] + languagesAdded: string[] +} { + const uploads: [string, string][] = [] + const termsSent: string[] = [] + const languagesAdded: string[] = [] + return { + platform: { + languages: async () => languages, + exportPo: async () => '', + uploadTerms: async (source: string) => { + termsSent.push(source) + }, + uploadTranslations: async (named: string, source: string) => { + uploads.push([named, source]) + }, + addLanguage: async (named: string) => { + languagesAdded.push(named) + }, + }, + uploads, + termsSent, + languagesAdded, } } @@ -367,6 +404,8 @@ test('sends the template before asking what the platform holds', async () => { uploadTerms: async () => { order.push('upload') }, + uploadTranslations: async () => {}, + addLanguage: async () => {}, } const { held } = storeOf() @@ -530,3 +569,374 @@ test('refuses an export that could not be downloaded', async () => { await expect(poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).exportPo('es-ES')) .rejects.toThrow(/404/) }) + +/** + * Returns a catalogue carrying one fuzzy translation. + * @param msgstr - The machine answer the catalogue holds. + * @returns The catalogue as PO text. + */ +function fuzzyCatalogue(msgstr: string): string { + return `${HEADER}\n#, fuzzy\nmsgid "Older posts"\nmsgstr "${msgstr}"\n` +} + +test('a reviewed answer replaces the fuzzy one it settles', () => { + const held = keepingAnswers(fuzzyCatalogue('Entradas anteriores'), catalogue('Entradas previas'), TEMPLATE) + + expect(held).toContain('msgstr "Entradas previas"') + expect(held).not.toContain('#, fuzzy') +}) + +test('a fuzzy export never replaces a settled answer', () => { + const held = keepingAnswers(catalogue('Entradas'), fuzzyCatalogue('Entradas raras'), TEMPLATE) + + expect(held).toContain('msgstr "Entradas"') + expect(held).not.toContain('Entradas raras') + expect(held).not.toContain('#, fuzzy') +}) + +test('a restored answer keeps its fuzzy flag', () => { + const held = keepingAnswers(fuzzyCatalogue('Entradas anteriores'), catalogue(''), TEMPLATE) + + expect(held).toContain('msgstr "Entradas anteriores"') + expect(held).toContain('#, fuzzy') +}) + +test('two fuzzy answers take the platform text and stay fuzzy', () => { + const held = keepingAnswers( + fuzzyCatalogue('Entradas anteriores'), + fuzzyCatalogue('Entradas previas'), + TEMPLATE, + ) + + expect(held).toContain('msgstr "Entradas previas"') + expect(held).toContain('#, fuzzy') +}) + +test('clearing a fuzzy flag alone is a meaningful change', () => { + expect(meaningfulChange(fuzzyCatalogue('Entradas'), catalogue('Entradas'))).toBe(true) +}) + +test('pushes each held catalogue under the platform its language is named by', async () => { + const fuzzy = `${HEADER}\n#, fuzzy\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n` + const { platform, uploads, termsSent } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': fuzzy }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(termsSent).toEqual([TEMPLATE]) + expect(uploads).toHaveLength(1) + expect(uploads[0][0]).toBe('es') + expect(uploads[0][1]).toContain('msgstr "Entradas anteriores"') + expect(uploads[0][1]).toContain('#, fuzzy') + expect(done.pushed).toEqual(['es-ES']) +}) + +test('pushes only the messages the template names', async () => { + const stale = `${HEADER}\nmsgid "Older posts"\nmsgstr "Entradas"\n\nmsgid "Retired"\nmsgstr "Retirada"\n` + const { platform, uploads } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': stale }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('msgstr "Entradas"') + expect(uploads[0][1]).not.toContain('Retired') +}) + +test('passes over a pushed language the site does not answer in', async () => { + const { platform, uploads } = receivingPlatform(['de']) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.pushed).toEqual([]) + expect(done.skipped).toEqual([ + 'de, which the site does not answer in', + 'es-ES, which the repository holds no catalogue for', + ]) +}) + +test('passes over a pushed language the repository holds no catalogue for', async () => { + const { platform, uploads } = receivingPlatform(['es']) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the repository holds no catalogue for']) +}) + +/** + * Returns a platform recording uploads and exporting the given catalogue per language. + * @param languages - The languages the platform lists. + * @param exports - The catalogue each language exports, keyed by platform name. + * @returns The platform and the uploads it received. + */ +function exportingPlatform(languages: string[], exports: Record): { + platform: Poeditor + uploads: [string, string][] +} { + const uploads: [string, string][] = [] + return { + platform: { + languages: async () => languages, + exportPo: async (named: string) => exports[named] ?? '', + uploadTerms: async () => {}, + uploadTranslations: async (named: string, source: string) => { + uploads.push([named, source]) + }, + addLanguage: async () => {}, + }, + uploads, + } +} + +test('never pushes over an answer the platform has settled', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('Entradas revisadas') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas de la maquina') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.pushed).toEqual([]) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + +test('pushes over an answer the platform still holds fuzzy', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: fuzzyCatalogue('Entradas viejas') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas nuevas') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('Entradas nuevas') +}) + +test('pushes a settled answer of its own over a fuzzy one the platform holds', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: fuzzyCatalogue('Entradas raras') }) + const { held } = storeOf({ 'es-ES': catalogue('Entradas corregidas') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('Entradas corregidas') + expect(uploads[0][1]).not.toContain('#, fuzzy') +}) + +test('pushes a message the platform holds no answer for', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas anteriores') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('Entradas anteriores') +}) + +/** PLURAL_TEMPLATE names one message carrying two forms. */ +const PLURAL_TEMPLATE = `msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "" +msgstr[1] "" +` + +/** + * Returns a catalogue answering the plural message with the given forms. + * @param one - The singular form. + * @param many - The plural form. + * @param fuzzy - Whether the entry needs review. + * @returns The catalogue as PO text. + */ +function pluralCatalogue(one: string, many: string, fuzzy = false): string { + return `${HEADER} +${fuzzy ? '#, fuzzy\n' : ''}msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "${one}" +msgstr[1] "${many}" +` +} + +test('pushes the form the platform left empty and keeps the one it answered', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: pluralCatalogue('%(count)d entrada revisada', ''), + }) + const { held } = storeOf({ + 'es-ES': pluralCatalogue('%(count)d entrada maquina', '%(count)d entradas maquina', true), + }) + + await pushTranslations(platform, ['es-ES'], held, PLURAL_TEMPLATE) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('%(count)d entrada revisada') + expect(uploads[0][1]).toContain('%(count)d entradas maquina') + expect(uploads[0][1]).not.toContain('entrada maquina') +}) + +test('never pushes over a plural the platform answered in every form', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: pluralCatalogue('%(count)d entrada', '%(count)d entradas'), + }) + const { held } = storeOf({ + 'es-ES': pluralCatalogue('%(count)d vieja', '%(count)d viejas', true), + }) + + const done = await pushTranslations(platform, ['es-ES'], held, PLURAL_TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + +test('says which supported language it holds no catalogue to add', async () => { + const { platform, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['fr-FR'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(done.skipped).toEqual(['fr-FR, which the repository holds no catalogue for']) +}) + +test('adds a language the platform lacks and pushes its full catalogue', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas anteriores') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(languagesAdded).toEqual(['es']) + expect(uploads).toHaveLength(1) + expect(uploads[0][0]).toBe('es') + expect(uploads[0][1]).toContain('#, fuzzy') + expect(done.added).toEqual(['es-ES']) + expect(done.pushed).toEqual(['es-ES']) +}) + +test('adds a dialect under its full code', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({ 'pt-BR': `${HEADER}\nmsgid "Older posts"\nmsgstr "Posts antigos"\n` }) + + await pushTranslations(platform, ['pt-BR'], held, TEMPLATE) + + expect(languagesAdded).toEqual(['pt-br']) + expect(uploads[0][0]).toBe('pt-br') +}) + +test('adds nothing for a language the platform already lists', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': catalogue('Entradas') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(uploads).toHaveLength(1) + expect(done.added).toEqual([]) +}) + +test('adds nothing for a supported language the repository holds no catalogue for', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['fr-FR'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(uploads).toHaveLength(0) + expect(done.added).toEqual([]) + expect(done.pushed).toEqual([]) +}) + +test('tells the platform a new language exists', async () => { + const sent: URLSearchParams[] = [] + const fetched = vi.fn(async (url: string, init?: RequestInit) => { + sent.push(init?.body as URLSearchParams) + expect(url).toContain('languages/add') + return new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + + await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).addLanguage('pt-BR') + + expect(sent[0].get('language')).toBe('pt-br') + expect(sent[0].get('api_token')).toBe('t') +}) + +test('uploads a language catalogue with its translations and terms together', async () => { + const sent: FormData[] = [] + const fetched = vi.fn(async (_url: string, init?: RequestInit) => { + sent.push(init?.body as FormData) + return new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + + await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }) + .uploadTranslations('fr-CA', catalogue('Anciens billets')) + + expect(sent[0].get('updating')).toBe('terms_translations') + expect(sent[0].get('overwrite')).toBe('1') + expect(sent[0].get('language')).toBe('fr-ca') + expect((sent[0].get('file') as File).name).toBe('probe.po') + expect(await (sent[0].get('file') as File).text()).toContain('Anciens billets') +}) + +test('retries one upload the platform rate limited', async () => { + const waits: number[] = [] + let asked = 0 + const fetched = vi.fn(async () => { + asked += 1 + return asked === 1 + ? new Response(JSON.stringify({ response: { status: 'fail', code: '4048', message: 'slow down' } })) + : new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 123, + paused: async (ms: number) => { + waits.push(ms) + }, + }) + + await platform.uploadTranslations('es', catalogue('Entradas')) + + expect(asked).toBe(2) + expect(waits).toEqual([123]) +}) + +test('surfaces a rate refusal that outlives the retry', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'fail', code: '4048', message: 'slow down' } })), + ) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 1, + paused: async () => {}, + }) + + await expect(platform.uploadTranslations('es', catalogue('Entradas'))).rejects.toThrow(/slow down/) + expect(fetched).toHaveBeenCalledTimes(2) +}) + +test('spaces consecutive uploads apart', async () => { + const waits: number[] = [] + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result: {} })), + ) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 456, + paused: async (ms: number) => { + waits.push(ms) + }, + }) + + await platform.uploadTerms(TEMPLATE) + await platform.uploadTranslations('es', catalogue('Entradas')) + await platform.uploadTranslations('fr', catalogue('Anciens')) + + expect(waits).toEqual([456, 456]) +}) + +test('waits between uploads through the runtime clock unless handed a pause', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result: {} })), + ) as unknown as typeof fetch + const platform = poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched, paced: 1 }) + + await platform.uploadTranslations('es', catalogue('Entradas')) + await platform.uploadTranslations('fr', catalogue('Anciens')) + + expect(fetched).toHaveBeenCalledTimes(2) +})