diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 4bab7ab4b..513bc6f4a 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -12,7 +12,7 @@ import { type NewPost, postTable, } from "@hackerspub/models/schema"; -import { generateUuidV7 } from "@hackerspub/models/uuid"; +import { generateUuidV7, type Uuid } from "@hackerspub/models/uuid"; import { schema } from "./mod.ts"; import { createFedCtx, @@ -711,6 +711,137 @@ test("articleByYearAndSlug returns a local article by route components", async ( }); }); +const articleContentsIncludeBeingTranslatedQuery = parse(` + query ArticleContentsIncludeBeingTranslated( + $handle: String! + $idOrYear: String! + $slug: String! + $includeBeingTranslated: Boolean + ) { + articleByYearAndSlug(handle: $handle, idOrYear: $idOrYear, slug: $slug) { + contents(includeBeingTranslated: $includeBeingTranslated) { + language + beingTranslated + } + } + } +`); + +test("Article.contents includeBeingTranslated:true returns both completed and in-progress rows", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "translationsincludetest", + name: "Translation Include Test", + email: "translationsinclude@example.com", + }); + const requester = await insertAccountWithActor(tx, { + username: "translationsincluderequester", + name: "Translation Include Requester", + email: "translationsincluderequester@example.com", + }); + const sourceId = generateUuidV7(); + const postId = generateUuidV7(); + const published = new Date("2026-04-15T00:00:00.000Z"); + + await tx.insert(articleSourceTable).values({ + id: sourceId, + accountId: author.account.id, + publishedYear: 2026, + slug: "include-being-translated", + tags: [], + allowLlmTranslation: true, + published, + updated: published, + }); + await tx.insert(articleContentTable).values([ + { + sourceId, + language: "en", + title: "Original", + content: "English original.", + published, + updated: published, + }, + { + sourceId, + language: "ko", + title: "Original (placeholder)", + content: "English original.", + originalLanguage: "en", + translationRequesterId: requester.account.id, + beingTranslated: true, + published, + updated: published, + }, + ]); + await tx.insert(postTable).values( + { + id: postId, + iri: `http://localhost/objects/${postId}`, + type: "Article", + visibility: "public", + actorId: author.actor.id, + articleSourceId: sourceId, + name: "Original", + contentHtml: "

English original.

", + language: "en", + tags: {}, + emojis: {}, + url: + `http://localhost/@${author.account.username}/2026/include-being-translated`, + published, + updated: published, + } satisfies NewPost, + ); + + const variableValues = { + handle: author.account.username, + idOrYear: "2026", + slug: "include-being-translated", + }; + + type ContentRow = { language: string; beingTranslated: boolean }; + type QueryShape = { + articleByYearAndSlug: { contents: ContentRow[] }; + }; + + const completedOnly = await execute({ + schema, + document: articleContentsIncludeBeingTranslatedQuery, + variableValues: { ...variableValues, includeBeingTranslated: false }, + contextValue: makeGuestContext(tx), + onError: "NO_PROPAGATE", + }); + assert.equal(completedOnly.errors, undefined); + const completedContents = + (toPlainJson(completedOnly.data) as QueryShape).articleByYearAndSlug + .contents; + assert.deepEqual( + completedContents, + [{ language: "en", beingTranslated: false }], + ); + + const includingInProgress = await execute({ + schema, + document: articleContentsIncludeBeingTranslatedQuery, + variableValues: { ...variableValues, includeBeingTranslated: true }, + contextValue: makeGuestContext(tx), + onError: "NO_PROPAGATE", + }); + assert.equal(includingInProgress.errors, undefined); + const allContents = + (toPlainJson(includingInProgress.data) as QueryShape).articleByYearAndSlug + .contents; + const sorted = [...allContents].sort((a, b) => + a.language.localeCompare(b.language) + ); + assert.deepEqual(sorted, [ + { language: "en", beingTranslated: false }, + { language: "ko", beingTranslated: true }, + ]); + }); +}); + test("createNote creates a note for the signed-in account", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { @@ -809,3 +940,657 @@ test("deletePost rejects deleting shared posts and postByUrl resolves owned post }); }); }); + +const requestArticleTranslationMutation = parse(` + mutation RequestArticleTranslation($input: RequestArticleTranslationInput!) { + requestArticleTranslation(input: $input) { + __typename + ... on RequestArticleTranslationPayload { + article { + id + contents(language: "ko", includeBeingTranslated: true) { + language + originalLanguage + beingTranslated + } + } + } + ... on NotAuthenticatedError { + notAuthenticated + } + ... on InvalidInputError { + inputPath + } + ... on LlmTranslationNotAllowedError { + reason + } + } + } +`); + +// Same payload shape as `requestArticleTranslationMutation`, but the +// inner `contents(language: ...)` query takes the language as a +// variable so tests that queue translations into languages other than +// Korean can still introspect the freshly inserted row. +const requestArticleTranslationMutationByLanguage = parse(` + mutation RequestArticleTranslationByLanguage( + $input: RequestArticleTranslationInput! + $language: Locale! + ) { + requestArticleTranslation(input: $input) { + __typename + ... on RequestArticleTranslationPayload { + article { + id + contents(language: $language, includeBeingTranslated: true) { + language + originalLanguage + beingTranslated + } + } + } + ... on NotAuthenticatedError { + notAuthenticated + } + ... on InvalidInputError { + inputPath + } + ... on LlmTranslationNotAllowedError { + reason + } + } + } +`); + +interface TranslatableArticleFixture { + author: Awaited>; + postId: Uuid; + sourceId: Uuid; +} + +async function insertTranslatableArticle( + tx: Parameters[0] extends (tx: infer T) => Promise + ? T + : never, + options: { + username: string; + slug: string; + allowLlmTranslation?: boolean; + language?: string; + visibility?: "public" | "unlisted" | "followers" | "direct" | "none"; + }, +): Promise { + const author = await insertAccountWithActor(tx, { + username: options.username, + name: options.username, + email: `${options.username}@example.com`, + }); + const sourceId = generateUuidV7(); + const postId = generateUuidV7(); + const published = new Date("2026-04-15T00:00:00.000Z"); + const language = options.language ?? "en"; + + await tx.insert(articleSourceTable).values({ + id: sourceId, + accountId: author.account.id, + publishedYear: 2026, + slug: options.slug, + tags: [], + allowLlmTranslation: options.allowLlmTranslation ?? true, + published, + updated: published, + }); + await tx.insert(articleContentTable).values({ + sourceId, + language, + title: "Hello", + content: "Plain article body without any external links.", + published, + updated: published, + }); + await tx.insert(postTable).values( + { + id: postId, + iri: `http://localhost/objects/${postId}`, + type: "Article", + visibility: options.visibility ?? "public", + actorId: author.actor.id, + articleSourceId: sourceId, + name: "Hello", + contentHtml: "

Plain article body without any external links.

