diff --git a/cli/cli.ts b/cli/cli.ts index c305d8b7c10..0fb5acc3dbb 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -7092,7 +7092,18 @@ ${pxt.crowdin.KEY_VARIABLE} - crowdin key advancedCommand("augmentdocs", "test markdown docs replacements", augmnetDocsAsync, " "); - advancedCommand("crowdin", "upload, download, clean, stats files to/from crowdin", pc => crowdin.execCrowdinAsync.apply(undefined, pc.args), " [output]") + p.defineCommand({ + name: "crowdin", + advanced: true, + argString: " [output]", + help: "upload, download, clean, stats files to/from crowdin", + flags: { + test: { description: "test run, do not upload files to crowdin" } + } + }, pc => { + if (pc.flags.test) pxt.crowdin.setTestMode(); + return crowdin.execCrowdinAsync.apply(undefined, pc.args) + }) advancedCommand("hidlist", "list HID devices", hid.listAsync) advancedCommand("hidserial", "run HID serial forwarding", hid.serialAsync, undefined, true); diff --git a/cli/crowdin.ts b/cli/crowdin.ts index bfe7513a729..7e2d4255966 100644 --- a/cli/crowdin.ts +++ b/cli/crowdin.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import Map = pxt.Map; import * as commandParser from './commandparser'; -import { downloadFileTranslationsAsync, getFileProgressAsync, listFilesAsync, uploadFileAsync } from './crowdinApi'; +import { downloadFileTranslationsAsync, getFileProgressAsync, listFilesAsync, restoreFileBefore, uploadFileAsync } from './crowdinApi'; export function uploadTargetTranslationsAsync(parsed?: commandParser.ParsedCommand) { const uploadDocs = parsed && !!parsed.flags["docs"]; @@ -229,6 +229,15 @@ export async function execCrowdinAsync(cmd: string, ...args: string[]): Promise< } await execDownloadAsync(args[0], args[1]); break; + case "restore": + if (!args[0]) { + throw new Error("Time missing"); + } + if (args[1] !== "force" && !pxt.crowdin.testMode) { + throw new Error(`Refusing to run restore command without 'force' argument. Re-run as 'pxt crowdin restore force' to proceed or use --test flag to test.`); + } + execRestoreFiles(args[0]); + break; default: throw new Error("unknown command"); } @@ -371,3 +380,33 @@ async function execStatsAsync(language?: string) { console.log(`blocks\t ${language}\t ${(translated / phrases * 100) >> 0}%\t ${(approved / phrases * 100) >> 0}%\t ${phrases}\t ${translated}\t ${approved}`) } } + +async function execRestoreFiles(time: string | number) { + let cutoffTime; + + if (!isNaN(parseInt(time + ""))) { + cutoffTime = parseInt(time + ""); + } + else { + cutoffTime = new Date(time).getTime(); + } + + const crowdinDir = pxt.appTarget.id; + + // If this is run inside pxt-core, give results for all targets + const isCore = crowdinDir === "core"; + + const files = await listFilesAsync(); + + for (const file of files) { + pxt.debug("Processing file: " + file + "..."); + + // Files for core are in the top-level of the crowdin project + const isCoreFile = file.indexOf("/") === -1; + + + if ((isCore && !isCoreFile) || !file.startsWith(crowdinDir + "/")) continue; + + await restoreFileBefore(file, cutoffTime); + } +} \ No newline at end of file diff --git a/cli/crowdinApi.ts b/cli/crowdinApi.ts index 293ede5191a..48547f178f0 100644 --- a/cli/crowdinApi.ts +++ b/cli/crowdinApi.ts @@ -299,6 +299,8 @@ async function getAllFiles() { } async function createFile(fileName: string, fileContent: any, directoryId?: number): Promise { + if (pxt.crowdin.testMode) return; + const { uploadStorageApi, sourceFilesApi } = getClient(); // This request happens in two parts: first we upload the file to the storage API, @@ -318,6 +320,8 @@ async function createFile(fileName: string, fileContent: any, directoryId?: numb } async function createDirectory(dirName: string, directoryId?: number): Promise { + if (pxt.crowdin.testMode) return undefined; + const { sourceFilesApi } = getClient(); const dir = await sourceFilesApi.createDirectory(projectId, { @@ -332,14 +336,85 @@ async function createDirectory(dirName: string, directoryId?: number): Promise new Date(lastRevision.date).getTime()) { + lastRevision = rev; + } + } + else { + lastRevision = rev; + } + + if (time < cutoffTime) { + if (lastRevisionBeforeCutoff) { + if (time > new Date(lastRevisionBeforeCutoff.date).getTime()) { + lastRevisionBeforeCutoff = rev; + } + } + else { + lastRevisionBeforeCutoff = rev; + } + } + } + + if (lastRevision === lastRevisionBeforeCutoff) { + pxt.log(`${filename} already at most recent valid revision before ${formatTime(cutoffTime)}`); + } + else if (lastRevisionBeforeCutoff) { + pxt.log(`Restoring ${filename} to revision ${formatTime(new Date(lastRevisionBeforeCutoff.date).getTime())}`) + await restorefile(lastRevisionBeforeCutoff.fileId, lastRevisionBeforeCutoff.id); + } + else { + pxt.log(`No revisions found for ${filename} before ${formatTime(cutoffTime)}`); + } +} + +function formatTime(time: number) { + const date = new Date(time); + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; +} + +async function listFileRevisions(filename: string): Promise { + const { sourceFilesApi } = getClient(); + + const fileId = await getFileIdAsync(filename); + const revisions = await sourceFilesApi + .withFetchAll() + .listFileRevisions(projectId, fileId); + + return revisions.data.map(rev => rev.data); +} + async function updateFile(fileId: number, fileName: string, fileContent: any): Promise { + if (pxt.crowdin.testMode) return; + const { uploadStorageApi, sourceFilesApi } = getClient(); const storageResponse = await uploadStorageApi.addStorage(fileName, fileContent); await sourceFilesApi.updateOrRestoreFile(projectId, fileId, { storageId: storageResponse.data.id, + updateOption: "keep_translations" + }); +} + +async function restorefile(fileId: number, revisionId: number) { + if (pxt.crowdin.testMode) return; + + const { sourceFilesApi } = getClient(); + + await sourceFilesApi.updateOrRestoreFile(projectId, fileId, { + revisionId }); }