mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Handle fallback when creating new translations
This commit is contained in:
parent
4d0a6e5223
commit
7cc6e7e2d4
@ -1,7 +1,8 @@
|
||||
import Elysia from "elysia";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { Movie, SeedMovie } from "~/models/movie";
|
||||
import { seedMovie, SeedMovieResponse } from "./movies";
|
||||
import { Resource, validateTranslations } from "~/models/utils";
|
||||
import { Resource } from "~/models/utils";
|
||||
import { comment } from "~/utils";
|
||||
import { KError } from "~/models/error";
|
||||
|
||||
@ -14,8 +15,8 @@ export const seed = new Elysia()
|
||||
.post(
|
||||
"/movies",
|
||||
async ({ body, error }) => {
|
||||
const err = validateTranslations(body.translations);
|
||||
if (err) return error(400, err);
|
||||
// needed due to https://github.com/elysiajs/elysia/issues/671
|
||||
body = Value.Decode(SeedMovie, body);
|
||||
|
||||
const ret = await seedMovie(body);
|
||||
if (ret.status === 422) return error(422, ret);
|
||||
@ -29,7 +30,6 @@ export const seed = new Elysia()
|
||||
description: "Existing movie edited/updated.",
|
||||
},
|
||||
201: { ...SeedMovieResponse, description: "Created a new movie." },
|
||||
400: { ...KError, description: "Invalid translation name" },
|
||||
409: {
|
||||
...Resource,
|
||||
description: comment`
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from "elysia";
|
||||
import { ExternalId, Genre, Image, Language, SeedImage } from "./utils";
|
||||
import { ExternalId, Genre, Image, Language, SeedImage, TranslationRecord } from "./utils";
|
||||
import { bubble, registerExamples } from "./examples";
|
||||
import { bubbleImages } from "./examples/bubble";
|
||||
|
||||
@ -50,8 +50,7 @@ export type Movie = typeof Movie.static;
|
||||
export const SeedMovie = t.Intersect([
|
||||
t.Omit(BaseMovie, ["id", "createdAt", "nextRefresh"]),
|
||||
t.Object({
|
||||
translations: t.Record(
|
||||
Language(),
|
||||
translations: TranslationRecord(
|
||||
t.Intersect([
|
||||
t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
||||
t.Object({
|
||||
@ -61,9 +60,6 @@ export const SeedMovie = t.Intersect([
|
||||
logo: t.Nullable(SeedImage),
|
||||
}),
|
||||
]),
|
||||
{
|
||||
minProperties: 1,
|
||||
},
|
||||
),
|
||||
videos: t.Optional(t.Array(t.String({ format: "uuid" }))),
|
||||
}),
|
||||
|
@ -1,53 +1,69 @@
|
||||
import { FormatRegistry } from "@sinclair/typebox";
|
||||
import {
|
||||
FormatRegistry,
|
||||
StaticDecode,
|
||||
TSchema,
|
||||
TString,
|
||||
} from "@sinclair/typebox";
|
||||
import { t } from "elysia";
|
||||
import { comment } from "../../utils";
|
||||
import type { KError } from "../error";
|
||||
import { KErrorT } from "../error";
|
||||
|
||||
export const validateTranslations = <T extends object>(
|
||||
translations: Record<string, T>,
|
||||
): KError | null => {
|
||||
for (const lang of Object.keys(translations)) {
|
||||
try {
|
||||
const valid = new Intl.Locale(lang).baseName;
|
||||
if (lang !== valid) {
|
||||
translations[valid] = translations[lang];
|
||||
delete translations[lang];
|
||||
// this is just for the doc
|
||||
FormatRegistry.Set("language", () => true);
|
||||
|
||||
export const Language = (props?: NonNullable<Parameters<typeof t.String>[0]>) =>
|
||||
t
|
||||
.Transform(
|
||||
t.String({
|
||||
format: "language",
|
||||
description: comment`
|
||||
${props?.description ?? ""}
|
||||
This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages).
|
||||
BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it.
|
||||
`,
|
||||
error: "Expected a valid (and NORMALIZED) bcp-47 language code.",
|
||||
...props,
|
||||
}),
|
||||
)
|
||||
.Decode((lang) => {
|
||||
try {
|
||||
return new Intl.Locale(lang).baseName;
|
||||
} catch {
|
||||
throw new KErrorT(`Invalid language name: '${lang}'`);
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 400,
|
||||
message: `Invalid translation name: '${lang}'.`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
})
|
||||
.Encode((x) => x);
|
||||
|
||||
FormatRegistry.Set("language", (lang) => {
|
||||
try {
|
||||
const normalized = new Intl.Locale(lang).baseName;
|
||||
// TODO: we should actually replace the locale with normalized if we managed to parse it but transforms aren't working
|
||||
return lang === normalized;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
export const TranslationRecord = <T extends TSchema>(
|
||||
values: Parameters<typeof t.Record<TString, T>>[1],
|
||||
props?: Parameters<typeof t.Record<TString, T>>[2],
|
||||
) =>
|
||||
t
|
||||
.Transform(t.Record(t.String(), values, { minPropreties: 1, ...props }))
|
||||
// @ts-expect-error idk why the translations type can't get resolved so it's a pain to work
|
||||
// with without casting it
|
||||
.Decode((translations: Record<string, StaticDecode<T>>) => {
|
||||
for (const lang of Object.keys(translations)) {
|
||||
try {
|
||||
const locale = new Intl.Locale(lang);
|
||||
|
||||
type StringProps = NonNullable<Parameters<typeof t.String>[0]>;
|
||||
|
||||
// TODO: format validation doesn't work in record's key. We should have a proper way to check that.
|
||||
export const Language = (props?: StringProps) =>
|
||||
t.String({
|
||||
format: "language",
|
||||
description: comment`
|
||||
${props?.description ?? ""}
|
||||
This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages).
|
||||
BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it.
|
||||
`,
|
||||
error: "Expected a valid (and NORMALIZED) bcp-47 language code.",
|
||||
...props,
|
||||
});
|
||||
// fallback (ex add `en` if we only have `en-us`)
|
||||
if (!(locale.language in translations))
|
||||
translations[locale.language] = translations[lang];
|
||||
// normalize locale names (caps, old values etc)
|
||||
// we need to do this here because the record's key (Language)'s transform is not runned.
|
||||
// this is a limitation of typebox
|
||||
if (lang !== locale.baseName) {
|
||||
translations[locale.baseName] = translations[lang];
|
||||
delete translations[lang];
|
||||
}
|
||||
} catch (e) {
|
||||
throw new KErrorT(`Invalid translation name: '${lang}'.`);
|
||||
}
|
||||
}
|
||||
return translations;
|
||||
})
|
||||
.Encode((x) => x);
|
||||
|
||||
export const processLanguages = (languages?: string) => {
|
||||
if (!languages) return ["*"];
|
||||
|
@ -1,8 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { seedMovie } from "~/controllers/seed/movies";
|
||||
import { db } from "~/db";
|
||||
import { shows } from "~/db/schema";
|
||||
import { bubble } from "~/models/examples";
|
||||
import { getMovie } from "./movies-helper";
|
||||
import { expectStatus } from "tests/utils";
|
||||
@ -11,10 +8,7 @@ let bubbleId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const ret = await seedMovie(bubble);
|
||||
bubbleId = ret.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await db.delete(shows).where(eq(shows.slug, bubble.slug));
|
||||
if (ret.status !== 422) bubbleId = ret.id;
|
||||
});
|
||||
|
||||
describe("Get movie", () => {
|
||||
@ -68,6 +62,16 @@ describe("Get movie", () => {
|
||||
it("Works without accept-language header", async () => {
|
||||
const [resp, body] = await getMovie(bubble.slug, undefined);
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
slug: bubble.slug,
|
||||
name: bubble.translations.en.name,
|
||||
});
|
||||
expect(resp.headers.get("Content-Language")).toBe("en");
|
||||
});
|
||||
it("Fallback if translations does not exist", async () => {
|
||||
const [resp, body] = await getMovie(bubble.slug, "en-au");
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
slug: bubble.slug,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, test } from "bun:test";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { db } from "~/db";
|
||||
import { shows, showTranslations, videos } from "~/db/schema";
|
||||
@ -154,8 +154,8 @@ describe("Movie seeding", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectStatus(resp, body).toBe(400);
|
||||
expect(body.status).toBe(400);
|
||||
expectStatus(resp, body).toBe(422);
|
||||
expect(body.status).toBe(422);
|
||||
expect(body.message).toBe("Invalid translation name: 'test'.");
|
||||
});
|
||||
|
||||
@ -163,6 +163,7 @@ describe("Movie seeding", () => {
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "casing-test",
|
||||
originalLanguage: "jp-jp",
|
||||
translations: {
|
||||
"en-us": {
|
||||
name: "foo",
|
||||
@ -185,15 +186,32 @@ describe("Movie seeding", () => {
|
||||
where: eq(shows.id, body.id),
|
||||
with: { translations: true },
|
||||
});
|
||||
expect(ret!.translations).toBeArrayOfSize(1);
|
||||
expect(ret!.translations[0]).toMatchObject({
|
||||
language: "en-US",
|
||||
name: "foo",
|
||||
});
|
||||
expect(ret!.originalLanguage).toBe("jp-JP");
|
||||
expect(ret!.translations).toBeArrayOfSize(2);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
language: "en-US",
|
||||
name: "foo",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
language: "en",
|
||||
name: "foo",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Refuses random as a slug", async () => {
|
||||
const [resp, body] = await createMovie({ ...bubble, slug: "random", airDate: null });
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "random",
|
||||
airDate: null,
|
||||
});
|
||||
expectStatus(resp, body).toBe(422);
|
||||
});
|
||||
it("Refuses random as a slug but fallback w/ airDate", async () => {
|
||||
@ -202,14 +220,87 @@ describe("Movie seeding", () => {
|
||||
expect(body.slug).toBe("random-2022");
|
||||
});
|
||||
|
||||
it("Handle fallback translations", async () => {
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "bubble-translation-test",
|
||||
translations: { "en-us": bubble.translations.en },
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
const ret = await db.query.shows.findFirst({
|
||||
where: eq(shows.id, body.id),
|
||||
with: { translations: true },
|
||||
});
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: bubble.translations.en.name,
|
||||
language: "en",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: bubble.translations.en.name,
|
||||
language: "en-US",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("No fallback if explicit", async () => {
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "bubble-translation-test-2",
|
||||
translations: {
|
||||
"en-us": bubble.translations.en,
|
||||
"en-au": { ...bubble.translations.en, name: "australian thing" },
|
||||
en: { ...bubble.translations.en, name: "Generic" },
|
||||
},
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
const ret = await db.query.shows.findFirst({
|
||||
where: eq(shows.id, body.id),
|
||||
with: { translations: true },
|
||||
});
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: bubble.translations.en.name,
|
||||
language: "en-US",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "australian thing",
|
||||
description: bubble.translations.en.description,
|
||||
language: "en-AU",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Generic",
|
||||
description: bubble.translations.en.description,
|
||||
language: "en",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test.todo("Create correct video slug (version)", async () => {});
|
||||
test.todo("Create correct video slug (part)", async () => {});
|
||||
test.todo("Create correct video slug (rendering)", async () => {});
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
await db.delete(shows).where(inArray(shows.slug, [dune.slug, "dune-2158"]));
|
||||
await db.delete(videos).where(inArray(videos.id, [duneVideo.id]));
|
||||
await db.delete(shows);
|
||||
await db.delete(videos);
|
||||
};
|
||||
// cleanup db beforehand to unsure tests are consistent
|
||||
beforeAll(cleanup);
|
||||
|
Loading…
x
Reference in New Issue
Block a user