", + language, + tags: {}, + emojis: {}, + url: `http://localhost/@${author.account.username}/2026/${options.slug}`, + published, + updated: published, + } satisfies NewPost, + ); + + return { author, postId: postId as Uuid, sourceId: sourceId as Uuid }; +} + +function makeUserContextWithStubbedTranslator( + tx: Parameters[0] extends (tx: infer T) => Promise + ? T + : never, + account: Parameters[1], +): UserContext { + // Stub the translator with a hanging LanguageModel so the queued + // `beingTranslated: true` row is never deleted by the + // failure-cleanup branch of `startArticleContentTranslation` while + // the test is asserting on the mutation's response. + const fedCtx = createFedCtx(tx); + fedCtx.data.models = { + summarizer: {} as never, + translator: { + specificationVersion: "v2", + provider: "test", + modelId: "hang", + supportedUrls: {}, + doGenerate: () => new Promise(() => {}), + doStream: () => new Promise(() => {}), + }, + } as unknown as typeof fedCtx.data.models; + return makeUserContext(tx, account, { fedCtx }); +} + +test("requestArticleTranslation rejects guests", async () => { + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslateguest", + slug: "guest", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + }, + contextValue: makeGuestContext(tx), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "NotAuthenticatedError", + notAuthenticated: "", + }, + }); + }); +}); + +test("requestArticleTranslation rejects non-existent articleId", async () => { + await withRollback(async (tx) => { + const requester = await insertAccountWithActor(tx, { + username: "rattranslatemissing", + name: "Translation Requester Missing", + email: "rattranslatemissing@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", generateUuidV7()), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "InvalidInputError", + inputPath: "articleId", + }, + }); + }); +}); + +test("requestArticleTranslation rejects non-Article posts", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "rattranslatenotearticle", + name: "Translation Note Article", + email: "rattranslatenotearticle@example.com", + }); + const note = await insertNotePost(tx, { account: author.account }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", note.post.id), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContext(tx, author.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "InvalidInputError", + inputPath: "articleId", + }, + }); + }); +}); + +test("requestArticleTranslation rejects articles the viewer cannot see", async () => { + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslatehidden", + slug: "hidden", + visibility: "direct", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatehiddenrequester", + name: "Hidden Requester", + email: "rattranslatehiddenrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "InvalidInputError", + inputPath: "articleId", + }, + }); + }); +}); + +test("requestArticleTranslation rejects articles with allowLlmTranslation=false", async () => { + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslatedisabled", + slug: "disabled", + allowLlmTranslation: false, + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatedisabledrequester", + name: "Disabled Requester", + email: "rattranslatedisabledrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "LlmTranslationNotAllowedError", + reason: "DISABLED", + }, + }); + }); +}); + +test("requestArticleTranslation rejects requests where target language equals the original", async () => { + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslatesamelang", + slug: "samelang", + language: "ko", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatesamelangrequester", + name: "Same Lang Requester", + email: "rattranslatesamelangrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "LlmTranslationNotAllowedError", + reason: "SAME_LANGUAGE", + }, + }); + }); +}); + +test("requestArticleTranslation rejects regional variants of the source language", async () => { + // `Article.contents(language: ...)` negotiates among available + // locales rather than requiring an exact tag, so allowing a + // same-family target (`en` -> `en-US`, `ko` -> `ko-KR`) would + // create a redundant placeholder row whose canonical URL would + // negotiate back to the existing source content and leave the + // newly inserted row unreachable. + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslatesamefamily", + slug: "samefamily", + language: "en", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatesamefamilyrequester", + name: "Same Family Requester", + email: "rattranslatesamefamilyrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "en-US", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "LlmTranslationNotAllowedError", + reason: "SAME_LANGUAGE", + }, + }); + }); +}); + +test("requestArticleTranslation allows cross-script variants of the same language", async () => { + // Simplified vs Traditional Chinese genuinely produce a different + // translation output, so `zh-CN` -> `zh-TW` (and vice versa) must + // be allowed even though both share the `zh` language subtag. The + // language+script comparison in the resolver permits this because + // `zh-CN` maximizes to `zh-Hans-CN` while `zh-TW` maximizes to + // `zh-Hant-TW`. + await withRollback(async (tx) => { + const { postId, sourceId } = await insertTranslatableArticle(tx, { + username: "rattranslatecrossscript", + slug: "crossscript", + language: "zh-CN", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatecrossscriptrequester", + name: "Cross Script Requester", + email: "rattranslatecrossscriptrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutationByLanguage, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "zh-TW", + }, + language: "zh-TW", + }, + contextValue: makeUserContextWithStubbedTranslator(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "RequestArticleTranslationPayload", + article: { + id: encodeGlobalID("Article", postId), + contents: [ + { + language: "zh-TW", + originalLanguage: "zh-CN", + beingTranslated: true, + }, + ], + }, + }, + }); + + const stored = await tx.query.articleContentTable.findFirst({ + where: { sourceId, language: "zh-TW" }, + }); + assert.ok(stored != null); + assert.equal(stored.beingTranslated, true); + assert.equal(stored.originalLanguage, "zh-CN"); + }); +}); + +test("requestArticleTranslation rejects locales not on the project allow-list", async () => { + // The `Locale` scalar would happily accept any well-formed BCP 47 + // tag, but the `[lang]` route only serves locales that pass + // `normalizeLocale` (the same `POSSIBLE_LOCALES` whitelist used + // across the project), so the mutation should refuse anything the + // canonical article URL flow can't display. Pick a tag that's a + // valid BCP 47 string but missing from `POSSIBLE_LOCALES`. + await withRollback(async (tx) => { + const { postId } = await insertTranslatableArticle(tx, { + username: "rattranslateunknownlang", + slug: "unknown-lang", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslateunknownlangrequester", + name: "Unknown Lang Requester", + email: "rattranslateunknownlangrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + // `ka-GE` (Georgian / Georgia): valid BCP 47, but the + // project's `POSSIBLE_LOCALES` only contains `ka` for + // Georgian. + targetLanguage: "ka-GE", + }, + }, + contextValue: makeUserContext(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "InvalidInputError", + inputPath: "targetLanguage", + }, + }); + }); +}); + +test("requestArticleTranslation queues an in-progress translation row", async () => { + await withRollback(async (tx) => { + const { postId, sourceId } = await insertTranslatableArticle(tx, { + username: "rattranslateok", + slug: "ok", + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslateokrequester", + name: "OK Requester", + email: "rattranslateokrequester@example.com", + }); + + const result = await execute({ + schema, + document: requestArticleTranslationMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + }, + contextValue: makeUserContextWithStubbedTranslator(tx, requester.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "RequestArticleTranslationPayload", + article: { + id: encodeGlobalID("Article", postId), + contents: [ + { + language: "ko", + originalLanguage: "en", + beingTranslated: true, + }, + ], + }, + }, + }); + + const stored = await tx.query.articleContentTable.findFirst({ + where: { sourceId, language: "ko" }, + }); + assert.ok(stored != null); + assert.equal(stored.beingTranslated, true); + assert.equal(stored.originalLanguage, "en"); + assert.equal(stored.translationRequesterId, requester.account.id); + }); +}); + +test("requestArticleTranslation skips enqueueing when a completed translation already exists", async () => { + // The model layer is already idempotent against this case (it + // returns early without invoking the translator), but the resolver + // can short-circuit even earlier from the already-fetched + // `articleSource.contents`. Exercise the early return by stubbing + // the translator with one that throws if invoked: a successful + // mutation response that doesn't disturb the existing row proves + // the precheck fired. + await withRollback(async (tx) => { + const { postId, sourceId, author } = await insertTranslatableArticle(tx, { + username: "rattranslatealready", + slug: "alreadytranslated", + }); + // Insert a completed `ko` translation alongside the original `en` + // row so the resolver's precheck has something to match. + const existingPublished = new Date("2026-04-16T00:00:00.000Z"); + await tx.insert(articleContentTable).values({ + sourceId, + language: "ko", + title: "안녕", + content: "이미 번역된 본문.", + originalLanguage: "en", + beingTranslated: false, + translationRequesterId: author.account.id, + published: existingPublished, + updated: existingPublished, + }); + const requester = await insertAccountWithActor(tx, { + username: "rattranslatealreadyrequester", + name: "Already Requester", + email: "rattranslatealreadyrequester@example.com", + }); + + const fedCtx = createFedCtx(tx); + let translatorCalled = false; + fedCtx.data.models = { + summarizer: {} as never, + translator: { + specificationVersion: "v2", + provider: "test", + modelId: "throw", + supportedUrls: {}, + doGenerate: () => { + translatorCalled = true; + throw new Error( + "translator should not be invoked when a completed " + + "translation already exists", + ); + }, + doStream: () => { + translatorCalled = true; + throw new Error( + "translator should not be invoked when a completed " + + "translation already exists", + ); + }, + }, + } as unknown as typeof fedCtx.data.models; + + const result = await execute({ + schema, + document: requestArticleTranslationMutationByLanguage, + variableValues: { + input: { + articleId: encodeGlobalID("Article", postId), + targetLanguage: "ko", + }, + language: "ko", + }, + contextValue: makeUserContext(tx, requester.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + requestArticleTranslation: { + __typename: "RequestArticleTranslationPayload", + article: { + id: encodeGlobalID("Article", postId), + contents: [ + { + language: "ko", + originalLanguage: "en", + beingTranslated: false, + }, + ], + }, + }, + }); + assert.equal(translatorCalled, false); + + // The pre-existing row must be untouched: not flipped back to + // `beingTranslated`, not re-stamped with the new requester. + const stored = await tx.query.articleContentTable.findFirst({ + where: { sourceId, language: "ko" }, + }); + assert.ok(stored != null); + assert.equal(stored.beingTranslated, false); + assert.equal(stored.translationRequesterId, author.account.id); + }); +}); diff --git a/graphql/post.ts b/graphql/post.ts index 0566c7a2a..8a0201c7b 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -6,7 +6,9 @@ import { getAvatarUrl } from "@hackerspub/models/account"; import { createArticle, deleteArticleDraft, + getOriginalArticleContent, LanguageChangeWithTranslationsError, + startArticleContentTranslation, updateArticle, updateArticleDraft, } from "@hackerspub/models/article"; @@ -17,7 +19,7 @@ import { } from "@hackerspub/models/bookmark"; import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji"; import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html"; -import { negotiateLocale } from "@hackerspub/models/i18n"; +import { negotiateLocale, normalizeLocale } from "@hackerspub/models/i18n"; import { renderMarkup } from "@hackerspub/models/markup"; import { createNote } from "@hackerspub/models/note"; import { @@ -65,10 +67,25 @@ class SharedPostDeletionNotAllowedError extends Error { } } +type LlmTranslationNotAllowedReason = "DISABLED" | "SAME_LANGUAGE"; + +class LlmTranslationNotAllowedError extends Error { + public constructor(public readonly reason: LlmTranslationNotAllowedReason) { + super(`LLM translation not allowed: ${reason}`); + } +} + export const PostType = builder.enumType("PostType", { values: ["ARTICLE", "NOTE", "QUESTION"], }); +const LlmTranslationNotAllowedReasonRef = builder.enumType( + "LlmTranslationNotAllowedReason", + { + values: ["DISABLED", "SAME_LANGUAGE"] as const, + }, +); + builder.objectType(SharedPostDeletionNotAllowedError, { name: "SharedPostDeletionNotAllowedError", fields: (t) => ({ @@ -76,6 +93,13 @@ builder.objectType(SharedPostDeletionNotAllowedError, { }), }); +builder.objectType(LlmTranslationNotAllowedError, { + name: "LlmTranslationNotAllowedError", + fields: (t) => ({ + reason: t.expose("reason", { type: LlmTranslationNotAllowedReasonRef }), + }), +}); + export const Post = builder.drizzleInterface("postTable", { variant: "Post", interfaces: [Reactable, Node], @@ -301,11 +325,9 @@ export const Article = builder.drizzleNode("postTable", { with: { articleSource: { with: { - contents: { - where: { - beingTranslated: args.includeBeingTranslated ?? false, - }, - }, + contents: args.includeBeingTranslated + ? {} + : { where: { beingTranslated: false } }, }, }, }, @@ -1775,6 +1797,126 @@ builder.relayMutationField( }, ); +builder.relayMutationField( + "requestArticleTranslation", + { + inputFields: (t) => ({ + articleId: t.globalID({ for: [Article], required: true }), + targetLanguage: t.field({ type: "Locale", required: true }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + LlmTranslationNotAllowedError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null || ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const post = await ctx.db.query.postTable.findFirst({ + where: { id: args.input.articleId.id }, + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + mentions: true, + articleSource: { + with: { contents: true }, + }, + }, + }); + if ( + post == null || + post.type !== "Article" || + post.articleSource == null || + !isPostVisibleTo(post, ctx.account.actor) + ) { + throw new InvalidInputError("articleId"); + } + if (!post.articleSource.allowLlmTranslation) { + throw new LlmTranslationNotAllowedError("DISABLED"); + } + const original = getOriginalArticleContent(post.articleSource); + if (original == null) { + throw new InvalidInputError("articleId"); + } + // The `Locale` scalar accepts any syntactically valid BCP 47 + // tag, but the `[lang]` route only serves locales that pass + // `normalizeLocale` (i.e. the `POSSIBLE_LOCALES` whitelist + // used across the project). Run the same check here so an + // API client cannot enqueue a translation for a tag the + // canonical article URL flow will never display. + const targetLanguage = normalizeLocale( + args.input.targetLanguage.baseName, + ); + if (targetLanguage == null) { + throw new InvalidInputError("targetLanguage"); + } + // Reject targets that share the source's *language and script* + // subtags after maximization (so `en` -> `en-US` and `ko` -> + // `ko-KR` are blocked because they both maximize to the same + // `language`+`script` pair, but `zh-CN` -> `zh-TW` is allowed + // because Simplified vs Traditional script genuinely produces a + // different translation output). `Article.contents` negotiates + // among available locales rather than requiring exact tags, so + // permitting a same-script variant would create a redundant + // placeholder row whose canonical URL would negotiate back to + // the existing source content and leave the newly inserted row + // unreachable; a different-script variant has its own canonical + // URL slot in the negotiation result. + const targetMax = new Intl.Locale(targetLanguage).maximize(); + const originalMax = new Intl.Locale(original.language).maximize(); + if ( + targetMax.language === originalMax.language && + targetMax.script === originalMax.script + ) { + throw new LlmTranslationNotAllowedError("SAME_LANGUAGE"); + } + + // Skip enqueueing if a *completed* translation for the target + // locale already exists. `startArticleContentTranslation` is + // already idempotent against this case (it returns early without + // calling the translator), but checking here against the + // already-fetched `articleSource.contents` lets the resolver + // avoid the extra DB round-trip and makes the no-op intent + // explicit at the resolver layer. In-progress and stale rows + // are intentionally not short-circuited here so the model layer + // can re-queue them per its 30-minute staleness window. + const alreadyTranslated = post.articleSource.contents.some( + (c) => + !c.beingTranslated && normalizeLocale(c.language) === targetLanguage, + ); + if (!alreadyTranslated) { + await startArticleContentTranslation(ctx.fedCtx, { + content: original, + targetLanguage, + requester: ctx.account, + }); + } + + return post; + }, + }, + { + outputFields: (t) => ({ + article: t.field({ + type: Article, + resolve: (post) => post, + }), + }), + }, +); + interface UploadMediaResult { url: string; width: number; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 7e1087c3c..7055861c6 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -647,6 +647,15 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// """ scalar JSON +type LlmTranslationNotAllowedError { + reason: LlmTranslationNotAllowedReason! +} + +enum LlmTranslationNotAllowedReason { + DISABLED + SAME_LANGUAGE +} + """A BCP 47-compliant language tag.""" scalar Locale @@ -766,6 +775,7 @@ type Mutation { registerFcmDeviceToken(input: RegisterFcmDeviceTokenInput!): RegisterFcmDeviceTokenResult! removeFollower(input: RemoveFollowerInput!): RemoveFollowerResult! removeReactionFromPost(input: RemoveReactionFromPostInput!): RemoveReactionFromPostResult! + requestArticleTranslation(input: RequestArticleTranslationInput!): RequestArticleTranslationResult! revokePasskey(passkeyId: ID!): ID """Revoke a session by its ID.""" @@ -1426,6 +1436,19 @@ type ReplyNotification implements Node & Notification { uuid: UUID! } +input RequestArticleTranslationInput { + articleId: ID! + clientMutationId: ID + targetLanguage: Locale! +} + +type RequestArticleTranslationPayload { + article: Article! + clientMutationId: ID +} + +union RequestArticleTranslationResult = InvalidInputError | LlmTranslationNotAllowedError | NotAuthenticatedError | RequestArticleTranslationPayload + input SaveArticleDraftInput { clientMutationId: ID content: Markdown! diff --git a/web-next/src/locales/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 338df7bd1..a40d0107c 100644 --- a/web-next/src/locales/en-US/messages.po +++ b/web-next/src/locales/en-US/messages.po @@ -14,7 +14,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: article().replies?.edges.length ?? 0 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:633 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:878 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" @@ -26,16 +26,14 @@ msgstr "{0, plural, one {# eligible account} other {# eligible accounts}}; {1, p #. placeholder {0}: a().followersCount.totalCount #. placeholder {0}: actor().followersCount.totalCount -#: src/components/ActorPreviewCard.tsx:106 -#: src/components/ActorPreviewCard.tsx:130 +#: src/components/ActorPreviewCard.tsx:63 #: src/components/ProfileCard.tsx:257 msgid "{0, plural, one {# follower} other {# followers}}" msgstr "{0, plural, one {# follower} other {# followers}}" #. placeholder {0}: a().followeesCount.totalCount #. placeholder {0}: actor().followeesCount.totalCount -#: src/components/ActorPreviewCard.tsx:95 -#: src/components/ActorPreviewCard.tsx:119 +#: src/components/ActorPreviewCard.tsx:54 #: src/components/ProfileCard.tsx:242 msgid "{0, plural, one {# following} other {# following}}" msgstr "{0, plural, one {# following} other {# following}}" @@ -48,12 +46,12 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, one {# invitation left} other {# invitations left}}" #. placeholder {0}: votes() -#: src/components/QuestionCard.tsx:476 +#: src/components/QuestionCard.tsx:478 msgid "{0, plural, one {# vote} other {# votes}}" msgstr "{0, plural, one {# vote} other {# votes}}" #. placeholder {0}: props.poll.voters.totalCount -#: src/components/QuestionCard.tsx:422 +#: src/components/QuestionCard.tsx:424 msgid "{0, plural, one {# voter} other {# voters}}" msgstr "{0, plural, one {# voter} other {# voters}}" @@ -169,7 +167,7 @@ msgstr "{0} shared your post" #. placeholder {0}: post().actor.name #. placeholder {1}: post().excerpt #. placeholder {1}: title() -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:158 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:204 #: src/routes/(root)/[handle]/[noteId].tsx:174 msgid "{0}: {1}" msgstr "{0}: {1}" @@ -408,7 +406,7 @@ msgstr "Choose the language your friend prefers. This language will only be used msgid "Choose your preferred language for the verification email." msgstr "Choose your preferred language for the verification email." -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Closed" msgstr "Closed" @@ -461,11 +459,11 @@ msgstr "Could not copy the link to the clipboard." msgid "Could not find a post at this URL" msgstr "Could not find a post at this URL" -#: src/components/ActorHoverCardLoader.tsx:36 +#: src/components/ActorHoverCardLoader.tsx:34 msgid "Could not load profile." msgstr "Could not load profile." -#: src/components/QuestionCard.tsx:367 +#: src/components/QuestionCard.tsx:369 msgid "Could not vote on this poll" msgstr "Could not vote on this poll" @@ -589,7 +587,7 @@ msgstr "Drag to select the area you want to keep, then click “Crop” to updat msgid "e.g., @user@mastodon.social" msgstr "e.g., @user@mastodon.social" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:413 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:602 msgid "Edit" msgstr "Edit" @@ -612,11 +610,11 @@ msgstr "Email address" msgid "Email or username" msgstr "Email or username" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ended" msgstr "Ended" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ends" msgstr "Ends" @@ -852,7 +850,7 @@ msgstr "Failed to update the article. Please try again." msgid "Failed to upload image" msgstr "Failed to upload image" -#: src/components/QuestionCard.tsx:375 +#: src/components/QuestionCard.tsx:377 msgid "Failed to vote" msgstr "Failed to vote" @@ -894,7 +892,7 @@ msgstr "Followers only" msgid "Following" msgstr "Following" -#: src/components/ActorPreviewCard.tsx:141 +#: src/components/ActorPreviewCard.tsx:149 #: src/components/ProfileCard.tsx:267 msgid "Following you" msgstr "Following you" @@ -990,7 +988,7 @@ msgid "If enabled, the AI will generate a summary of the article for you. Otherw msgstr "If enabled, the AI will generate a summary of the article for you. Otherwise, the first few lines of the article will be used as the summary." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:655 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:900 msgid "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." msgstr "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." @@ -1288,7 +1286,7 @@ msgstr "Move to the top" msgid "Move up" msgstr "Move up" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Multiple choice" msgstr "Multiple choice" @@ -1423,7 +1421,7 @@ msgstr "Notifications" msgid "Number of invitations" msgstr "Number of invitations" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Open" msgstr "Open" @@ -1439,7 +1437,7 @@ msgstr "Or" msgid "Or enter the code from the email" msgstr "Or enter the code from the email" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:531 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:767 msgid "Other languages" msgstr "Other languages" @@ -1509,11 +1507,11 @@ msgstr "Please enter your Fediverse handle." msgid "Please sign in to access this page" msgstr "Please sign in to access this page" -#: src/components/QuestionCard.tsx:361 +#: src/components/QuestionCard.tsx:363 msgid "Please sign in to vote" msgstr "Please sign in to vote" -#: src/components/QuestionCard.tsx:336 +#: src/components/QuestionCard.tsx:338 msgid "Poll closed" msgstr "Poll closed" @@ -1621,8 +1619,8 @@ msgstr "React with {emoji}" msgid "reaction" msgstr "reaction" -#: src/components/ArticleCard.tsx:379 -#: src/components/ArticleCard.tsx:399 +#: src/components/ArticleCard.tsx:377 +#: src/components/ArticleCard.tsx:397 msgid "Read full article" msgstr "Read full article" @@ -1751,11 +1749,11 @@ msgstr "Search posts…" msgid "Search results for {0}" msgstr "Search results for {0}" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select an option" msgstr "Select an option" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select options" msgstr "Select options" @@ -1801,7 +1799,7 @@ msgstr "Sign in" msgid "Sign in to Hackers' Pub" msgstr "Sign in to Hackers' Pub" -#: src/components/QuestionCard.tsx:338 +#: src/components/QuestionCard.tsx:340 msgid "Sign in to vote" msgstr "Sign in to vote" @@ -1834,7 +1832,7 @@ msgstr "Signing in…" msgid "Signing up for Hackers' Pub" msgstr "Signing up for Hackers' Pub" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Single choice" msgstr "Single choice" @@ -1871,15 +1869,15 @@ msgstr "Successfully saved preferences" msgid "Successfully saved settings" msgstr "Successfully saved settings" -#: src/components/ArticleCard.tsx:332 -#: src/components/ArticleCard.tsx:351 +#: src/components/ArticleCard.tsx:330 +#: src/components/ArticleCard.tsx:349 msgid "Summarized by LLM" msgstr "Summarized by LLM" #: src/components/DocumentView.tsx:34 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:439 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:447 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:696 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:628 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:636 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:941 msgid "Table of contents" msgstr "Table of contents" @@ -1889,7 +1887,7 @@ msgstr "Table of contents" #: src/components/article-composer/ArticleComposerForm.tsx:142 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:292 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:705 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:950 msgid "Tags" msgstr "Tags" @@ -1997,6 +1995,10 @@ msgstr "This invitation link was not found." msgid "This service does not support remote follow." msgstr "This service does not support remote follow." +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:460 +msgid "This usually takes about a minute. The page will update automatically when the translation is ready." +msgstr "This usually takes about a minute. The page will update automatically when the translation is ready." + #: src/components/article-composer/ArticleComposerPublishFields.tsx:28 msgid "This will be part of the article URL" msgstr "This will be part of the article URL" @@ -2029,15 +2031,35 @@ msgid "Total: {0}" msgstr "Total: {0}" #. placeholder {0}: "LANGUAGE" -#: src/components/ArticleCard.tsx:215 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:515 +#: src/components/ArticleCard.tsx:213 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:751 msgid "Translated from {0}" msgstr "Translated from {0}" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:323 +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:455 +msgid "Translating to {0}…" +msgstr "Translating to {0}…" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:451 msgid "Translating…" msgstr "Translating…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:299 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:308 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:495 +msgid "Translation request failed" +msgstr "Translation request failed" + +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:500 +msgid "Translation request failed for {0}" +msgstr "Translation request failed for {0}" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:508 +msgid "Try again" +msgstr "Try again" + #: src/components/article-composer/ArticleComposerForm.tsx:146 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:296 msgid "Type tags separated by spaces" @@ -2159,22 +2181,26 @@ msgstr "Visible to everyone, but does not appear in the public timeline. Only th msgid "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." msgstr "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." -#: src/components/QuestionCard.tsx:343 +#: src/components/QuestionCard.tsx:345 msgid "Vote" msgstr "Vote" -#: src/components/QuestionCard.tsx:357 +#: src/components/QuestionCard.tsx:359 msgid "Vote recorded" msgstr "Vote recorded" -#: src/components/QuestionCard.tsx:337 +#: src/components/QuestionCard.tsx:339 msgid "Voted" msgstr "Voted" -#: src/components/QuestionCard.tsx:339 +#: src/components/QuestionCard.tsx:341 msgid "Voting…" msgstr "Voting…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:505 +msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." +msgstr "We couldn't reach the translation service. Try again, or come back in a few minutes." + #: src/routes/(root)/[handle]/settings/index.tsx:513 msgid "Website" msgstr "Website" @@ -2195,7 +2221,7 @@ msgstr "What's on your mind?" msgid "Without shares" msgstr "Without shares" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:646 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:891 msgid "Write a reply…" msgstr "Write a reply…" diff --git a/web-next/src/locales/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index 72ed03e76..f7a6c5998 100644 --- a/web-next/src/locales/ja-JP/messages.po +++ b/web-next/src/locales/ja-JP/messages.po @@ -14,7 +14,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: article().replies?.edges.length ?? 0 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:633 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:878 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {#コメント}}" @@ -26,16 +26,14 @@ msgstr "{0, plural, other {対象アカウント#件}}中、{1, plural, other { #. placeholder {0}: a().followersCount.totalCount #. placeholder {0}: actor().followersCount.totalCount -#: src/components/ActorPreviewCard.tsx:106 -#: src/components/ActorPreviewCard.tsx:130 +#: src/components/ActorPreviewCard.tsx:63 #: src/components/ProfileCard.tsx:257 msgid "{0, plural, one {# follower} other {# followers}}" msgstr "{0, plural, other {#フォロワー}}" #. placeholder {0}: a().followeesCount.totalCount #. placeholder {0}: actor().followeesCount.totalCount -#: src/components/ActorPreviewCard.tsx:95 -#: src/components/ActorPreviewCard.tsx:119 +#: src/components/ActorPreviewCard.tsx:54 #: src/components/ProfileCard.tsx:242 msgid "{0, plural, one {# following} other {# following}}" msgstr "{0, plural, other {#フォロー}}" @@ -48,12 +46,12 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {残り#件の招待}}" #. placeholder {0}: votes() -#: src/components/QuestionCard.tsx:476 +#: src/components/QuestionCard.tsx:478 msgid "{0, plural, one {# vote} other {# votes}}" msgstr "{0, plural, other {#票}}" #. placeholder {0}: props.poll.voters.totalCount -#: src/components/QuestionCard.tsx:422 +#: src/components/QuestionCard.tsx:424 msgid "{0, plural, one {# voter} other {# voters}}" msgstr "{0, plural, other {投票者 #人}}" @@ -169,7 +167,7 @@ msgstr "{0}さんがあなたのコンテンツを共有しました" #. placeholder {0}: post().actor.name #. placeholder {1}: post().excerpt #. placeholder {1}: title() -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:158 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:204 #: src/routes/(root)/[handle]/[noteId].tsx:174 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -408,7 +406,7 @@ msgstr "招待する友達の使用言語を選択してください。この言 msgid "Choose your preferred language for the verification email." msgstr "確認メールの言語を選択してください。" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Closed" msgstr "終了" @@ -461,11 +459,11 @@ msgstr "クリップボードにリンクをコピーできませんでした。 msgid "Could not find a post at this URL" msgstr "このURLのコンテンツが見つかりませんでした" -#: src/components/ActorHoverCardLoader.tsx:36 +#: src/components/ActorHoverCardLoader.tsx:34 msgid "Could not load profile." msgstr "プロフィールを読み込めませんでした。" -#: src/components/QuestionCard.tsx:367 +#: src/components/QuestionCard.tsx:369 msgid "Could not vote on this poll" msgstr "このアンケートに投票できませんでした" @@ -589,7 +587,7 @@ msgstr "保持したい領域をドラッグして選択し、「切り抜き」 msgid "e.g., @user@mastodon.social" msgstr "例: @user@mastodon.social" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:413 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:602 msgid "Edit" msgstr "編集" @@ -612,11 +610,11 @@ msgstr "メールアドレス" msgid "Email or username" msgstr "メールアドレスまたはユーザー名" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ended" msgstr "終了" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ends" msgstr "終了予定" @@ -848,7 +846,7 @@ msgstr "記事の更新に失敗しました。もう一度お試しください msgid "Failed to upload image" msgstr "画像のアップロードに失敗しました" -#: src/components/QuestionCard.tsx:375 +#: src/components/QuestionCard.tsx:377 msgid "Failed to vote" msgstr "投票に失敗しました" @@ -890,7 +888,7 @@ msgstr "フォロワーのみ" msgid "Following" msgstr "フォロー中" -#: src/components/ActorPreviewCard.tsx:141 +#: src/components/ActorPreviewCard.tsx:149 #: src/components/ProfileCard.tsx:267 msgid "Following you" msgstr "あなたをフォロー中" @@ -986,7 +984,7 @@ msgid "If enabled, the AI will generate a summary of the article for you. Otherw msgstr "有効にすると、AIが記事の要約を生成します。無効の場合は、記事の最初の数行が要約として使用されます。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:655 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:900 msgid "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." msgstr "フェディバース(fediverse)アカウントをお持ちの場合、この記事に返信することができます。ご利用のインスタンスの検索バーに{0}を検索し、該当記事に返信してください。" @@ -1284,7 +1282,7 @@ msgstr "一番上へ移動" msgid "Move up" msgstr "上へ移動" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Multiple choice" msgstr "複数選択" @@ -1418,7 +1416,7 @@ msgstr "通知" msgid "Number of invitations" msgstr "招待数" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Open" msgstr "受付中" @@ -1434,7 +1432,7 @@ msgstr "または" msgid "Or enter the code from the email" msgstr "またはメールのコードを入力してください" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:531 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:767 msgid "Other languages" msgstr "他の言語" @@ -1504,11 +1502,11 @@ msgstr "フェディバースのハンドルを入力してください。" msgid "Please sign in to access this page" msgstr "このページにアクセスするにはログインしてください" -#: src/components/QuestionCard.tsx:361 +#: src/components/QuestionCard.tsx:363 msgid "Please sign in to vote" msgstr "投票するにはログインしてください" -#: src/components/QuestionCard.tsx:336 +#: src/components/QuestionCard.tsx:338 msgid "Poll closed" msgstr "アンケートは終了しました" @@ -1616,8 +1614,8 @@ msgstr "{emoji}でリアクション" msgid "reaction" msgstr "リアクション" -#: src/components/ArticleCard.tsx:379 -#: src/components/ArticleCard.tsx:399 +#: src/components/ArticleCard.tsx:377 +#: src/components/ArticleCard.tsx:397 msgid "Read full article" msgstr "記事全文を読む" @@ -1746,11 +1744,11 @@ msgstr "コンテンツを検索…" msgid "Search results for {0}" msgstr "{0}の検索結果" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select an option" msgstr "選択肢を選んでください" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select options" msgstr "選択肢を選んでください" @@ -1796,7 +1794,7 @@ msgstr "ログイン" msgid "Sign in to Hackers' Pub" msgstr "Hackers' Pubにログイン" -#: src/components/QuestionCard.tsx:338 +#: src/components/QuestionCard.tsx:340 msgid "Sign in to vote" msgstr "ログインして投票" @@ -1829,7 +1827,7 @@ msgstr "ログイン中…" msgid "Signing up for Hackers' Pub" msgstr "Hackers' Pubに登録" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Single choice" msgstr "単一選択" @@ -1866,15 +1864,15 @@ msgstr "環境設定を正常に保存しました" msgid "Successfully saved settings" msgstr "設定を正常に保存しました" -#: src/components/ArticleCard.tsx:332 -#: src/components/ArticleCard.tsx:351 +#: src/components/ArticleCard.tsx:330 +#: src/components/ArticleCard.tsx:349 msgid "Summarized by LLM" msgstr "LLMによる要約" #: src/components/DocumentView.tsx:34 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:439 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:447 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:696 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:628 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:636 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:941 msgid "Table of contents" msgstr "目次" @@ -1884,7 +1882,7 @@ msgstr "目次" #: src/components/article-composer/ArticleComposerForm.tsx:142 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:292 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:705 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:950 msgid "Tags" msgstr "タグ" @@ -1992,6 +1990,10 @@ msgstr "この招待リンクが見つかりませんでした。" msgid "This service does not support remote follow." msgstr "このサービスはリモートフォローに対応していません。" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:460 +msgid "This usually takes about a minute. The page will update automatically when the translation is ready." +msgstr "通常1分ほどかかります。翻訳が完了するとページが自動的に更新されます。" + #: src/components/article-composer/ArticleComposerPublishFields.tsx:28 msgid "This will be part of the article URL" msgstr "これは記事のURLの一部になります" @@ -2024,15 +2026,35 @@ msgid "Total: {0}" msgstr "合計:{0}" #. placeholder {0}: "LANGUAGE" -#: src/components/ArticleCard.tsx:215 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:515 +#: src/components/ArticleCard.tsx:213 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:751 msgid "Translated from {0}" msgstr "{0}から翻訳" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:323 +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:455 +msgid "Translating to {0}…" +msgstr "{0}に翻訳中…" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:451 msgid "Translating…" msgstr "翻訳中…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:299 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:308 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:495 +msgid "Translation request failed" +msgstr "翻訳のリクエストに失敗しました" + +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:500 +msgid "Translation request failed for {0}" +msgstr "{0}への翻訳リクエストに失敗しました" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:508 +msgid "Try again" +msgstr "再試行" + #: src/components/article-composer/ArticleComposerForm.tsx:146 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:296 msgid "Type tags separated by spaces" @@ -2154,22 +2176,26 @@ msgstr "誰でも閲覧できますが、公開タイムラインには表示さ msgid "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." msgstr "未登録ユーザーを含む誰でも閲覧できます。公開タイムラインにも表示されます。" -#: src/components/QuestionCard.tsx:343 +#: src/components/QuestionCard.tsx:345 msgid "Vote" msgstr "投票" -#: src/components/QuestionCard.tsx:357 +#: src/components/QuestionCard.tsx:359 msgid "Vote recorded" msgstr "投票しました" -#: src/components/QuestionCard.tsx:337 +#: src/components/QuestionCard.tsx:339 msgid "Voted" msgstr "投票済み" -#: src/components/QuestionCard.tsx:339 +#: src/components/QuestionCard.tsx:341 msgid "Voting…" msgstr "投票中…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:505 +msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." +msgstr "翻訳サービスに接続できませんでした。再試行するか、数分後に再度お試しください。" + #: src/routes/(root)/[handle]/settings/index.tsx:513 msgid "Website" msgstr "ウェブサイト" @@ -2190,7 +2216,7 @@ msgstr "今何してる?" msgid "Without shares" msgstr "共有除外" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:646 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:891 msgid "Write a reply…" msgstr "返信を書く…" diff --git a/web-next/src/locales/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 5b7a1eb63..3ff104721 100644 --- a/web-next/src/locales/ko-KR/messages.po +++ b/web-next/src/locales/ko-KR/messages.po @@ -14,7 +14,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: article().replies?.edges.length ?? 0 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:633 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:878 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 댓글}}" @@ -26,16 +26,14 @@ msgstr "{0, plural, other {대상 계정 #명}} 중, 지금 재발급하면 {1, #. placeholder {0}: a().followersCount.totalCount #. placeholder {0}: actor().followersCount.totalCount -#: src/components/ActorPreviewCard.tsx:106 -#: src/components/ActorPreviewCard.tsx:130 +#: src/components/ActorPreviewCard.tsx:63 #: src/components/ProfileCard.tsx:257 msgid "{0, plural, one {# follower} other {# followers}}" msgstr "{0, plural, other {# 팔로워}}" #. placeholder {0}: a().followeesCount.totalCount #. placeholder {0}: actor().followeesCount.totalCount -#: src/components/ActorPreviewCard.tsx:95 -#: src/components/ActorPreviewCard.tsx:119 +#: src/components/ActorPreviewCard.tsx:54 #: src/components/ProfileCard.tsx:242 msgid "{0, plural, one {# following} other {# following}}" msgstr "{0, plural, other {# 팔로잉}}" @@ -48,12 +46,12 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {남은 초대 #건}}" #. placeholder {0}: votes() -#: src/components/QuestionCard.tsx:476 +#: src/components/QuestionCard.tsx:478 msgid "{0, plural, one {# vote} other {# votes}}" msgstr "{0, plural, other {#표}}" #. placeholder {0}: props.poll.voters.totalCount -#: src/components/QuestionCard.tsx:422 +#: src/components/QuestionCard.tsx:424 msgid "{0, plural, one {# voter} other {# voters}}" msgstr "{0, plural, other {투표자 #명}}" @@ -169,7 +167,7 @@ msgstr "{0} 님이 회원님의 콘텐츠를 공유했습니다" #. placeholder {0}: post().actor.name #. placeholder {1}: post().excerpt #. placeholder {1}: title() -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:158 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:204 #: src/routes/(root)/[handle]/[noteId].tsx:174 msgid "{0}: {1}" msgstr "{0}: {1}" @@ -408,7 +406,7 @@ msgstr "초대장을 받을 친구가 사용하는 언어를 선택하세요. msgid "Choose your preferred language for the verification email." msgstr "인증 이메일의 언어를 선택해 주세요." -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Closed" msgstr "닫힘" @@ -461,11 +459,11 @@ msgstr "클립보드에 링크를 복사할 수 없습니다." msgid "Could not find a post at this URL" msgstr "이 URL에서 콘텐츠를 찾을 수 없습니다" -#: src/components/ActorHoverCardLoader.tsx:36 +#: src/components/ActorHoverCardLoader.tsx:34 msgid "Could not load profile." msgstr "프로필을 불러올 수 없습니다." -#: src/components/QuestionCard.tsx:367 +#: src/components/QuestionCard.tsx:369 msgid "Could not vote on this poll" msgstr "이 투표에 참여할 수 없습니다" @@ -589,7 +587,7 @@ msgstr "유지하려는 영역을 드래그하여 선택한 다음 “자르기 msgid "e.g., @user@mastodon.social" msgstr "예: @user@mastodon.social" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:413 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:602 msgid "Edit" msgstr "수정" @@ -612,11 +610,11 @@ msgstr "이메일 주소" msgid "Email or username" msgstr "이메일 또는 아이디" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ended" msgstr "종료됨" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ends" msgstr "종료까지" @@ -848,7 +846,7 @@ msgstr "게시글을 수정하지 못했습니다. 다시 시도해 주세요." msgid "Failed to upload image" msgstr "이미지 업로드에 실패했습니다" -#: src/components/QuestionCard.tsx:375 +#: src/components/QuestionCard.tsx:377 msgid "Failed to vote" msgstr "투표에 실패했습니다" @@ -890,7 +888,7 @@ msgstr "팔로워만" msgid "Following" msgstr "팔로잉" -#: src/components/ActorPreviewCard.tsx:141 +#: src/components/ActorPreviewCard.tsx:149 #: src/components/ProfileCard.tsx:267 msgid "Following you" msgstr "당신을 팔로우합니다" @@ -986,7 +984,7 @@ msgid "If enabled, the AI will generate a summary of the article for you. Otherw msgstr "활성화하면 AI가 글의 요약을 생성합니다. 비활성화 시 글의 처음 몇 줄이 요약으로 사용됩니다." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:655 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:900 msgid "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." msgstr "연합우주(fediverse) 계정이 있으시다면, 이 게시글에 댓글을 달 수 있습니다. 사용하시는 인스턴스의 검색창에 {0}로 검색하신 뒤, 해당 게시글에 댓글을 남기시면 됩니다." @@ -1284,7 +1282,7 @@ msgstr "맨 위로 이동" msgid "Move up" msgstr "위로 이동" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Multiple choice" msgstr "복수 선택" @@ -1418,7 +1416,7 @@ msgstr "알림" msgid "Number of invitations" msgstr "초대 횟수" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Open" msgstr "진행 중" @@ -1434,7 +1432,7 @@ msgstr "또는" msgid "Or enter the code from the email" msgstr "또는 이메일의 코드를 입력하세요" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:531 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:767 msgid "Other languages" msgstr "다른 언어" @@ -1504,11 +1502,11 @@ msgstr "연합우주 핸들을 입력해 주세요." msgid "Please sign in to access this page" msgstr "이 페이지에 접근하려면 로그인하세요" -#: src/components/QuestionCard.tsx:361 +#: src/components/QuestionCard.tsx:363 msgid "Please sign in to vote" msgstr "투표하려면 로그인하세요" -#: src/components/QuestionCard.tsx:336 +#: src/components/QuestionCard.tsx:338 msgid "Poll closed" msgstr "투표가 마감되었습니다" @@ -1616,8 +1614,8 @@ msgstr "{emoji}로 반응" msgid "reaction" msgstr "반응" -#: src/components/ArticleCard.tsx:379 -#: src/components/ArticleCard.tsx:399 +#: src/components/ArticleCard.tsx:377 +#: src/components/ArticleCard.tsx:397 msgid "Read full article" msgstr "게시글 전체 읽기" @@ -1746,11 +1744,11 @@ msgstr "콘텐츠 검색…" msgid "Search results for {0}" msgstr "{0}의 검색 결과" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select an option" msgstr "선택지를 선택하세요" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select options" msgstr "선택지를 선택하세요" @@ -1796,7 +1794,7 @@ msgstr "로그인" msgid "Sign in to Hackers' Pub" msgstr "Hackers' Pub 로그인" -#: src/components/QuestionCard.tsx:338 +#: src/components/QuestionCard.tsx:340 msgid "Sign in to vote" msgstr "로그인 후 투표" @@ -1829,7 +1827,7 @@ msgstr "로그인 중…" msgid "Signing up for Hackers' Pub" msgstr "Hackers' Pub 가입" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Single choice" msgstr "단일 선택" @@ -1866,15 +1864,15 @@ msgstr "환경 설정이 성공적으로 저장되었습니다" msgid "Successfully saved settings" msgstr "설정이 성공적으로 저장되었습니다" -#: src/components/ArticleCard.tsx:332 -#: src/components/ArticleCard.tsx:351 +#: src/components/ArticleCard.tsx:330 +#: src/components/ArticleCard.tsx:349 msgid "Summarized by LLM" msgstr "LLM 요약" #: src/components/DocumentView.tsx:34 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:439 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:447 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:696 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:628 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:636 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:941 msgid "Table of contents" msgstr "목차" @@ -1884,7 +1882,7 @@ msgstr "목차" #: src/components/article-composer/ArticleComposerForm.tsx:142 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:292 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:705 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:950 msgid "Tags" msgstr "태그" @@ -1992,6 +1990,10 @@ msgstr "이 초대 링크를 찾을 수 없습니다." msgid "This service does not support remote follow." msgstr "이 서비스는 원격 팔로를 지원하지 않습니다." +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:460 +msgid "This usually takes about a minute. The page will update automatically when the translation is ready." +msgstr "보통 1분 정도 소요됩니다. 번역이 완료되면 페이지가 자동으로 업데이트됩니다." + #: src/components/article-composer/ArticleComposerPublishFields.tsx:28 msgid "This will be part of the article URL" msgstr "게시글 URL의 일부가 됩니다" @@ -2024,15 +2026,35 @@ msgid "Total: {0}" msgstr "전체: {0}" #. placeholder {0}: "LANGUAGE" -#: src/components/ArticleCard.tsx:215 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:515 +#: src/components/ArticleCard.tsx:213 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:751 msgid "Translated from {0}" msgstr "{0}에서 번역됨" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:323 +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:455 +msgid "Translating to {0}…" +msgstr "{0}로 번역 중…" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:451 msgid "Translating…" msgstr "번역 중…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:299 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:308 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:495 +msgid "Translation request failed" +msgstr "번역 요청 실패" + +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:500 +msgid "Translation request failed for {0}" +msgstr "{0}로의 번역 요청에 실패했습니다" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:508 +msgid "Try again" +msgstr "다시 시도" + #: src/components/article-composer/ArticleComposerForm.tsx:146 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:296 msgid "Type tags separated by spaces" @@ -2154,22 +2176,26 @@ msgstr "모든 사람이 볼 수 있지만 공개 타임라인에는 표시되 msgid "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." msgstr "미가입 사용자를 포함해 모든 사람이 볼 수 있습니다. 공개 타임라인에도 표시됩니다." -#: src/components/QuestionCard.tsx:343 +#: src/components/QuestionCard.tsx:345 msgid "Vote" msgstr "투표" -#: src/components/QuestionCard.tsx:357 +#: src/components/QuestionCard.tsx:359 msgid "Vote recorded" msgstr "투표했습니다" -#: src/components/QuestionCard.tsx:337 +#: src/components/QuestionCard.tsx:339 msgid "Voted" msgstr "투표함" -#: src/components/QuestionCard.tsx:339 +#: src/components/QuestionCard.tsx:341 msgid "Voting…" msgstr "투표 중…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:505 +msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." +msgstr "번역 서비스에 연결하지 못했습니다. 다시 시도하거나 몇 분 후에 다시 방문해 주세요." + #: src/routes/(root)/[handle]/settings/index.tsx:513 msgid "Website" msgstr "웹사이트" @@ -2190,7 +2216,7 @@ msgstr "무슨 생각 해요?" msgid "Without shares" msgstr "공유 제외" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:646 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:891 msgid "Write a reply…" msgstr "댓글을 입력하세요…" diff --git a/web-next/src/locales/zh-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index e7ad3c380..d486ba6ae 100644 --- a/web-next/src/locales/zh-CN/messages.po +++ b/web-next/src/locales/zh-CN/messages.po @@ -14,7 +14,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: article().replies?.edges.length ?? 0 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:633 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:878 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 条评论}}" @@ -26,16 +26,14 @@ msgstr "{0, plural, other {符合条件的账号 # 个}};若现在重新发放 #. placeholder {0}: a().followersCount.totalCount #. placeholder {0}: actor().followersCount.totalCount -#: src/components/ActorPreviewCard.tsx:106 -#: src/components/ActorPreviewCard.tsx:130 +#: src/components/ActorPreviewCard.tsx:63 #: src/components/ProfileCard.tsx:257 msgid "{0, plural, one {# follower} other {# followers}}" msgstr "{0, plural, other {被 # 人关注}}" #. placeholder {0}: a().followeesCount.totalCount #. placeholder {0}: actor().followeesCount.totalCount -#: src/components/ActorPreviewCard.tsx:95 -#: src/components/ActorPreviewCard.tsx:119 +#: src/components/ActorPreviewCard.tsx:54 #: src/components/ProfileCard.tsx:242 msgid "{0, plural, one {# following} other {# following}}" msgstr "{0, plural, other {关注 # 人}}" @@ -48,12 +46,12 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩余 # 次邀请}}" #. placeholder {0}: votes() -#: src/components/QuestionCard.tsx:476 +#: src/components/QuestionCard.tsx:478 msgid "{0, plural, one {# vote} other {# votes}}" msgstr "{0, plural, other {# 票}}" #. placeholder {0}: props.poll.voters.totalCount -#: src/components/QuestionCard.tsx:422 +#: src/components/QuestionCard.tsx:424 msgid "{0, plural, one {# voter} other {# voters}}" msgstr "{0, plural, other {# 位投票者}}" @@ -169,7 +167,7 @@ msgstr "{0} 转发了你的内容" #. placeholder {0}: post().actor.name #. placeholder {1}: post().excerpt #. placeholder {1}: title() -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:158 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:204 #: src/routes/(root)/[handle]/[noteId].tsx:174 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -408,7 +406,7 @@ msgstr "选择你的朋友使用的语言。这个语言只会用于邀请。" msgid "Choose your preferred language for the verification email." msgstr "请选择验证邮件的语言。" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Closed" msgstr "已结束" @@ -461,11 +459,11 @@ msgstr "无法将链接复制到剪贴板。" msgid "Could not find a post at this URL" msgstr "无法在此URL找到内容" -#: src/components/ActorHoverCardLoader.tsx:36 +#: src/components/ActorHoverCardLoader.tsx:34 msgid "Could not load profile." msgstr "无法加载个人资料。" -#: src/components/QuestionCard.tsx:367 +#: src/components/QuestionCard.tsx:369 msgid "Could not vote on this poll" msgstr "无法提交此投票" @@ -589,7 +587,7 @@ msgstr "拖动选择要保留的区域,然后点击「裁剪」来更新你的 msgid "e.g., @user@mastodon.social" msgstr "例如:@user@mastodon.social" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:413 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:602 msgid "Edit" msgstr "编辑" @@ -612,11 +610,11 @@ msgstr "电子邮件地址" msgid "Email or username" msgstr "邮箱或用户名" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ended" msgstr "已结束" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ends" msgstr "截止" @@ -848,7 +846,7 @@ msgstr "文章更新失败,请重试。" msgid "Failed to upload image" msgstr "图片上传失败" -#: src/components/QuestionCard.tsx:375 +#: src/components/QuestionCard.tsx:377 msgid "Failed to vote" msgstr "投票失败" @@ -890,7 +888,7 @@ msgstr "只粉丝可见" msgid "Following" msgstr "关注" -#: src/components/ActorPreviewCard.tsx:141 +#: src/components/ActorPreviewCard.tsx:149 #: src/components/ProfileCard.tsx:267 msgid "Following you" msgstr "关注了你" @@ -986,7 +984,7 @@ msgid "If enabled, the AI will generate a summary of the article for you. Otherw msgstr "启用后,AI 将为您生成文章摘要。否则,将使用文章的前几行作为摘要。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:655 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:900 msgid "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." msgstr "如果你在联邦宇宙有个账户,你可以在你自己的实例里评论此文章。在你的实例搜索 {0} 后回复。" @@ -1284,7 +1282,7 @@ msgstr "移动到顶部" msgid "Move up" msgstr "向上移动" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Multiple choice" msgstr "多选" @@ -1418,7 +1416,7 @@ msgstr "通知" msgid "Number of invitations" msgstr "邀请次数" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Open" msgstr "进行中" @@ -1434,7 +1432,7 @@ msgstr "或" msgid "Or enter the code from the email" msgstr "或输入邮件中的验证码" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:531 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:767 msgid "Other languages" msgstr "其他语言" @@ -1504,11 +1502,11 @@ msgstr "请输入你的联邦宇宙用户名。" msgid "Please sign in to access this page" msgstr "请登录以访问此页面" -#: src/components/QuestionCard.tsx:361 +#: src/components/QuestionCard.tsx:363 msgid "Please sign in to vote" msgstr "请登录后投票" -#: src/components/QuestionCard.tsx:336 +#: src/components/QuestionCard.tsx:338 msgid "Poll closed" msgstr "投票已结束" @@ -1616,8 +1614,8 @@ msgstr "用{emoji}反应" msgid "reaction" msgstr "反应" -#: src/components/ArticleCard.tsx:379 -#: src/components/ArticleCard.tsx:399 +#: src/components/ArticleCard.tsx:377 +#: src/components/ArticleCard.tsx:397 msgid "Read full article" msgstr "阅读完整文章" @@ -1746,11 +1744,11 @@ msgstr "搜索内容…" msgid "Search results for {0}" msgstr "{0}的搜索结果" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select an option" msgstr "请选择一个选项" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select options" msgstr "请选择选项" @@ -1796,7 +1794,7 @@ msgstr "登录" msgid "Sign in to Hackers' Pub" msgstr "登录 Hackers' Pub" -#: src/components/QuestionCard.tsx:338 +#: src/components/QuestionCard.tsx:340 msgid "Sign in to vote" msgstr "登录后投票" @@ -1829,7 +1827,7 @@ msgstr "登录中…" msgid "Signing up for Hackers' Pub" msgstr "注册 Hackers' Pub" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Single choice" msgstr "单选" @@ -1866,15 +1864,15 @@ msgstr "设置已成功保存" msgid "Successfully saved settings" msgstr "设置保存成功" -#: src/components/ArticleCard.tsx:332 -#: src/components/ArticleCard.tsx:351 +#: src/components/ArticleCard.tsx:330 +#: src/components/ArticleCard.tsx:349 msgid "Summarized by LLM" msgstr "由 AI 生成的摘要" #: src/components/DocumentView.tsx:34 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:439 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:447 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:696 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:628 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:636 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:941 msgid "Table of contents" msgstr "目录" @@ -1884,7 +1882,7 @@ msgstr "目录" #: src/components/article-composer/ArticleComposerForm.tsx:142 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:292 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:705 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:950 msgid "Tags" msgstr "标签" @@ -1992,6 +1990,10 @@ msgstr "未找到此邀请链接。" msgid "This service does not support remote follow." msgstr "该服务不支持远程关注。" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:460 +msgid "This usually takes about a minute. The page will update automatically when the translation is ready." +msgstr "通常需要一分钟左右。翻译完成后页面将自动更新。" + #: src/components/article-composer/ArticleComposerPublishFields.tsx:28 msgid "This will be part of the article URL" msgstr "这将成为文章 URL 的一部分" @@ -2024,15 +2026,35 @@ msgid "Total: {0}" msgstr "共 {0} 个" #. placeholder {0}: "LANGUAGE" -#: src/components/ArticleCard.tsx:215 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:515 +#: src/components/ArticleCard.tsx:213 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:751 msgid "Translated from {0}" msgstr "翻译自{0}" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:323 +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:455 +msgid "Translating to {0}…" +msgstr "正在翻译为{0}…" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:451 msgid "Translating…" msgstr "正在翻译…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:299 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:308 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:495 +msgid "Translation request failed" +msgstr "翻译请求失败" + +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:500 +msgid "Translation request failed for {0}" +msgstr "向{0}的翻译请求失败" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:508 +msgid "Try again" +msgstr "重试" + #: src/components/article-composer/ArticleComposerForm.tsx:146 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:296 msgid "Type tags separated by spaces" @@ -2154,22 +2176,26 @@ msgstr "所有人都可以看到,但不会出现在公共时间线中。只有 msgid "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." msgstr "包括未注册用户在内的所有人都可以看到。内容也会出现在公共时间线中。" -#: src/components/QuestionCard.tsx:343 +#: src/components/QuestionCard.tsx:345 msgid "Vote" msgstr "投票" -#: src/components/QuestionCard.tsx:357 +#: src/components/QuestionCard.tsx:359 msgid "Vote recorded" msgstr "已记录投票" -#: src/components/QuestionCard.tsx:337 +#: src/components/QuestionCard.tsx:339 msgid "Voted" msgstr "已投票" -#: src/components/QuestionCard.tsx:339 +#: src/components/QuestionCard.tsx:341 msgid "Voting…" msgstr "正在投票…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:505 +msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." +msgstr "无法连接到翻译服务。请重试或几分钟后再来。" + #: src/routes/(root)/[handle]/settings/index.tsx:513 msgid "Website" msgstr "网站" @@ -2190,7 +2216,7 @@ msgstr "你在想啥?" msgid "Without shares" msgstr "不含转帖" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:646 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:891 msgid "Write a reply…" msgstr "写个回复…" diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index d7c9beadd..129cf3253 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -14,7 +14,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: article().replies?.edges.length ?? 0 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:633 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:878 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 則評論}}" @@ -26,16 +26,14 @@ msgstr "{0, plural, other {符合條件的帳號 # 個}};若現在重新發放 #. placeholder {0}: a().followersCount.totalCount #. placeholder {0}: actor().followersCount.totalCount -#: src/components/ActorPreviewCard.tsx:106 -#: src/components/ActorPreviewCard.tsx:130 +#: src/components/ActorPreviewCard.tsx:63 #: src/components/ProfileCard.tsx:257 msgid "{0, plural, one {# follower} other {# followers}}" msgstr "{0, plural, other {被 # 人關注}}" #. placeholder {0}: a().followeesCount.totalCount #. placeholder {0}: actor().followeesCount.totalCount -#: src/components/ActorPreviewCard.tsx:95 -#: src/components/ActorPreviewCard.tsx:119 +#: src/components/ActorPreviewCard.tsx:54 #: src/components/ProfileCard.tsx:242 msgid "{0, plural, one {# following} other {# following}}" msgstr "{0, plural, other {關注 # 人}}" @@ -48,12 +46,12 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩餘 # 次邀請}}" #. placeholder {0}: votes() -#: src/components/QuestionCard.tsx:476 +#: src/components/QuestionCard.tsx:478 msgid "{0, plural, one {# vote} other {# votes}}" msgstr "{0, plural, other {# 票}}" #. placeholder {0}: props.poll.voters.totalCount -#: src/components/QuestionCard.tsx:422 +#: src/components/QuestionCard.tsx:424 msgid "{0, plural, one {# voter} other {# voters}}" msgstr "{0, plural, other {# 位投票者}}" @@ -169,7 +167,7 @@ msgstr "{0} 轉貼了你的內容" #. placeholder {0}: post().actor.name #. placeholder {1}: post().excerpt #. placeholder {1}: title() -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:158 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:204 #: src/routes/(root)/[handle]/[noteId].tsx:174 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -408,7 +406,7 @@ msgstr "選擇你的朋友使用的語言。這個語言只會用於邀請。" msgid "Choose your preferred language for the verification email." msgstr "請選擇驗證信件的語言。" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Closed" msgstr "已結束" @@ -461,11 +459,11 @@ msgstr "無法將連結複製到剪貼簿。" msgid "Could not find a post at this URL" msgstr "無法在此URL找到內容" -#: src/components/ActorHoverCardLoader.tsx:36 +#: src/components/ActorHoverCardLoader.tsx:34 msgid "Could not load profile." msgstr "無法載入個人資料。" -#: src/components/QuestionCard.tsx:367 +#: src/components/QuestionCard.tsx:369 msgid "Could not vote on this poll" msgstr "無法提交此投票" @@ -589,7 +587,7 @@ msgstr "拖動選擇要保留的區域,然後點擊「裁剪」來更新你的 msgid "e.g., @user@mastodon.social" msgstr "例如:@user@mastodon.social" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:413 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:602 msgid "Edit" msgstr "編輯" @@ -612,11 +610,11 @@ msgstr "電子郵件地址" msgid "Email or username" msgstr "電子郵件或使用者名稱" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ended" msgstr "已結束" -#: src/components/QuestionCard.tsx:417 +#: src/components/QuestionCard.tsx:419 msgid "Ends" msgstr "截止" @@ -848,7 +846,7 @@ msgstr "文章更新失敗,請重試。" msgid "Failed to upload image" msgstr "圖片上傳失敗" -#: src/components/QuestionCard.tsx:375 +#: src/components/QuestionCard.tsx:377 msgid "Failed to vote" msgstr "投票失敗" @@ -890,7 +888,7 @@ msgstr "只粉絲可見" msgid "Following" msgstr "關注" -#: src/components/ActorPreviewCard.tsx:141 +#: src/components/ActorPreviewCard.tsx:149 #: src/components/ProfileCard.tsx:267 msgid "Following you" msgstr "關注了你" @@ -986,7 +984,7 @@ msgid "If enabled, the AI will generate a summary of the article for you. Otherw msgstr "啟用後,AI 將為您生成文章摘要。否則,將使用文章的前幾行作為摘要。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:655 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:900 msgid "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." msgstr "如果你在聯邦宇宙有個帳戶,你可以在你自己的站台裡評論此文章。在你的站台搜尋 {0} 後回覆。" @@ -1284,7 +1282,7 @@ msgstr "移動到頂部" msgid "Move up" msgstr "向上移動" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Multiple choice" msgstr "多選" @@ -1418,7 +1416,7 @@ msgstr "通知" msgid "Number of invitations" msgstr "邀請次數" -#: src/components/QuestionCard.tsx:414 +#: src/components/QuestionCard.tsx:416 msgid "Open" msgstr "進行中" @@ -1434,7 +1432,7 @@ msgstr "或" msgid "Or enter the code from the email" msgstr "或輸入郵件中的驗證碼" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:531 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:767 msgid "Other languages" msgstr "其他語言" @@ -1504,11 +1502,11 @@ msgstr "請輸入你的聯邦宇宙使用者名稱。" msgid "Please sign in to access this page" msgstr "請登入以存取此頁面" -#: src/components/QuestionCard.tsx:361 +#: src/components/QuestionCard.tsx:363 msgid "Please sign in to vote" msgstr "請登入後投票" -#: src/components/QuestionCard.tsx:336 +#: src/components/QuestionCard.tsx:338 msgid "Poll closed" msgstr "投票已結束" @@ -1616,8 +1614,8 @@ msgstr "用{emoji}反應" msgid "reaction" msgstr "反應" -#: src/components/ArticleCard.tsx:379 -#: src/components/ArticleCard.tsx:399 +#: src/components/ArticleCard.tsx:377 +#: src/components/ArticleCard.tsx:397 msgid "Read full article" msgstr "閱讀完整文章" @@ -1746,11 +1744,11 @@ msgstr "搜尋內容…" msgid "Search results for {0}" msgstr "{0}的搜尋結果" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select an option" msgstr "請選擇一個選項" -#: src/components/QuestionCard.tsx:341 +#: src/components/QuestionCard.tsx:343 msgid "Select options" msgstr "請選擇選項" @@ -1796,7 +1794,7 @@ msgstr "登入" msgid "Sign in to Hackers' Pub" msgstr "登入 Hackers' Pub" -#: src/components/QuestionCard.tsx:338 +#: src/components/QuestionCard.tsx:340 msgid "Sign in to vote" msgstr "登入後投票" @@ -1829,7 +1827,7 @@ msgstr "登入中…" msgid "Signing up for Hackers' Pub" msgstr "註冊 Hackers' Pub" -#: src/components/QuestionCard.tsx:411 +#: src/components/QuestionCard.tsx:413 msgid "Single choice" msgstr "單選" @@ -1866,15 +1864,15 @@ msgstr "設定已成功儲存" msgid "Successfully saved settings" msgstr "設定儲存成功" -#: src/components/ArticleCard.tsx:332 -#: src/components/ArticleCard.tsx:351 +#: src/components/ArticleCard.tsx:330 +#: src/components/ArticleCard.tsx:349 msgid "Summarized by LLM" msgstr "由 AI 生成的摘要" #: src/components/DocumentView.tsx:34 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:439 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:447 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:696 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:628 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:636 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:941 msgid "Table of contents" msgstr "目錄" @@ -1884,7 +1882,7 @@ msgstr "目錄" #: src/components/article-composer/ArticleComposerForm.tsx:142 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:292 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:705 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:950 msgid "Tags" msgstr "標籤" @@ -1992,6 +1990,10 @@ msgstr "未找到此邀請連結。" msgid "This service does not support remote follow." msgstr "該服務不支援遠端關注。" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:460 +msgid "This usually takes about a minute. The page will update automatically when the translation is ready." +msgstr "通常需要一分鐘左右。翻譯完成後頁面將自動更新。" + #: src/components/article-composer/ArticleComposerPublishFields.tsx:28 msgid "This will be part of the article URL" msgstr "這將成為文章網址的一部分" @@ -2024,15 +2026,35 @@ msgid "Total: {0}" msgstr "共 {0} 個" #. placeholder {0}: "LANGUAGE" -#: src/components/ArticleCard.tsx:215 -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:515 +#: src/components/ArticleCard.tsx:213 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:751 msgid "Translated from {0}" msgstr "翻譯自{0}" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:323 +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:455 +msgid "Translating to {0}…" +msgstr "正在翻譯為{0}…" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:451 msgid "Translating…" msgstr "正在翻譯…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:299 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx:308 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:495 +msgid "Translation request failed" +msgstr "翻譯請求失敗" + +#. placeholder {0}: name() +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:500 +msgid "Translation request failed for {0}" +msgstr "向{0}的翻譯請求失敗" + +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:508 +msgid "Try again" +msgstr "重試" + #: src/components/article-composer/ArticleComposerForm.tsx:146 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:296 msgid "Type tags separated by spaces" @@ -2154,22 +2176,26 @@ msgstr "所有人都可以看到,但不會出現在公共時間軸中。只有 msgid "Visible to everyone, including non-registered users. The post will appear in the public timeline as well." msgstr "包括未註冊使用者在內的所有人都可以看到。內容也會出現在公共時間軸中。" -#: src/components/QuestionCard.tsx:343 +#: src/components/QuestionCard.tsx:345 msgid "Vote" msgstr "投票" -#: src/components/QuestionCard.tsx:357 +#: src/components/QuestionCard.tsx:359 msgid "Vote recorded" msgstr "已記錄投票" -#: src/components/QuestionCard.tsx:337 +#: src/components/QuestionCard.tsx:339 msgid "Voted" msgstr "已投票" -#: src/components/QuestionCard.tsx:339 +#: src/components/QuestionCard.tsx:341 msgid "Voting…" msgstr "正在投票…" +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:505 +msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." +msgstr "無法連接到翻譯服務。請重試或幾分鐘後再來。" + #: src/routes/(root)/[handle]/settings/index.tsx:513 msgid "Website" msgstr "網站" @@ -2190,7 +2216,7 @@ msgstr "你在想什麼?" msgid "Without shares" msgstr "不含轉貼" -#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:646 +#: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:891 msgid "Write a reply…" msgstr "寫個回覆…" diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx new file mode 100644 index 000000000..b701cfac8 --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx @@ -0,0 +1,538 @@ +import { normalizeLocale } from "@hackerspub/models/i18n"; +import { + Navigate, + query, + type RouteDefinition, + useParams, +} from "@solidjs/router"; +import { HttpStatusCode } from "@solidjs/start"; +import { + type Disposable, + fetchQuery, + graphql, + type Subscription, +} from "relay-runtime"; +import { + createEffect, + createMemo, + createSignal, + Match, + onCleanup, + Show, + Switch, +} from "solid-js"; +import { + createMutation, + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; +import { showToast } from "~/components/ui/toast.tsx"; +import { useLingui } from "~/lib/i18n/macro.d.ts"; +import type { LangPageQuery } from "./__generated__/LangPageQuery.graphql.ts"; +import type { LangPage_requestArticleTranslation_Mutation } from "./__generated__/LangPage_requestArticleTranslation_Mutation.graphql.ts"; +import { + ArticleBody, + ArticleMetaHead, + ArticleTranslationFailure, + ArticleTranslationPlaceholder, +} from "./index.tsx"; + +// Matches the staleness window inside `startArticleContentTranslation` +// (`models/article.ts`). After 30 minutes of no `updated` change on a +// `beingTranslated: true` row, the model's retry path is willing to +// re-queue the translation, so the route should re-fire its mutation +// instead of polling forever. +const TRANSLATION_STALE_MS = 30 * 60 * 1000; + +// Two BCP 47 tags refer to the same translation output when their +// maximized forms agree on both the `language` and `script` subtags. +// The same `requestArticleTranslation` rule lives on the backend +// (`graphql/post.ts`); the route uses it to distinguish "user asked +// for a regional variant we already have content for, so redirect to +// the canonical URL" from "user asked for a different script (e.g., +// `zh-TW` against a `zh-CN` original), which is a meaningfully +// different translation we should queue." Either tag may be null or +// malformed (the GraphQL `Article.language` is nullable, and a +// content row's `language` could in principle be a tag the runtime +// can't parse); in those cases there's nothing to compare so we +// conservatively report "no match." +function matchesLanguageScript( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + if (a == null || b == null) return false; + try { + const aMax = new Intl.Locale(a).maximize(); + const bMax = new Intl.Locale(b).maximize(); + return aMax.language === bMax.language && aMax.script === bMax.script; + } catch { + return false; + } +} + +export const route = { + matchFilters: { + handle: /^@/, + lang: /^[A-Za-z]{2,3}(?:[_-][A-Za-z0-9]+)*$/, + }, + preload(args) { + const handle = args.params.handle!; + const idOrYear = args.params.idOrYear!; + const slug = args.params.slug!; + const language = normalizeLocale(args.params.lang!); + if (language == null) return; + void loadLangPageQuery(handle, idOrYear, slug, language); + }, +} satisfies RouteDefinition; + +const LangPageQueryDef = graphql` + query LangPageQuery( + $handle: String! + $idOrYear: String! + $slug: String! + $language: Locale! + ) { + articleByYearAndSlug( + handle: $handle + idOrYear: $idOrYear + slug: $slug + ) { + id + language + allowLlmTranslation + publishedYear + slug + actor { + username + } + contents(language: $language, includeBeingTranslated: true) { + language + originalLanguage + beingTranslated + updated + } + ...Slug_head + @arguments(language: $language, includeBeingTranslated: true) + ...Slug_body + @arguments(language: $language, includeBeingTranslated: true) + } + viewer { + id + locales + ...Slug_viewer + } + } +`; + +const requestArticleTranslationMutation = graphql` + mutation LangPage_requestArticleTranslation_Mutation( + $input: RequestArticleTranslationInput! + $language: Locale! + ) { + requestArticleTranslation(input: $input) { + __typename + ... on RequestArticleTranslationPayload { + article { + id + contents(language: $language, includeBeingTranslated: true) { + language + beingTranslated + } + ...Slug_head + @arguments(language: $language, includeBeingTranslated: true) + ...Slug_body + @arguments(language: $language, includeBeingTranslated: true) + } + } + ... on NotAuthenticatedError { + notAuthenticated + } + ... on InvalidInputError { + inputPath + } + ... on LlmTranslationNotAllowedError { + reason + } + } + } +`; + +const loadLangPageQuery = query( + (handle: string, idOrYear: string, slug: string, language: string) => + loadQuery( + useRelayEnvironment()(), + LangPageQueryDef, + { handle, idOrYear, slug, language }, + ), + "loadArticleLangPageQuery", +); + +export default function ArticleLangPage() { + const params = useParams(); + + return ( + } + > + {(language) => ( + + )} + + ); +} + +interface ArticleLangPageContentProps { + handle: string; + idOrYear: string; + slug: string; + language: string; +} + +function ArticleLangPageContent(props: ArticleLangPageContentProps) { + const { t } = useLingui(); + const env = useRelayEnvironment(); + const [requestTranslation] = createMutation< + LangPage_requestArticleTranslation_Mutation + >(requestArticleTranslationMutation); + const [requestFailed, setRequestFailed] = createSignal(false); + let pendingRequest: Disposable | null = null; + onCleanup(() => pendingRequest?.dispose()); + + const data = createPreloadedQuery( + LangPageQueryDef, + () => + loadLangPageQuery( + props.handle, + props.idOrYear, + props.slug, + props.language, + ), + ); + + const article = createMemo(() => data()?.articleByYearAndSlug ?? null); + const content = createMemo(() => article()?.contents[0] ?? null); + const viewer = createMemo(() => data()?.viewer ?? null); + const canRequestTranslation = createMemo(() => { + const a = article(); + return viewer() != null && a != null && a.allowLlmTranslation && + !matchesLanguageScript(a.language, props.language); + }); + // Mirrors the `30 * 60 * 1000` staleness window inside + // `startArticleContentTranslation`: if the placeholder row hasn't + // updated in 30 minutes the background translation worker has + // probably died, and the model layer's retry path will accept a + // fresh `requestArticleTranslation` call to re-queue it. + // `Date.parse` returns `NaN` if the GraphQL `DateTime` scalar ever + // hands us back something that isn't a valid ISO-8601 timestamp; + // a `NaN` comparison is always false, so we'd silently treat the + // row as fresh forever and never retry, which is the wrong default + // for a stuck translation. Treat unparseable timestamps as + // `0` (Unix epoch) so the row reads as definitively stale and the + // retry path gets a chance to recover. + const isStaleInProgress = createMemo(() => { + const c = content(); + if (c == null || !c.beingTranslated) return false; + const updatedMs = Date.parse(c.updated); + const lastUpdate = Number.isNaN(updatedMs) ? 0 : updatedMs; + return lastUpdate < Date.now() - TRANSLATION_STALE_MS; + }); + const shouldAutoRequest = createMemo(() => { + if (!canRequestTranslation()) return false; + const c = content(); + if (c == null) return true; + if (isStaleInProgress()) return true; + // `Article.contents` negotiates among available locales, so a + // request for `zh-TW` on a `zh-CN`-only article comes back with + // the `zh-CN` row. Don't render that under the wrong-script URL + // and don't redirect away from what the user asked for; queue a + // translation in their requested script instead. + if (!matchesLanguageScript(c.language, props.language)) return true; + return false; + }); + // Counter that bumps every time content transitions from existing + // to null. The auto-request effect uses it (via `requestKey`) to + // distinguish "still the same first-time-missing render" from "the + // background failure-cleanup branch in + // `startArticleContentTranslation` deleted the placeholder row and + // we need to re-queue." + const [missingEpoch, setMissingEpoch] = createSignal(0); + let prevContentExisted = false; + createEffect(() => { + const exists = content() != null; + if (prevContentExisted && !exists) { + setMissingEpoch((n) => n + 1); + } + prevContentExisted = exists; + }); + // Counter for explicit user-initiated retries. Bumping it changes + // `requestKey()`, which causes the auto-request effect to fire + // another mutation; we use this so a transient failure (e.g. a + // brief network drop) doesn't strand the user on a permanent + // not-found state without a way to recover short of reloading. + const [retryAttempt, setRetryAttempt] = createSignal(0); + const handleRetry = () => { + setRequestFailed(false); + setRetryAttempt((n) => n + 1); + }; + // Identity for "this is a fresh reason to fire the mutation." When + // it changes, the auto-request effect fires another mutation; when + // it stays the same (or is null because we don't need to request), + // it doesn't. The stale branch includes `content()?.updated` so a + // second-time-stale row produces a different key from the first + // stale fire; the missing branch includes `missingEpoch()` so a + // row that gets deleted, re-queued, then deleted again produces a + // different key each time; both branches include `retryAttempt()` + // so a manual retry forces a re-fire even if nothing else has + // moved. + const requestKey = createMemo(() => { + if (!shouldAutoRequest()) return null; + const c = content(); + const retry = retryAttempt(); + if (c == null) { + return `missing/${article()?.id}/${props.language}/${missingEpoch()}/${retry}`; + } + return `stale/${article()?.id}/${props.language}/${c.updated}/${retry}`; + }); + const canonicalBase = createMemo(() => { + const a = article(); + return a == null + ? null + : `/@${a.actor.username}/${a.publishedYear}/${a.slug}`; + }); + const redirectHref = createMemo(() => { + const c = content(); + const base = canonicalBase(); + if (c == null || base == null) return null; + // A logged-in viewer who can request translation gets the + // auto-request branch when the negotiated content is in a + // different script; don't preempt that with a redirect. Guests + // (and viewers on articles where the author disabled LLM + // translation) still fall through to the redirect below so they + // at least see the content that does exist. + if ( + !matchesLanguageScript(c.language, props.language) && + canRequestTranslation() + ) { + return null; + } + if (c.originalLanguage == null) return base; + if (c.language !== props.language) return `${base}/${c.language}`; + return null; + }); + + // Auto-request a translation whenever `requestKey()` changes to a + // non-null value. Three edges fire it: initial mount with content + // missing, the in-progress row going stale (>30 min since + // `updated`), and the row vanishing again after the background + // translator's failure-cleanup branch deleted it. `firedKey` keeps + // a duplicate fire from happening when an unrelated reactive memo + // re-evaluates the effect with the same key. The `requestFailed()` + // gate stops a stuck backend (mutation succeeds but the cleanup + // branch deletes the placeholder row, which bumps `missingEpoch` + // and changes `requestKey`) from re-firing the mutation in a tight + // loop; the user's "Try again" button (`handleRetry`) clears + // `requestFailed` and bumps `retryAttempt`, so manual recovery + // still works. + let firedRequestKey: string | null = null; + + // Solid Router reuses this component across param-only navigations + // (e.g., `/zh-TW` -> `/ja` on the same article, or `/ja` on a + // different article), so any local state from the previous URL + // would otherwise carry over. `requestKey()` already includes + // `props.language` and `article()?.id`, so a navigation produces + // a fresh key, but `requestFailed` and `firedRequestKey` itself + // stay sticky: a failure on one `/{lang}` would gate auto-request + // on the next, and a stale `firedRequestKey` matching the new key + // (after navigating away and back to the same URL) would keep the + // effect from re-firing. Reset both, plus the + // `prevContentExisted` ref the `missingEpoch` effect relies on, + // whenever any route param changes; cancel any in-flight mutation + // too because its result no longer applies. + createEffect(() => { + props.handle; + props.idOrYear; + props.slug; + props.language; + pendingRequest?.dispose(); + pendingRequest = null; + setRequestFailed(false); + firedRequestKey = null; + prevContentExisted = false; + }); + + createEffect(() => { + const key = requestKey(); + if (key == null || key === firedRequestKey || requestFailed()) return; + firedRequestKey = key; + pendingRequest?.dispose(); + pendingRequest = requestTranslation({ + variables: { + input: { + articleId: article()!.id, + targetLanguage: props.language, + }, + language: props.language, + }, + onCompleted(response) { + const payload = response.requestArticleTranslation; + if (payload.__typename !== "RequestArticleTranslationPayload") { + console.error( + "Translation request returned an error payload:", + payload, + ); + showToast({ + title: t`Translation request failed`, + variant: "destructive", + }); + setRequestFailed(true); + return; + } + // Quick-failure case: the server inserted the placeholder + // and the background `translate(...)` call rejected + // synchronously, so by the time Pothos serialized + // `article.contents` the failure-cleanup branch had + // already deleted the row. The payload reports success + // but there's no in-progress row to render or poll, and + // the polling effect (gated on + // `content().beingTranslated`) never gets a chance to + // recover. Surface this as a retryable failure so the + // user sees the retry UI instead of an indefinite + // placeholder. + if (payload.article.contents.length === 0) { + console.error( + "Translation request returned without a queued row:", + payload, + ); + showToast({ + title: t`Translation request failed`, + variant: "destructive", + }); + setRequestFailed(true); + } + }, + onError(error) { + console.error("Translation request failed:", error); + showToast({ + title: t`Translation request failed`, + variant: "destructive", + }); + setRequestFailed(true); + }, + }); + }); + + // While a translation is in flight, poll for completion every 30 + // seconds. When `beingTranslated` flips back to false (translation + // finished) or the component unmounts, the interval is cleared via + // `onCleanup` registered inside this effect's tracking scope. + // `fetchQuery` is used (instead of revalidating the Solid Router + // cache key) because it forces a network round trip and writes the + // fresh response into the Relay store, which `createPreloadedQuery` + // observes; revalidating the router cache alone would leave the + // already-populated Relay store unchanged. + createEffect(() => { + if (!content()?.beingTranslated) return; + // The previous tick's request, if still in flight, is left alone: + // Relay treats `unsubscribe()` as a cancellation, so cancelling + // it would mean a slow network/server (poll > 30 s) never gets a + // chance to write fresh content back to the store. Skip the new + // poll instead and let the prior one finish; the next 30 s tick + // tries again. Only cancel on `onCleanup` (interval removed, + // `beingTranslated` flipped, or component unmounted). + let pending: Subscription | null = null; + const interval = setInterval(() => { + if (pending != null) return; + pending = fetchQuery(env(), LangPageQueryDef, { + handle: props.handle, + idOrYear: props.idOrYear, + slug: props.slug, + language: props.language, + }).subscribe({ + complete() { + pending = null; + }, + error(error: unknown) { + // Background polling can hit transient network failures + // without any UI affordance; surface them in the console + // so they're discoverable, but don't toast or otherwise + // interrupt the placeholder. The next tick will retry on + // its own. + pending = null; + console.error("Translation polling failed:", error); + }, + }); + }, 30_000); + onCleanup(() => { + clearInterval(interval); + pending?.unsubscribe(); + }); + }); + + return ( + + }> + + + + +
+
+ +
+
+
+ +
+
+ +
+
+
+ + + + + { + /* + * The placeholder row's `updated` is older than the + * staleness window and we can't auto-recover (guest, or + * the article disabled LLM translation after the row was + * queued). Treat it as not-found rather than rendering + * the indefinite "translating" placeholder; otherwise the + * visitor sees a perpetual spinner with no way for us to + * recover. A logged-in viewer with translation rights + * can still re-trigger the queue from the bare slug page. + */ + } + + + + + + + + + +
+
+ ); +} diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx index 3804f44a4..08c0f2f7f 100644 --- a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx @@ -1,5 +1,6 @@ +import { normalizeLocale } from "@hackerspub/models/i18n"; import type { Toc } from "@hackerspub/models/markup"; -import { Meta } from "@solidjs/meta"; +import { Link, Meta } from "@solidjs/meta"; import { query, type RouteDefinition, @@ -28,9 +29,11 @@ import { AvatarFallback, AvatarImage, } from "~/components/ui/avatar.tsx"; +import { Button } from "~/components/ui/button.tsx"; import { InternalLink } from "~/components/InternalLink.tsx"; import { Timestamp } from "~/components/Timestamp.tsx"; import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; +import IconLoader2 from "~icons/lucide/loader-2"; import { MentionHoverCardLayer, useMentionHoverCards, @@ -60,16 +63,18 @@ const SlugPageQueryDef = graphql` $handle: String! $idOrYear: String! $slug: String! + $language: Locale ) { articleByYearAndSlug( handle: $handle idOrYear: $idOrYear slug: $slug ) { - ...Slug_head - ...Slug_body + ...Slug_head @arguments(language: $language) + ...Slug_body @arguments(language: $language) } viewer { + locales ...Slug_viewer } } @@ -80,7 +85,7 @@ const loadPageQuery = query( loadQuery( useRelayEnvironment()(), SlugPageQueryDef, - { handle, idOrYear, slug }, + { handle, idOrYear, slug, language: null }, ), "loadArticlePageQuery", ); @@ -109,6 +114,7 @@ export default function ArticlePage() { )} @@ -118,26 +124,47 @@ export default function ArticlePage() { ); } +export { ArticleBody, ArticleMetaHead }; +// `ArticleTranslationPlaceholder` is also exported above (`export function ...`). + interface ArticleMetaHeadProps { $article: Slug_head$key; + /** + * Language tag to append to the article URL when computing the + * canonical/og:url. Pass it on the `[lang]` route; omit on the index + * route so the canonical points at the article's bare URL. + */ + canonicalLanguage?: string; } function ArticleMetaHead(props: ArticleMetaHeadProps) { const { t } = useLingui(); const article = createFragment( graphql` - fragment Slug_head on Article { + fragment Slug_head on Article + @argumentDefinitions( + language: { type: "Locale" } + includeBeingTranslated: { type: "Boolean", defaultValue: false } + ) + { actor { handle name rawName username } - contents { + contents( + language: $language + includeBeingTranslated: $includeBeingTranslated + ) { title summary language } + allContents: contents(includeBeingTranslated: true) { + language + beingTranslated + } language iri url @@ -154,14 +181,58 @@ function ArticleMetaHead(props: ArticleMetaHeadProps) { return ( {(article) => { - const content = () => article().contents?.[0]; + // The bare slug route doesn't pass a `language` argument to + // `Slug_head`, so `contents` returns every completed row in + // whatever order the resolver picks; `contents[0]` is then + // an arbitrary translation rather than the article's source + // text. Find the row whose language matches the article's + // own `language` (the canonical "original") first, and only + // fall back to `contents[0]` if the original isn't in the + // returned set (e.g., the `[lang]` route filtered to a + // specific translation). + const content = () => { + const c = article().contents; + if (c == null) return undefined; + return c.find((entry) => entry.language === article().language) ?? + c[0]; + }; const title = () => content()?.title ?? ""; const description = () => content()?.summary ?? ""; + const currentLanguage = () => + content()?.language ?? article().language ?? undefined; + const canonicalUrl = () => { + const articleUrl = article().url; + if (articleUrl == null) return null; + if (props.canonicalLanguage == null) return articleUrl; + try { + const u = new URL(articleUrl); + // Strip any trailing slashes (more than one is unlikely + // but possible if the upstream URL ever changes), then + // append the language as a new path segment. The + // language tag is `encodeURIComponent`-d to be defensive + // against future tags that might contain reserved + // characters; a normalized BCP 47 tag is a no-op here. + u.pathname = `${u.pathname.replace(/\/+$/, "")}/${ + encodeURIComponent(props.canonicalLanguage) + }`; + return u.toString(); + } catch { + return null; + } + }; return ( <> {t`${article().actor.rawName}: ${title()}`} + + {(href) => ( + <> + + + + )} + @@ -201,9 +272,30 @@ function ArticleMetaHead(props: ArticleMetaHeadProps) { )} - - {(language) => } + + {(language) => ( + + )} + !c.beingTranslated && c.language !== currentLanguage(), + )} + > + {(c) => ( + + )} + ; rel="alternate"; type="application/activity+json"`} @@ -233,6 +325,7 @@ function articleOgImageUrls( interface ArticleBodyProps { $article: Slug_body$key; $viewer?: Slug_viewer$key; + viewerLocales?: readonly string[] | null; } function ArticleBody(props: ArticleBodyProps) { @@ -240,14 +333,24 @@ function ArticleBody(props: ArticleBodyProps) { const mentionState = useMentionHoverCards(proseRef); const article = createFragment( graphql` - fragment Slug_body on Article { - contents { + fragment Slug_body on Article + @argumentDefinitions( + language: { type: "Locale" } + includeBeingTranslated: { type: "Boolean", defaultValue: false } + ) + { + contents( + language: $language + includeBeingTranslated: $includeBeingTranslated + ) { title content toc language + originalLanguage beingTranslated } + language tags ...PostControls_post ...Slug_articleHeader @@ -261,7 +364,18 @@ function ArticleBody(props: ArticleBodyProps) { return ( {(article) => { - const content = () => article().contents?.[0]; + // Same deterministic picker as `ArticleMetaHead` uses: prefer + // the row whose language matches the article's own + // (canonical original) over an arbitrary `contents[0]`, + // falling back to the first row if the original isn't in the + // returned set (the `[lang]` route filters to one specific + // translation). + const content = () => { + const c = article().contents; + if (c == null) return undefined; + return c.find((entry) => entry.language === article().language) ?? + c[0]; + }; const toc = () => (content()?.toc ?? []) as Toc[]; return ( @@ -271,14 +385,24 @@ function ArticleBody(props: ArticleBodyProps) {