diff --git a/.changeset/warm-files-protect.md b/.changeset/warm-files-protect.md new file mode 100644 index 00000000..604cad66 --- /dev/null +++ b/.changeset/warm-files-protect.md @@ -0,0 +1,7 @@ +--- +"@replexica/spec": minor +"@replexica/cli": minor +"replexica": minor +--- + +vtt loader diff --git a/demo/next-app/next-env.d.ts b/demo/next-app/next-env.d.ts new file mode 100644 index 00000000..40c3d680 --- /dev/null +++ b/demo/next-app/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/packages/cli/package.json b/packages/cli/package.json index 771320ca..722b80cf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,7 @@ "lodash": "^4.17.21", "markdown-it": "^14.1.0", "markdown-it-front-matter": "^0.2.4", + "node-webvtt": "^1.9.4", "marked": "^15.0.4", "object-hash": "^3.0.0", "open": "^10.1.0", diff --git a/packages/cli/src/loaders/index.spec.ts b/packages/cli/src/loaders/index.spec.ts index a0095c06..e3fb50d8 100644 --- a/packages/cli/src/loaders/index.spec.ts +++ b/packages/cli/src/loaders/index.spec.ts @@ -795,6 +795,100 @@ user.password=Contraseña expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.yaml", expectedOutput, { encoding: "utf-8", flag: "w" }); }); }); + + describe("vtt bucket loader", () => { + it("should load complex vtt data", async () => { + setupFileMocks(); + + const input = ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello world! + +00:00:30.000 --> 00:00:31.000 align:start line:0% +This is a subtitle + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar + `.trim(); + + const expectedOutput = { + "0#0-1#": "Hello world!", + "1#30-31#": "This is a subtitle", + "2#60-61#": "Foo", + "3#110-111#": "Bar", + }; + + mockFileOperations(input); + + const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt"); + vttLoader.setDefaultLocale("en"); + const data = await vttLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save complex vtt data", async () => { + setupFileMocks(); + const input = ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello world! + +00:00:30.000 --> 00:00:31.000 align:start line:0% +This is a subtitle + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar + `.trim(); + + // // Complex VTT payload to save + const payload = { + "0#0-1#": "¡Hola mundo!", + "1#30-31#": "Este es un subtítulo", + "2#60-61#": "Foo", + "3#110-111#": "Bar", + }; + + const expectedOutput = + ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +¡Hola mundo! + +00:00:30.000 --> 00:00:31.000 +Este es un subtítulo + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar`.trim() + "\n"; + + mockFileOperations(input); + + const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt"); + vttLoader.setDefaultLocale("en"); + await vttLoader.pull("en"); + + await vttLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.vtt", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + }); + describe("XML bucket loader", () => { it("should load XML data", async () => { setupFileMocks(); @@ -823,6 +917,7 @@ user.password=Contraseña const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml"); xmlLoader.setDefaultLocale("en"); const data = await xmlLoader.pull("en"); + expect(data).toEqual(expectedOutput); }); diff --git a/packages/cli/src/loaders/index.ts b/packages/cli/src/loaders/index.ts index ab29faf8..405203e2 100644 --- a/packages/cli/src/loaders/index.ts +++ b/packages/cli/src/loaders/index.ts @@ -23,6 +23,7 @@ import createXliffLoader from "./xliff"; import createXmlLoader from "./xml"; import createSrtLoader from "./srt"; import createDatoLoader from "./dato"; +import createVttLoader from "./vtt"; import createVariableLoader from "./variable"; import createSyncLoader from "./sync"; @@ -175,5 +176,13 @@ export default function createBucketLoader( createFlatLoader(), createUnlocalizableLoader(), ); + case "vtt": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createVttLoader(), + createFlatLoader(), + createSyncLoader(), + createUnlocalizableLoader(), + ); } } diff --git a/packages/cli/src/loaders/vtt.ts b/packages/cli/src/loaders/vtt.ts new file mode 100644 index 00000000..9e51003b --- /dev/null +++ b/packages/cli/src/loaders/vtt.ts @@ -0,0 +1,47 @@ +import webvtt from "node-webvtt"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createVttLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + const vtt = webvtt.parse(input)?.cues; + if (Object.keys(vtt).length === 0) { + return {}; + } else { + return vtt.reduce((result: any, cue: any, index: number) => { + const key = `${index}#${cue.start}-${cue.end}#${cue.identifier}`; + result[key] = cue.text; + return result; + }, {}); + } + }, + async push(locale, payload) { + const output = Object.entries(payload).map(([key, text]) => { + const [id, timeRange, identifier] = key.split("#"); + const [startTime, endTime] = timeRange.split("-"); + + return { + end: Number(endTime), + identifier: identifier, + start: Number(startTime), + styles: "", + text: text, + }; + }); + + console.log(payload, output); + + const input = { + valid: true, + strict: true, + cues: output, + }; + + return webvtt.compile(input); + }, + }); +} diff --git a/packages/cli/types/vtt.d.ts b/packages/cli/types/vtt.d.ts new file mode 100644 index 00000000..ba350796 --- /dev/null +++ b/packages/cli/types/vtt.d.ts @@ -0,0 +1,4 @@ +declare module "node-webvtt" { + export function parse(data: string): any; + export function compile(data: any): string; +} diff --git a/packages/spec/src/formats.ts b/packages/spec/src/formats.ts index 39498de4..517e81fa 100644 --- a/packages/spec/src/formats.ts +++ b/packages/spec/src/formats.ts @@ -19,6 +19,7 @@ export const bucketTypes = [ "srt", "dato", "compiler", + "vtt", ] as const; export const bucketTypeSchema = Z.enum(bucketTypes);