diff --git a/scripts/translation/utils.spec.ts b/scripts/translation/utils.spec.ts index aa702b84a2..a1825a065e 100644 --- a/scripts/translation/utils.spec.ts +++ b/scripts/translation/utils.spec.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { CACHE_MAX_AGE_MS, isCacheFresh } from "./utils"; +import { CACHE_MAX_AGE_MS, fetchAllPages, isCacheFresh } from "./utils"; describe("isCacheFresh", () => { const now = new Date("2026-08-20T12:00:00Z").getTime(); @@ -21,3 +21,43 @@ describe("isCacheFresh", () => { expect(isCacheFresh(now + CACHE_MAX_AGE_MS, now)).toBe(false); }); }); + +describe("fetchAllPages", () => { + afterEach(() => vi.unstubAllGlobals()); + + /** Answers each URL with a canned page, and records the order they were requested in. */ + function stubPages(pages: Record) { + const requested: string[] = []; + vi.stubGlobal("fetch", async (url: string) => { + requested.push(url); + const page = pages[url]; + if (!page) return { ok: false, status: 404, statusText: "Not Found" }; + return { ok: true, json: async () => page }; + }); + return requested; + } + + it("follows every page and merges the results in order", async () => { + const requested = stubPages({ + "/first": { count: 3, next: "/second", results: [ "a", "b" ] }, + "/second": { count: 3, next: null, results: [ "c" ] } + }); + + const merged = await fetchAllPages("/first"); + expect(merged).toStrictEqual({ count: 3, results: [ "a", "b", "c" ] }); + expect(requested).toStrictEqual([ "/first", "/second" ]); + }); + + it("counts what it read rather than trusting the reported total", async () => { + stubPages({ "/only": { count: 99, next: null, results: [ "a" ] } }); + + expect(await fetchAllPages("/only")).toStrictEqual({ count: 1, results: [ "a" ] }); + }); + + it("names the failing URL instead of failing to parse the body", async () => { + stubPages({ "/first": { count: 2, next: "/gone", results: [ "a" ] } }); + + await expect(fetchAllPages("/first")) + .rejects.toThrow("Weblate answered 404 Not Found for /gone."); + }); +}); diff --git a/scripts/translation/utils.ts b/scripts/translation/utils.ts index 44e7bc81e6..fb19f84fc1 100644 --- a/scripts/translation/utils.ts +++ b/scripts/translation/utils.ts @@ -27,8 +27,8 @@ export async function getLanguageStats(project: WeblateProject) { // Make the request console.log("Reading language stats from Weblate API."); - const request = await fetch(`https://hosted.weblate.org/api/components/trilium/${project}/translations/`); - const stats = JSON.parse(await request.text()); + const stats = await fetchAllPages( + `https://hosted.weblate.org/api/components/trilium/${project}/translations/`); // Update the cache await writeFile(cacheFile, JSON.stringify(stats, null, 4)); @@ -36,6 +36,32 @@ export async function getLanguageStats(project: WeblateProject) { return stats; } +/** + * Reads every page of a paginated Weblate endpoint into a single `results` array. + * + * The API serves 50 entries per page. Each component is already at 40 languages, so a + * caller reading only the first response would start losing languages once translators + * pick up ten more, and the coverage gate would pass by never seeing them. + */ +export async function fetchAllPages(firstPageUrl: string) { + const results: unknown[] = []; + let url: string | null = firstPageUrl; + + while (url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Weblate answered ${response.status} ${response.statusText} for ${url}.`); + } + + const page = await response.json(); + results.push(...page.results); + url = page.next; + } + + return { count: results.length, results }; +} + /** * Determines whether a cache file written at `mtimeMs` can still be used at `nowMs`. *