diff --git a/.env b/.env new file mode 100644 index 0000000..8670a51 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +# Crowdin +CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID" +CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url" +CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token" \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index a459de9..507cdeb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,8 @@ export default [ { rules: { "no-console": "off", + "no-await-in-loop": "off", + "@typescript-eslint/no-unsafe-assignment": "off" } } ] diff --git a/src/crowdin/interfaces/file.ts b/src/crowdin/interfaces/file.ts new file mode 100644 index 0000000..f83b974 --- /dev/null +++ b/src/crowdin/interfaces/file.ts @@ -0,0 +1,47 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export interface File { + data: Array<{ + data: { + id: number; + projectId: number; + branchId: number; + directoryId: number; + name: string; + title: string; + context: string; + type: string; + path: string; + status: string; + revisionId: number; + priority: string; + importOptions: { + importTranslations: boolean; + firstLineContainsHeader: boolean; + contentSegmentation: boolean; + customSegmentation: boolean; + scheme: { + identifier: number; + sourcePhrase: number; + en: number; + de: number; + }; + }; + exportOptions: { + exportPattern: string; + }; + excludedTargetLanguages: Array; + parserVersion: number; + createdAt: string; + updatedAt: string; + }; + }>; + pagination: { + offset: number; + limit: number; + }; +} diff --git a/src/crowdin/interfaces/preTranslation.ts b/src/crowdin/interfaces/preTranslation.ts new file mode 100644 index 0000000..c6d37a6 --- /dev/null +++ b/src/crowdin/interfaces/preTranslation.ts @@ -0,0 +1,27 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export interface PreTranslation { + data: { + identifier: string; + status: string; + progress: number; + attributes: { + languageIds: Array; + fileIds: Array; + method: string; + autoApproveOption: string; + duplicateTranslations: boolean; + skipApprovedTranslations: boolean; + translateUntranslatedOnly: boolean; + translateWithPerfectMatchOnly: boolean; + }; + createdAt: string; + updatedAt: string; + startedAt: string; + finishedAt: string; + }; +} diff --git a/src/crowdin/interfaces/project.ts b/src/crowdin/interfaces/project.ts new file mode 100644 index 0000000..3f70a6e --- /dev/null +++ b/src/crowdin/interfaces/project.ts @@ -0,0 +1,60 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export interface Project { + data: { + id: number; + type: number; + userId: number; + sourceLanguageId: string; + targetLanguageIds: Array; + languageAccessPolicy: string; + name: string; + cname: string; + identifier: string; + description: string; + visibility: string; + logo: string; + publicDownloads: boolean; + createdAt: string; + updatedAt: string; + lastActivity: string; + sourceLanguage: { + id: string; + name: string; + editorCode: string; + twoLettersCode: string; + threeLettersCode: string; + locale: string; + androidCode: string; + osxCode: string; + osxLocale: string; + pluralCategoryNames: Array; + pluralRules: string; + pluralExamples: Array; + textDirection: string; + dialectOf: string; + }; + targetLanguages: Array<{ + id: string; + name: string; + editorCode: string; + twoLettersCode: string; + threeLettersCode: string; + locale: string; + androidCode: string; + osxCode: string; + osxLocale: string; + pluralCategoryNames: Array; + pluralRules: string; + pluralExamples: Array; + textDirection: string; + dialectOf: string; + }>; + webUrl: string; + savingsReportSettingsTemplateId: number; + }; +} diff --git a/src/crowdin/reapplyTranslations.ts b/src/crowdin/reapplyTranslations.ts new file mode 100644 index 0000000..52468a9 --- /dev/null +++ b/src/crowdin/reapplyTranslations.ts @@ -0,0 +1,71 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { sleep } from "../utils/sleep.js"; +import { getFiles } from "./utils/getFiles.js"; +import { getLanguages } from "./utils/getLanguages.js"; +import type { PreTranslation } from "./interfaces/preTranslation.js"; + +const projectId = process.env.CROWDIN_PROJECT_ID; +const apiUrl = process.env.CROWDIN_API_URL; +const token = process.env.CROWDIN_TOKEN; + +if ( + projectId === undefined + || projectId === "" + || apiUrl === undefined + || apiUrl === "" + || token === undefined + || token === "" +) { + throw new Error(`Project ID or API URL is missing! Did you run this script with 'op run'?`); +} + +const languages = await getLanguages(projectId, apiUrl, token); +console.log(`Found ${languages.length.toString()} active languages.`); +const files = await getFiles(projectId, apiUrl, token); +console.log(`Found ${files.length.toString()} files.`); + +const url = `${apiUrl}/projects/${projectId}/pre-translations`; + +const request = await fetch(url, { + body: JSON.stringify({ + autoApproveOption: "perfectMatchOnly", + fileIds: files, + languageIds: languages, + method: "tm", + skipApprovedTranslations: true, + translateWithPerfectMatchOnly: true, + }), + headers: { + "authorization": `Bearer ${token}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- header. + "content-type": "application/json", + }, + method: "POST", +}); + +const response: PreTranslation = await request.json(); + +const { identifier } = response.data; +console.log(`Pre-translation ${identifier} started! Will report progress every 5 seconds.`); + +let { progress } = response.data; +const progressUrl = `${apiUrl}/projects/${projectId}/pre-translations/${identifier}`; + +while (progress < 100) { + await sleep(5000); + const progressRequest = await fetch(progressUrl, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + const progressResult: PreTranslation = await progressRequest.json(); + const { progress: updatedProgress } = progressResult.data; + progress = updatedProgress; + console.log(`Progress: ${progress.toString()}%`); +} +console.log("Pretranslation complete!"); diff --git a/src/crowdin/utils/getFiles.ts b/src/crowdin/utils/getFiles.ts new file mode 100644 index 0000000..96cf57a --- /dev/null +++ b/src/crowdin/utils/getFiles.ts @@ -0,0 +1,50 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { File } from "../interfaces/file.js"; + +/** + * Gets a list of all files from a project. + * @param projectId - The project ID (a numeric string). + * @param apiUrl - The base URL for the API. + * @param token - The API key. + * @returns An array of language IDs as strings. + */ +export const getFiles + = async( + projectId: string, + apiUrl: string, + token: string, + ): Promise> => { + const url = `${apiUrl}/projects/${projectId}/files?limit=500`; + let offset = 0; + const ids: Array = []; + + console.log(`Requesting files ${offset.toString()} to ${(offset + 500).toString()}`); + let request = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + let response: File = await request.json(); + ids.push(...response.data.map((datum) => { + return datum.data.id; + })); + while (response.data.length >= 500) { + offset = offset + 500; + console.log(`Requesting files ${offset.toString()} to ${(offset + 500).toString()}`); + request = await fetch(`${url}&offset=${offset.toString()}`, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + response = await request.json(); + ids.push(...response.data.map((datum) => { + return datum.data.id; + })); + } + return ids; + }; diff --git a/src/crowdin/utils/getLanguages.ts b/src/crowdin/utils/getLanguages.ts new file mode 100644 index 0000000..eb87283 --- /dev/null +++ b/src/crowdin/utils/getLanguages.ts @@ -0,0 +1,31 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { Project } from "../interfaces/project.js"; + +/** + * Fetches a project from Crowdin and returns the list of target language IDs. + * @param projectId - The project ID (a numeric string). + * @param apiUrl - The base URL for the API. + * @param token - The API key. + * @returns An array of language IDs as strings. + */ +export const getLanguages + = async( + projectId: string, + apiUrl: string, + token: string, + ): Promise> => { + const url = `${apiUrl}/projects/${projectId}`; + const request = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + const response: Project = await request.json(); + const ids = response.data.targetLanguageIds; + return ids; + }; diff --git a/src/index.ts b/src/index.ts index fbfae5a..f765cae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,6 @@ console.log(`Hello there~! It looks like you may be trying to run this tool like a typical project. But it is not! -Instead of running "pnpm start", you should identify the script you want to run and use "tsx src/path/to/script.js".`); +Instead of running "pnpm start", you should identify the script you want to run and use "tsx src/path/to/script.js". + +Or, if your script requires environment variables, run "op run --env-file=.env --no-masking -- tsx src/path/to/script.js"`); diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 0000000..26872b9 --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,16 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * Uses async promises to pause exection for the specified time. + * @param ms - The number of milliseconds to pause for. + * @returns The promise. + */ +export const sleep = async(ms: number): Promise> => { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +};