diff --git a/package-lock.json b/package-lock.json
index 70a787063..d4bd270e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18515,4 +18515,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/components/app/chat/ChatView.svelte b/src/components/app/chat/ChatView.svelte
index 74814e260..81ec3917f 100644
--- a/src/components/app/chat/ChatView.svelte
+++ b/src/components/app/chat/ChatView.svelte
@@ -1,8 +1,12 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/settings/LocaleSearch.svelte b/src/components/settings/LocaleSearch.svelte
index fde5a9bcc..8cf6030b4 100644
--- a/src/components/settings/LocaleSearch.svelte
+++ b/src/components/settings/LocaleSearch.svelte
@@ -1,7 +1,7 @@
diff --git a/src/db/chats/ChatDatabase.svelte.ts b/src/db/chats/ChatDatabase.svelte.ts
index 37b641213..b51d208c7 100644
--- a/src/db/chats/ChatDatabase.svelte.ts
+++ b/src/db/chats/ChatDatabase.svelte.ts
@@ -10,9 +10,10 @@ import {
import { Domain } from '@db/Domains';
import SaveTracker from '@db/SaveTracker.svelte';
import { firestore } from '@db/firebase';
-import isQuotaError from '@db/isQuotaError';
import type Gallery from '@db/galleries/Gallery';
import HowTo from '@db/howtos/HowToDatabase.svelte';
+import isQuotaError from '@db/isQuotaError';
+import { notifications } from '@db/notifications.svelte';
import type Project from '@db/projects/Project';
import supportsIndexedDB from '@db/supportsIndexedDB';
import deferToIdle from '@util/deferToIdle';
@@ -22,6 +23,7 @@ import {
arrayRemove,
arrayUnion,
collection,
+ deleteField,
deleteDoc,
doc,
getDoc,
@@ -36,7 +38,6 @@ import {
import { SvelteMap } from 'svelte/reactivity';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
-import { notifications } from '@db/notifications.svelte';
////////////////////////////////
// SCHEMAS
@@ -68,12 +69,26 @@ const MessageSchemaV2 = MessageSchemaV1.extend(
}).shape,
);
-const MessageSchema = MessageSchemaV2;
-export const MessageSchemaLatestVersion = 2;
+const MessageSchemaV3 = MessageSchemaV2.extend(
+ z.object({
+ /** The language the creator tagged this message with (a Wordplay
+ * language code, e.g. "en"). Optional because messages created before
+ * language tagging existed have no value; new messages set it from the
+ * creator's chosen language. */
+ language: z.string().optional(),
+ /** Cached translations of this message's text, keyed by target
+ * Wordplay language code (e.g. "es"). */
+ translations: z.record(z.string(), z.string()).optional(),
+ }).shape,
+);
+
+const MessageSchema = MessageSchemaV3;
-export type SerializedMessage = z.infer;
+export type SerializedMessage = z.infer;
export type SerializedMessageUnknownVersion =
- z.infer | SerializedMessage;
+ | z.infer
+ | z.infer
+ | SerializedMessage;
const ChatSchemaV1 = z.object({
// The version of the schema
@@ -100,13 +115,25 @@ const ChatSchemaV2 = ChatSchemaV1.omit({ v: true }).extend(
z.object({ v: z.literal(2), type: z.enum(['project', 'howto']) }).shape,
);
+/** v3 adds an optional primary-language locale string (e.g. "en-US") to the chat
+ * document itself. Untagged messages fall back to this when a source language is
+ * needed for translation, instead of the viewer's UI locale. */
+const ChatSchemaV3 = ChatSchemaV2.omit({ v: true }).extend(
+ z.object({
+ v: z.literal(3),
+ language: z.string().optional(),
+ }).shape,
+);
+
/** The latest version of the chat schema */
-const ChatSchema = ChatSchemaV2;
-const ChatSchemaLatestVersion = 2;
+const ChatSchema = ChatSchemaV3;
+const ChatSchemaLatestVersion = 3;
-export type SerializedChat = z.infer;
+export type SerializedChat = z.infer;
export type SerializedChatUnknownVersion =
- z.infer | SerializedChat;
+ | z.infer
+ | z.infer
+ | SerializedChat;
/** Chat upgrader */
export function upgradeChat(
@@ -115,6 +142,8 @@ export function upgradeChat(
switch (chat.v) {
case 1:
return upgradeChat({ ...chat, v: 2, type: 'project' });
+ case 2:
+ return upgradeChat({ ...chat, v: 3 });
case ChatSchemaLatestVersion:
return chat;
default:
@@ -126,10 +155,39 @@ export function upgradeChat(
// APIs
////////////////////////////////
-// We let a chat be at most 128KB, which is a lot of text, but since we have to pass the
-// whole document around each time, we need to cap it.
+// We let a chat's real message text be at most 128KB, which is a lot of
+// text, but since we have to pass the whole document around each time, we
+// need to cap it.
const MAX_CHAT_MESSAGES_BYTES = 131072;
+// Cached translations get their own, separate cap so they can never push a
+// real message out of the chat. When they exceed it, the oldest cached
+// translations are evicted first — before any message text is ever touched.
+const MAX_CHAT_TRANSLATIONS_BYTES = 131072;
+
+const messageTranslationsSize = (message: SerializedMessage) =>
+ Object.values(message.translations ?? {}).reduce(
+ (sum, text) => sum + text.length,
+ 0,
+ );
+
+const trimChatTranslations = (messages: SerializedMessage[]) => {
+ let translationsSize = messages.reduce(
+ (size, message) => size + messageTranslationsSize(message),
+ 0,
+ );
+ if (translationsSize <= MAX_CHAT_TRANSLATIONS_BYTES) return messages;
+
+ return messages.map((message) => {
+ if (translationsSize <= MAX_CHAT_TRANSLATIONS_BYTES) return message;
+ const size = messageTranslationsSize(message);
+ if (size === 0) return message;
+ translationsSize -= size;
+ const { translations, ...withoutTranslations } = message;
+ return withoutTranslations;
+ });
+};
+
/** An immutable wrapper class for accessing and manipulating chat data */
export default class Chat {
/** The data of the chat. */
@@ -138,24 +196,29 @@ export default class Chat {
constructor(data: SerializedChat) {
this.data = data;
- // We automatically trim the chat messages if they exceed the maximum size.
- // We estimate about 2 bytes per codepoint, even though some are 1 and some are 4.
- const size = data.messages.reduce(
+ // We automatically trim the oldest chat messages if their text exceeds
+ // the maximum size. We estimate about 2 bytes per codepoint, even
+ // though some are 1 and some are 4.
+ const textSize = data.messages.reduce(
(size, message) => size + (message.text?.length ?? 0),
0,
);
-
- // If the chat is too big, keep trimming old messages until it fits.
- if (size > MAX_CHAT_MESSAGES_BYTES) {
- let newSize = size;
- let messages = data.messages;
+ let messages = data.messages;
+ if (textSize > MAX_CHAT_MESSAGES_BYTES) {
+ let newSize = textSize;
+ messages = [...messages];
while (newSize > MAX_CHAT_MESSAGES_BYTES) {
const message = messages.shift();
if (message === undefined) break;
newSize -= message.text?.length ?? 0;
}
- this.data = { ...data, messages: messages };
}
+
+ // Cached translations are disposable: if they exceed their own budget,
+ // drop the oldest ones until they fit, never touching message text.
+ messages = trimChatTranslations(messages);
+
+ if (messages !== data.messages) this.data = { ...data, messages };
}
getProjectID() {
@@ -240,6 +303,24 @@ export default class Chat {
return new Chat({ ...this.data, messages: mergedMessages });
}
+ /** Cache translations for several messages into the given language. */
+ withMessagesTranslations(
+ translations: Map,
+ language: string,
+ ) {
+ return new Chat({
+ ...this.data,
+ messages: this.data.messages.map((m) => {
+ const text = translations.get(m.id);
+ if (text === undefined) return m;
+ return {
+ ...m,
+ translations: { ...m.translations, [language]: text },
+ };
+ }),
+ });
+ }
+
/** Keep the message, but replace it's text with nothing. */
withoutMessage(message: SerializedMessage) {
return new Chat({
@@ -281,6 +362,14 @@ export default class Chat {
return this.data.type;
}
+ /** The primary locale string of this chat (e.g. "en-US"), or undefined for
+ * pre-existing chats created before this field existed. Used as the
+ * source-language fallback when translating messages with no per-message
+ * language tag. */
+ getLanguage(): string | undefined {
+ return this.data.language;
+ }
+
getData() {
return { ...this.data };
}
@@ -542,7 +631,7 @@ export class ChatDatabase {
private async modifyChatMessage(
chatID: string,
messageID: string,
- transform: (m: SerializedMessage) => SerializedMessage,
+ transform: (m: SerializedMessage) => Record,
) {
if (firestore === undefined) return;
const chatRef = doc(firestore, ChatsCollection, chatID);
@@ -592,6 +681,9 @@ export class ChatDatabase {
chat.getProjectID(),
chat.withReportedMessage(message, reporterID),
);
+ // Preserve cached translations: the message text is unchanged, only its
+ // moderation flag is being set. Wiping translations here would force every
+ // viewer to re-translate a message whose content hasn't changed.
await this.modifyChatMessage(chat.getProjectID(), message.id, (m) => ({
...m,
moderation: 'pending',
@@ -614,6 +706,7 @@ export class ChatDatabase {
...m,
moderation: action,
moderator: moderatorID,
+ ...(action === 'approved' ? {} : { translations: deleteField() }),
}));
}
@@ -623,9 +716,55 @@ export class ChatDatabase {
await this.modifyChatMessage(chat.getProjectID(), message.id, (m) => ({
...m,
text: null,
+ translations: deleteField(),
}));
}
+ /** Cache translations for several messages into the same language in one
+ * transaction, so future viewers reuse them without re-calling the
+ * translation service. */
+ async saveMessageTranslations(
+ chat: Chat,
+ language: string,
+ translations: Map,
+ ) {
+ if (translations.size === 0) return;
+ this.chats.set(
+ chat.getProjectID(),
+ chat.withMessagesTranslations(translations, language),
+ );
+ await this.modifyChatMessages(chat.getProjectID(), (m) => {
+ const text = translations.get(m.id);
+ if (text === undefined) return m;
+ return {
+ ...m,
+ translations: { ...m.translations, [language]: text },
+ };
+ });
+ }
+
+ private async modifyChatMessages(
+ chatID: string,
+ transform: (m: SerializedMessage) => SerializedMessage,
+ ) {
+ if (firestore === undefined) return;
+ const chatRef = doc(firestore, ChatsCollection, chatID);
+ await this.trackSave(
+ chatID,
+ runTransaction(firestore, async (tx) => {
+ const snap = await tx.get(chatRef);
+ if (!snap.exists()) return;
+ const current = upgradeChat(
+ snap.data() as SerializedChatUnknownVersion,
+ );
+ const messages = trimChatTranslations(
+ current.messages.map(transform),
+ );
+ tx.update(chatRef, { messages });
+ }),
+ );
+ }
+
/** Drop a chat from in-memory state and clear its save tracking + durable
* dirty row. Does NOT delete the Firestore doc or the cached row — callers
* handle those (the cloud listener owns cache eviction). Shared by the
@@ -708,18 +847,20 @@ export class ChatDatabase {
async addChat(
project: Project,
gallery: Gallery | undefined,
+ language?: string,
): Promise {
if (firestore === undefined) return undefined;
if (project.getOwner() === null) return undefined;
const newChat: SerializedChat = {
- v: 2,
+ v: 3,
project: project.getID(),
messages: [],
// Everyone contributing is eligible to see and participate in the chat.
participants: Array.from(this.getAllParticipants(project, gallery)),
unread: [],
type: 'project',
+ ...(language !== undefined ? { language } : {}),
};
return this.createChat(newChat, () =>
@@ -727,12 +868,12 @@ export class ChatDatabase {
);
}
- async addChatToHowTo(howTo: HowTo, gallery: Gallery | undefined) {
+ async addChatToHowTo(howTo: HowTo, gallery: Gallery | undefined, language?: string) {
if (firestore === undefined) return undefined;
if (howTo.getCreator() === null) return undefined;
const newChat: SerializedChat = {
- v: 2,
+ v: 3,
project: howTo.getHowToId(),
messages: [],
// All gallery curators, creators, viewers can access the chat
@@ -748,6 +889,7 @@ export class ChatDatabase {
),
unread: [],
type: 'howto',
+ ...(language !== undefined ? { language } : {}),
};
return this.createChat(newChat, () =>
@@ -926,6 +1068,7 @@ export class ChatDatabase {
async addMessage(
chat: Chat,
message: string,
+ language?: string,
): Promise {
const user = this.db.getUser()?.uid;
if (user === undefined) return;
@@ -935,6 +1078,9 @@ export class ChatDatabase {
text: message,
time: Date.now(),
creator: user,
+ // Only tag a language when the creator chose one; existing messages
+ // and untagged sends leave the optional field unset.
+ ...(language !== undefined ? { language } : {}),
};
// Optimistic local update so the sender sees their message immediately.
diff --git a/src/db/chats/ChatDatabase.test.ts b/src/db/chats/ChatDatabase.test.ts
index 85a1c895c..0623ce3c3 100644
--- a/src/db/chats/ChatDatabase.test.ts
+++ b/src/db/chats/ChatDatabase.test.ts
@@ -27,6 +27,7 @@ vi.mock('firebase/firestore', () => ({
setDoc: vi.fn(async () => {}),
updateDoc: vi.fn(async () => {}),
deleteDoc: vi.fn(async () => {}),
+ deleteField: vi.fn(() => ({ _op: 'deleteField' })),
runTransaction: vi.fn(
async (
_firestore: unknown,
@@ -74,16 +75,15 @@ vi.mock('@db/Database', () => ({
Projects: {},
}));
-import { ChatDatabase, upgradeChat } from './ChatDatabase.svelte';
-import Chat from './ChatDatabase.svelte';
-import { updateDoc } from 'firebase/firestore';
+import { deleteField, updateDoc } from 'firebase/firestore';
+import Chat, { ChatDatabase, upgradeChat } from './ChatDatabase.svelte';
function makeChat(
overrides: Partial = {},
messages: SerializedMessage[] = [],
): Chat {
return new Chat({
- v: 2,
+ v: 3,
project: 'project-1',
participants: ['user-1', 'user-2', 'user-3'],
messages,
@@ -145,6 +145,35 @@ describe('ChatDatabase granular message operations', () => {
// Everyone except the sender is marked unread.
expect([...d.unread].sort()).toEqual(['user-2', 'user-3']);
});
+
+ it('tags the message with the chosen language when provided', async () => {
+ const chat = makeChat();
+
+ await db.addMessage(chat, 'hola', 'es');
+
+ const [, data] = (updateDoc as unknown as ReturnType)
+ .mock.calls[0];
+ const { elements } = (data as { messages: unknown }).messages as {
+ elements: SerializedMessage[];
+ };
+ expect(elements[0]).toMatchObject({
+ text: 'hola',
+ language: 'es',
+ });
+ });
+
+ it('leaves the language field unset when no language is chosen', async () => {
+ const chat = makeChat();
+
+ await db.addMessage(chat, 'hello world');
+
+ const [, data] = (updateDoc as unknown as ReturnType)
+ .mock.calls[0];
+ const { elements } = (data as { messages: unknown }).messages as {
+ elements: SerializedMessage[];
+ };
+ expect(elements[0].language).toBeUndefined();
+ });
});
describe('markChatRead', () => {
@@ -219,6 +248,8 @@ describe('ChatDatabase granular message operations', () => {
moderation: 'pending',
reporter: 'user-1',
});
+
+ expect(data.messages[0].translations).toBeUndefined();
});
});
@@ -259,16 +290,153 @@ describe('ChatDatabase granular message operations', () => {
moderation: 'removed',
moderator: 'mod-uid',
});
+ expect(data.messages[0].translations).toEqual(deleteField());
+ });
+ });
+
+ describe('saveMessageTranslations', () => {
+ it('writes several cached translations in one transaction', async () => {
+ const existingMessages: SerializedMessage[] = [
+ {
+ id: 'm1',
+ time: 1000,
+ creator: 'user-1',
+ text: 'hello',
+ },
+ {
+ id: 'm2',
+ time: 1001,
+ creator: 'user-2',
+ text: 'world',
+ },
+ ];
+ transactionReadSnap = {
+ exists: () => true,
+ data: () => ({
+ v: 2,
+ project: 'project-1',
+ participants: ['user-1', 'user-2'],
+ messages: existingMessages,
+ unread: [],
+ type: 'project',
+ }),
+ };
+
+ await db.saveMessageTranslations(
+ makeChat({}, existingMessages),
+ 'es',
+ new Map([
+ ['m1', 'hola'],
+ ['m2', 'mundo'],
+ ]),
+ );
+
+ expect(lastTransactionOps).toHaveLength(1);
+ const data = lastTransactionOps[0].data as {
+ messages: SerializedMessage[];
+ };
+ expect(data.messages).toHaveLength(2);
+ expect(data.messages[0]).toMatchObject({
+ id: 'm1',
+ translations: { es: 'hola' },
+ });
+ expect(data.messages[1]).toMatchObject({
+ id: 'm2',
+ translations: { es: 'mundo' },
+ });
+ });
+
+ it('trims cached translations before persisting when they exceed the budget', async () => {
+ const existingMessages: SerializedMessage[] = [
+ {
+ id: 'm1',
+ time: 1000,
+ creator: 'user-1',
+ text: 'hello',
+ translations: { fr: 'x'.repeat(131072) },
+ },
+ {
+ id: 'm2',
+ time: 1001,
+ creator: 'user-2',
+ text: 'world',
+ },
+ ];
+ transactionReadSnap = {
+ exists: () => true,
+ data: () => ({
+ v: 2,
+ project: 'project-1',
+ participants: ['user-1', 'user-2'],
+ messages: existingMessages,
+ unread: [],
+ type: 'project',
+ }),
+ };
+
+ await db.saveMessageTranslations(
+ makeChat({}, existingMessages),
+ 'es',
+ new Map([['m2', 'hola']]),
+ );
+
+ const data = lastTransactionOps[0].data as {
+ messages: SerializedMessage[];
+ };
+ expect(data.messages).toHaveLength(2);
+ expect(data.messages[0].translations).toBeUndefined();
+ expect(data.messages[1]).toMatchObject({
+ id: 'm2',
+ translations: { es: 'hola' },
+ });
+ });
+
+ it('merges new translations without dropping existing ones', async () => {
+ const existingMessages: SerializedMessage[] = [
+ {
+ id: 'm1',
+ time: 1000,
+ creator: 'user-1',
+ text: 'hello',
+ translations: { fr: 'bonjour' },
+ },
+ ];
+ transactionReadSnap = {
+ exists: () => true,
+ data: () => ({
+ v: 2,
+ project: 'project-1',
+ participants: ['user-1', 'user-2'],
+ messages: existingMessages,
+ unread: [],
+ type: 'project',
+ }),
+ };
+
+ await db.saveMessageTranslations(
+ makeChat({}, existingMessages),
+ 'es',
+ new Map([['m1', 'hola']]),
+ );
+
+ const data = lastTransactionOps[0].data as {
+ messages: SerializedMessage[];
+ };
+ expect(data.messages[0]).toMatchObject({
+ id: 'm1',
+ translations: { fr: 'bonjour', es: 'hola' },
+ });
});
});
describe('deleteMessage', () => {
- it('uses a transaction that nulls the message text in-place', async () => {
+ it('uses a transaction that nulls the message text in-place and clears translations', async () => {
const existingMessage: SerializedMessage = {
id: 'm1',
time: 1000,
creator: 'user-1',
text: 'oops',
+ translations: { es: 'ups' },
};
transactionReadSnap = {
exists: () => true,
@@ -294,17 +462,18 @@ describe('ChatDatabase granular message operations', () => {
id: 'm1',
text: null,
});
+ expect(data.messages[0].translations).toEqual(deleteField());
});
});
});
/**
* Upgrade-on-load coverage for chats. Old chat docs are upgraded when a snapshot
- * arrives (upgradeChat), so a regression silently corrupts every pre-v2 chat on
- * load. v1 → v2 adds the `type` discriminator (project vs how-to).
+ * arrives (upgradeChat), so a regression silently corrupts every pre-v2/v3 chat on
+ * load. v1 → v2 adds the `type` discriminator; v2 → v3 adds an optional `language`.
*/
describe('upgradeChat (upgrade-on-load)', () => {
- it('upgrades a v1 doc to v2, defaulting type to project', () => {
+ it('upgrades a v1 doc to v3, defaulting type to project and language to undefined', () => {
const v1 = {
v: 1 as const,
project: 'p1',
@@ -313,8 +482,9 @@ describe('upgradeChat (upgrade-on-load)', () => {
unread: ['u2'],
};
const upgraded = upgradeChat(v1);
- expect(upgraded.v).toBe(2);
+ expect(upgraded.v).toBe(3);
expect(upgraded.type).toBe('project');
+ expect(upgraded.language).toBeUndefined();
// v1 user data is preserved across the upgrade.
expect(upgraded.project).toBe('p1');
expect(upgraded.participants).toEqual(['u1', 'u2']);
@@ -323,16 +493,33 @@ describe('upgradeChat (upgrade-on-load)', () => {
expect(upgraded.unread).toEqual(['u2']);
});
- it('an already-latest v2 doc upgrades to itself', () => {
- const v2: SerializedChat = {
- v: 2,
+ it('upgrades a v2 doc to v3, preserving all fields', () => {
+ const v2 = {
+ v: 2 as const,
project: 'p1',
participants: ['u1'],
messages: [],
unread: [],
- type: 'howto',
+ type: 'howto' as const,
};
- expect(upgradeChat(v2)).toEqual(v2);
+ const upgraded = upgradeChat(v2);
+ expect(upgraded.v).toBe(3);
+ expect(upgraded.type).toBe('howto');
+ expect(upgraded.language).toBeUndefined();
+ expect(upgraded.project).toBe('p1');
+ });
+
+ it('a v3 doc with a language field round-trips unchanged', () => {
+ const v3: SerializedChat = {
+ v: 3,
+ project: 'p1',
+ participants: ['u1'],
+ messages: [],
+ unread: [],
+ type: 'project',
+ language: 'en-US',
+ };
+ expect(upgradeChat(v3)).toEqual(v3);
});
it('throws on an unknown version', () => {
@@ -340,3 +527,109 @@ describe('upgradeChat (upgrade-on-load)', () => {
expect(() => upgradeChat({ v: 999, project: 'p1' })).toThrow();
});
});
+
+// ---------------------------------------------------------------------------
+// Chat.withMessagesTranslations
+// ---------------------------------------------------------------------------
+
+describe('Chat.withMessagesTranslations', () => {
+ it('adds translations to matched messages and leaves others unchanged', () => {
+ const chat = makeChat({}, [
+ { id: 'm1', time: 1, creator: 'user-1', text: 'hello' },
+ { id: 'm2', time: 2, creator: 'user-2', text: 'world' },
+ ]);
+ const updated = chat.withMessagesTranslations(
+ new Map([['m1', 'hola']]),
+ 'es-MX',
+ );
+ const msgs = updated.getMessages();
+ expect(msgs[0].translations).toEqual({ 'es-MX': 'hola' });
+ // m2 had no translation entry — must be untouched.
+ expect(msgs[1].translations).toBeUndefined();
+ });
+
+ it('merges with existing translations without clobbering them', () => {
+ const chat = makeChat({}, [
+ {
+ id: 'm1',
+ time: 1,
+ creator: 'user-1',
+ text: 'hello',
+ translations: { 'fr-FR': 'bonjour' },
+ },
+ ]);
+ const updated = chat.withMessagesTranslations(
+ new Map([['m1', 'hola']]),
+ 'es-MX',
+ );
+ expect(updated.getMessages()[0].translations).toEqual({
+ 'fr-FR': 'bonjour',
+ 'es-MX': 'hola',
+ });
+ });
+
+ it('overwrites an existing entry for the same language', () => {
+ const chat = makeChat({}, [
+ {
+ id: 'm1',
+ time: 1,
+ creator: 'user-1',
+ text: 'hello',
+ translations: { 'es-MX': 'old' },
+ },
+ ]);
+ const updated = chat.withMessagesTranslations(
+ new Map([['m1', 'nueva']]),
+ 'es-MX',
+ );
+ expect(updated.getMessages()[0].translations?.['es-MX']).toBe('nueva');
+ });
+
+ it('is non-mutating — the original chat is unchanged', () => {
+ const chat = makeChat({}, [
+ { id: 'm1', time: 1, creator: 'user-1', text: 'hello' },
+ ]);
+ chat.withMessagesTranslations(new Map([['m1', 'hola']]), 'es-MX');
+ expect(chat.getMessages()[0].translations).toBeUndefined();
+ });
+
+ it('returns an equal chat when the translations map is empty', () => {
+ const chat = makeChat({}, [
+ { id: 'm1', time: 1, creator: 'user-1', text: 'hello' },
+ ]);
+ const updated = chat.withMessagesTranslations(new Map(), 'es-MX');
+ expect(updated.getMessages()[0].translations).toBeUndefined();
+ });
+
+ it('ignores ids that do not match any message', () => {
+ const chat = makeChat({}, [
+ { id: 'm1', time: 1, creator: 'user-1', text: 'hello' },
+ ]);
+ const updated = chat.withMessagesTranslations(
+ new Map([['no-such-id', 'hola']]),
+ 'es-MX',
+ );
+ expect(updated.getMessages()[0].translations).toBeUndefined();
+ });
+
+ it('evicts oversized cached translations instead of dropping the message', () => {
+ const oversizedChat = makeChat(
+ {},
+ [
+ {
+ id: 'm1',
+ time: 1,
+ creator: 'user-1',
+ text: '',
+ translations: {
+ es: 'x'.repeat(131072 + 1),
+ },
+ },
+ ],
+ );
+
+ const messages = oversizedChat.getMessages();
+ expect(messages).toHaveLength(1);
+ expect(messages[0].translations).toBeUndefined();
+ });
+});
diff --git a/src/db/getFirebaseTranslator.ts b/src/db/getFirebaseTranslator.ts
new file mode 100644
index 000000000..577c18071
--- /dev/null
+++ b/src/db/getFirebaseTranslator.ts
@@ -0,0 +1,33 @@
+import { localeToString } from '@locale/Locale';
+import type { RawTranslator } from '@db/translateMarkup';
+import type { Functions } from 'firebase/functions';
+import type {
+ GetLLMTranslationsInputs,
+ GetLLMTranslationsOutput,
+} from 'shared-types';
+
+/**
+ * Adapt the Firebase `getLLMTranslations` callable (Claude) into a backend-
+ * agnostic [RawTranslator](src/db/translateMarkup.ts). Shared by project
+ * translation and chat message translation so both hit the same callable with
+ * the same request shape.
+ */
+export default function getFirebaseTranslator(
+ functions: Functions,
+): RawTranslator {
+ return async (texts, from, to, context) => {
+ const { httpsCallable } = await import('firebase/functions');
+ const call = httpsCallable<
+ GetLLMTranslationsInputs,
+ GetLLMTranslationsOutput
+ >(functions, 'getLLMTranslations');
+ return (
+ await call({
+ from: localeToString(from),
+ to: localeToString(to),
+ texts,
+ ...(context ? { projectContext: context } : {}),
+ })
+ ).data;
+ };
+}
diff --git a/src/db/projects/translate.ts b/src/db/projects/translate.ts
index 272f214a0..a99474a77 100644
--- a/src/db/projects/translate.ts
+++ b/src/db/projects/translate.ts
@@ -2,10 +2,7 @@ import { Locales } from '@db/Database';
import type Locale from '@locale/Locale';
import { localeToString } from '@locale/Locale';
import type { Functions } from 'firebase/functions';
-import type {
- GetLLMTranslationsInputs,
- GetLLMTranslationsOutput,
-} from 'shared-types';
+import getFirebaseTranslator from '@db/getFirebaseTranslator';
import type Project from '@db/projects/Project';
import translateProjectContent from './translateProjectContent';
@@ -31,21 +28,7 @@ export default async function translateProject(
project,
sourceLocale,
targetLocale,
- async (texts, from, to, context) => {
- const { httpsCallable } = await import('firebase/functions');
- const getLLMTranslations = httpsCallable<
- GetLLMTranslationsInputs,
- GetLLMTranslationsOutput
- >(functions, 'getLLMTranslations');
- return (
- await getLLMTranslations({
- from: localeToString(from),
- to: localeToString(to),
- texts,
- ...(context ? { projectContext: context } : {}),
- })
- ).data;
- },
+ getFirebaseTranslator(functions),
targetLocaleText ?? undefined,
);
}
diff --git a/src/db/translateMarkup.test.ts b/src/db/translateMarkup.test.ts
index 0c938494a..121d4beca 100644
--- a/src/db/translateMarkup.test.ts
+++ b/src/db/translateMarkup.test.ts
@@ -45,3 +45,103 @@ test('translateMarkupText returns null when the translator fails', async () => {
const failing: RawTranslator = async () => null;
expect(await translateMarkupText('hello', en, es, failing)).toBeNull();
});
+
+import { translateMarkupTexts, type MarkupTranslationInput } from './translateMarkup';
+
+// ---------------------------------------------------------------------------
+// translateMarkupTexts
+// ---------------------------------------------------------------------------
+
+const fr = stringToLocale('fr-FR');
+
+test('translateMarkupTexts groups by source locale — single batch per language', async () => {
+ if (en === undefined || es === undefined) throw new Error('bad locale');
+ const calls: { texts: string[] }[] = [];
+ const spy: RawTranslator = async (texts) => {
+ calls.push({ texts });
+ return texts.map((t) => t + '_translated');
+ };
+ const inputs: MarkupTranslationInput[] = [
+ { id: 'a', text: 'hello', from: en },
+ { id: 'b', text: 'world', from: en },
+ ];
+ const { translated, failed } = await translateMarkupTexts(inputs, es, spy);
+ // Both en-US strings go in one call, not two.
+ expect(calls).toHaveLength(1);
+ expect(calls[0].texts).toHaveLength(2);
+ expect(translated.size).toBe(2);
+ expect(failed.size).toBe(0);
+});
+
+test('translateMarkupTexts sends separate batches for different source locales', async () => {
+ if (en === undefined || es === undefined || fr === undefined)
+ throw new Error('bad locale');
+ const calls: string[][] = [];
+ const spy: RawTranslator = async (texts) => {
+ calls.push(texts);
+ return texts.map((t) => t + '_translated');
+ };
+ const inputs: MarkupTranslationInput[] = [
+ { id: 'a', text: 'hello', from: en },
+ { id: 'b', text: 'bonjour', from: fr },
+ ];
+ await translateMarkupTexts(inputs, es, spy);
+ // en-US and fr-FR are separate groups → two calls.
+ expect(calls).toHaveLength(2);
+});
+
+test('translateMarkupTexts isolates per-group failures — other groups still succeed', async () => {
+ if (en === undefined || es === undefined || fr === undefined)
+ throw new Error('bad locale');
+ const spy: RawTranslator = async (_texts, from) => {
+ // The French group fails; the English group succeeds.
+ if (from.language === 'fr') return null;
+ return _texts.map((t) => t + '_ok');
+ };
+ const inputs: MarkupTranslationInput[] = [
+ { id: 'en1', text: 'hello', from: en },
+ { id: 'fr1', text: 'bonjour', from: fr },
+ ];
+ const { translated, failed } = await translateMarkupTexts(inputs, es, spy);
+ expect(translated.has('en1')).toBe(true);
+ expect(failed.has('fr1')).toBe(true);
+ // The succeeded id must not appear in failed.
+ expect(failed.has('en1')).toBe(false);
+});
+
+test('translateMarkupTexts marks id failed when translator returns undefined for that entry', async () => {
+ if (en === undefined || es === undefined) throw new Error('bad locale');
+ const spy: RawTranslator = async (texts) =>
+ // Return undefined for the second entry.
+ texts.map((t, i) => (i === 1 ? undefined : t + '_ok'));
+ const inputs: MarkupTranslationInput[] = [
+ { id: 'a', text: 'one', from: en },
+ { id: 'b', text: 'two', from: en },
+ ];
+ const { translated, failed } = await translateMarkupTexts(inputs, es, spy);
+ expect(translated.has('a')).toBe(true);
+ expect(failed.has('b')).toBe(true);
+});
+
+test('translateMarkupTexts marks all ids in a throwing group as failed', async () => {
+ if (en === undefined || es === undefined) throw new Error('bad locale');
+ const spy: RawTranslator = async () => {
+ throw new Error('network failure');
+ };
+ const inputs: MarkupTranslationInput[] = [
+ { id: 'x', text: 'hello', from: en },
+ { id: 'y', text: 'world', from: en },
+ ];
+ const { translated, failed } = await translateMarkupTexts(inputs, es, spy);
+ expect(translated.size).toBe(0);
+ expect(failed).toContain('x');
+ expect(failed).toContain('y');
+});
+
+test('translateMarkupTexts returns empty maps for empty input', async () => {
+ if (es === undefined) throw new Error('bad locale');
+ const spy: RawTranslator = async (texts) => texts;
+ const { translated, failed } = await translateMarkupTexts([], es, spy);
+ expect(translated.size).toBe(0);
+ expect(failed.size).toBe(0);
+});
diff --git a/src/db/translateMarkup.ts b/src/db/translateMarkup.ts
index 7daaaf31b..d948f8b63 100644
--- a/src/db/translateMarkup.ts
+++ b/src/db/translateMarkup.ts
@@ -1,4 +1,5 @@
import type Locale from '@locale/Locale';
+import { localeToString } from '@locale/Locale';
import Markup from '@nodes/Markup';
import getPreferredSpaces from '@parser/getPreferredSpaces';
import { toMarkup } from '@parser/toMarkup';
@@ -89,6 +90,89 @@ export async function translateMarkupText(
return typeof translated === 'string' ? translated : null;
}
+/** One markup string to translate, tagged with a caller-chosen id (so the
+ * result can be correlated back) and its own source locale. */
+export type MarkupTranslationInput = {
+ id: string;
+ text: string;
+ from: Locale;
+};
+
+/** The outcome of a {@link translateMarkupTexts} pass: the translated text for
+ * every id that succeeded, and the set of ids whose batch failed. */
+export type MarkupTranslationResults = {
+ translated: Map;
+ failed: Set;
+};
+
+/**
+ * Translate many Wordplay markup strings into one target locale — the plural of
+ * {@link translateMarkupText}. Inputs are grouped by source locale so each
+ * language costs a single batched call instead of one round-trip per string,
+ * with embedded `\code\` preserved. A batch that errors (or returns a
+ * non-string for an entry) marks only its own ids failed, so one bad group
+ * doesn't fail the rest. Backend-agnostic via the injected {@link RawTranslator}.
+ */
+export async function translateMarkupTexts(
+ inputs: MarkupTranslationInput[],
+ to: Locale,
+ translate: RawTranslator,
+ context?: { names?: string[]; docs?: string[] },
+): Promise {
+ const translated = new Map();
+ const failed = new Set();
+
+ // Group by source locale so each language is one batched call.
+ const grouped = new Map<
+ string,
+ { from: Locale; ids: string[]; texts: string[] }
+ >();
+ for (const input of inputs) {
+ const key = localeToString(input.from);
+ const normalized = normalizeSoftBreaks(input.text);
+ const existing = grouped.get(key);
+ if (existing) {
+ existing.ids.push(input.id);
+ existing.texts.push(normalized);
+ } else {
+ grouped.set(key, {
+ from: input.from,
+ ids: [input.id],
+ texts: [normalized],
+ });
+ }
+ }
+
+ await Promise.all(
+ Array.from(grouped.values()).map(async (group) => {
+ try {
+ const result = await translate(
+ group.texts,
+ group.from,
+ to,
+ context,
+ );
+ if (result === null) {
+ for (const id of group.ids) failed.add(id);
+ return;
+ }
+ for (let i = 0; i < group.ids.length; i += 1) {
+ const value = result[i];
+ if (typeof value === 'string')
+ translated.set(group.ids[i], value);
+ else failed.add(group.ids[i]);
+ }
+ } catch (_) {
+ // This batch failed; mark its ids so callers can flag each one
+ // rather than failing the whole pass.
+ for (const id of group.ids) failed.add(id);
+ }
+ }),
+ );
+
+ return { translated, failed };
+}
+
/**
* Translate a Markup node (prose plus embedded `\code\`) into a translated Markup
* node, preserving code and reattaching renderable spaces. Returns `null` on
diff --git a/src/locale/LanguageCode.test.ts b/src/locale/LanguageCode.test.ts
index 5117c888a..1085a555f 100644
--- a/src/locale/LanguageCode.test.ts
+++ b/src/locale/LanguageCode.test.ts
@@ -1,4 +1,3 @@
-import { expect, test } from 'vitest';
import {
getCLDRCandidates,
GoogleTranslateCodeOverrides,
@@ -6,6 +5,7 @@ import {
Translatable,
TranslatableLocales,
} from '@locale/LanguageCode';
+import { expect, test } from 'vitest';
test('every translatable code is a real language', () => {
for (const code of Translatable)
@@ -20,6 +20,12 @@ test('TranslatableLocales only offers allowlisted languages', () => {
).toContain(locale.language);
});
+test('TranslatableLocales contains no duplicate locale tags', async () => {
+ const { localeToString } = await import('@locale/Locale');
+ const tags = TranslatableLocales.map((locale) => localeToString(locale));
+ expect(tags).toEqual([...new Set(tags)]);
+});
+
test('languages Google Translate does not support are not offered', () => {
// Verified absent from https://cloud.google.com/translate/docs/languages
// (2026-06-25). These previously slipped through the denylist and would
diff --git a/src/locale/UITexts.ts b/src/locale/UITexts.ts
index 73cbef5a8..1bb0d8193 100644
--- a/src/locale/UITexts.ts
+++ b/src/locale/UITexts.ts
@@ -973,6 +973,25 @@ type UITexts = {
collaborate: {
/** [plain] The ARIA label for the chat section */
label: string;
+ /** Controls for translating received messages into another language */
+ translate: {
+ /** [plain] Label for the control that translates received messages */
+ label: string;
+ /** [plain] The ARIA label for the message language selector */
+ language: string;
+ /** [plain] Button label and tip to clear the current translation target */
+ off: string;
+ /** [plain] Shown below the translate control when the whole chat couldn't be translated; $to is the target language name */
+ error: Template<['to']>;
+ /** [plain] Shown when exactly one message couldn't be translated; $sender is the sender's username */
+ messageError: Template<['sender']>;
+ /** [plain] Shown when multiple messages couldn't be translated; $count is the number of failed messages */
+ messageErrors: Template<['#count']>;
+ /** [plain] Translation direction label; $from is the source language name, $to is the target language name */
+ direction: Template<['from', 'to']>;
+ /** [plain] Announced to screen readers with the number of languages matching the search; $count is the match count */
+ results: Template<['#count']>;
+ };
/** The chat message input field */
field: {
/** The chat message input field */
@@ -1024,6 +1043,8 @@ type UITexts = {
empty: string;
/** [plain] A message was deleted */
deleted: string;
+ /** [plain] Shown when the user tries to send a message without tagging its language */
+ untaggedMessage: string;
};
/** Messages to explain the purpose of the chat to each kind of participant */
prompt: {
diff --git a/src/locale/en-US.json b/src/locale/en-US.json
index 5faab2e02..0597cb6c5 100644
--- a/src/locale/en-US.json
+++ b/src/locale/en-US.json
@@ -8222,6 +8222,16 @@
},
"collaborate": {
"label": "collaborate",
+ "translate": {
+ "label": "Translate messages to",
+ "language": "message language",
+ "off": "Stop translating",
+ "error": "Couldn't translate this chat to $to.",
+ "messageError": "A message from $sender couldn't be translated.",
+ "messageErrors": "$#count[$count message couldn't be translated.|$count messages couldn't be translated.]",
+ "direction": "$from → $to",
+ "results": "$#count[$count language matches|$count languages match]"
+ },
"role": {
"owner": "owner",
"collaborators": "collaborators (edit and chat)",
@@ -8266,7 +8276,8 @@
"unowned": "This project isn't stored online, so it can't have a chat or collaborators.",
"offline": "Unable to load this chat.",
"empty": "No messages.",
- "deleted": "This message was deleted."
+ "deleted": "This message was deleted.",
+ "untaggedMessage": "Tag your message with the language it was written in."
},
"prompt": {
"solo": "Chat with yourself or add...",
@@ -9338,6 +9349,7 @@
"• Your *settings*. This includes the languages you choose, your animation preferences, and your tutorial progress. Everything else is stored on your device only.",
"• Aggregate *activity*. We track logins and the pages you visit, but not in a way that can identify you, track you across the site, or track you across other sites. We use Google Analytics in 'consent denied' mode, which only gathers minimal non-identifiable information about page views, without storing cookies, or sending IP address information to Google. We use this aggregate information to help raise funding by reporting how much the platform is being used.",
"We don't store anything else. Our is public, so anyone can verify this, and report any unintended tracking.",
+ "When you use the translate feature in a project or gallery chat, the text of *other participants' messages* is sent to an AI translation service (currently Claude, by Anthropic) and the results are stored in the chat document that all participants share. This is the only context in which someone else's words are processed by an AI on your behalf. By using chat translation, you acknowledge this.",
"*You* own your data, not us. That means:",
"• You control who can access your projects. They are private by default, but you can share them with individuals, groups, or make them entirely public.",
"• You can fully delete any project or your own account at any time.",
diff --git a/src/locale/templateInputs.generated.ts b/src/locale/templateInputs.generated.ts
index d982fc281..e8236deec 100644
--- a/src/locale/templateInputs.generated.ts
+++ b/src/locale/templateInputs.generated.ts
@@ -607,6 +607,11 @@ export const DECLARED_INPUTS: Readonly> = {
'ui.annotations.cursorParent': ['node', 'type'],
'ui.annotations.nodeDescription': ['description'],
'ui.checkpoints.label.ago': ['amount', 'unit'],
+ 'ui.collaborate.translate.direction': ['from', 'to'],
+ 'ui.collaborate.translate.error': ['to'],
+ 'ui.collaborate.translate.messageError': ['sender'],
+ 'ui.collaborate.translate.messageErrors': ['#count'],
+ 'ui.collaborate.translate.results': ['#count'],
'ui.dialog.notifications.notification.howToChatHeader': ['title'],
'ui.dialog.notifications.notification.howToHeader': ['title'],
'ui.dialog.notifications.notification.moderationHeader': ['name'],
diff --git a/src/util/verify-locales/LocaleSchema.ts b/src/util/verify-locales/LocaleSchema.ts
index 3e9850aa5..bc81ab1b3 100644
--- a/src/util/verify-locales/LocaleSchema.ts
+++ b/src/util/verify-locales/LocaleSchema.ts
@@ -1,9 +1,9 @@
import type LocaleText from '@locale/LocaleText';
-import fs from 'fs';
-import path from 'path';
import { getObjectFromJSONFile } from '@util/verify-locales/getObjectFromJSONFile';
import Log from '@util/verify-locales/Log';
import Validator from '@util/verify-locales/Validator';
+import fs from 'fs';
+import path from 'path';
// Read in and compile the two schema so we can check files.
const LocaleSchema = JSON.parse(
@@ -30,4 +30,4 @@ export function getLocaleJSON(log: Log, locale: string): unknown | undefined {
export const DefaultLocale = getLocaleJSON(
new Log(false),
'en-US',
-) as LocaleText;
+) as LocaleText;
\ No newline at end of file
diff --git a/static/locales/ar-SA/ar-SA.json b/static/locales/ar-SA/ar-SA.json
index 61bda8caf..5abb50e89 100644
--- a/static/locales/ar-SA/ar-SA.json
+++ b/static/locales/ar-SA/ar-SA.json
@@ -8148,7 +8148,8 @@
"unowned": "$~هذا المشروع غير مخزن عبر الإنترنت، لذا لا يمكن أن يكون له دردشة أو متعاونون.",
"offline": "$~تعذر تحميل هذه الدردشة.",
"empty": "$~لا توجد رسائل.",
- "deleted": "$~تم حذف هذه الرسالة."
+ "deleted": "$~تم حذف هذه الرسالة.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~تحدث مع نفسك أو أضف...",
@@ -8190,6 +8191,16 @@
"commenters": "$~يستطيع /المعلقون/ *مناقشة* المشروع معك، لكن لا يمكنهم *تعديله*.",
"viewers": "$~يستطيع /المشاهدون/ *مشاهدة* المشروع و*الكود* الخاص به، لكن لا يمكنهم *مناقشته* أو *تعديله*.",
"restrict": "$~عندما يكون المشروع في *معرض*، يستطيع *كل من* يرى المعرض رؤية مشروعك بشكل افتراضي. مفتاح /التقييد/ *يخفيه* عن الجميع باستثنائك وأمناء المعرض."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/as-IN/as-IN.json b/static/locales/as-IN/as-IN.json
index a53478ee2..bc7569971 100644
--- a/static/locales/as-IN/as-IN.json
+++ b/static/locales/as-IN/as-IN.json
@@ -8161,7 +8161,8 @@
"unowned": "$~এই প্ৰকল্পটো অনলাইনত সংৰক্ষণ কৰা হোৱা নাই, গতিকে ইয়াত আড্ডা বা সহযোগী থাকিব নোৱাৰে।",
"offline": "$~এই আড্ডাটো লোড কৰিব পৰা নাই।",
"empty": "$~কোনো বাৰ্তা নাই।",
- "deleted": "$~এই বাৰ্তাটো মচি পেলোৱা হৈছিল।"
+ "deleted": "$~এই বাৰ্তাটো মচি পেলোৱা হৈছিল।",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~নিজৰ সৈতে আড্ডা বা যোগ কৰক...\n\n• এজন সহযোগী, যিয়ে সম্পাদনা আৰু আড্ডা দিব পাৰে\n\n• এজন মন্তব্যকাৰী, যিয়ে আড্ডা দিব পাৰে কিন্তু সম্পাদনা কৰিব নোৱাৰে\n\n• এজন দৰ্শক, যিয়ে আপোনাৰ প্ৰকল্প চাব পাৰে কিন্তু সম্পাদনা বা আড্ডা দিব নোৱাৰে",
@@ -8203,6 +8204,16 @@
"commenters": "$~/Commenters/ এ আপোনাৰ সৈতে প্ৰকল্পটোৰ বিষয়ে *আলোচনা* কৰিব পাৰে, কিন্তু তেওঁলোকে ইয়াক *সম্পাদনা* কৰিব নোৱাৰে।",
"viewers": "$~/Viewers/ এ প্ৰকল্প আৰু ইয়াৰ *কোড* *চাব* পাৰে, কিন্তু তেওঁলোকে ইয়াক *আলোচনা* বা *সম্পাদনা* কৰিব নোৱাৰে।",
"restrict": "$~যেতিয়া এটা প্ৰকল্প এটা *গেলেৰী*ত থাকে, অবিকল্পিতভাৱে *সকলোৱে* যিয়ে গেলেৰী চাব পাৰে আপোনাৰ প্ৰকল্পটো চাব পাৰে। /restrict/ টগলে ইয়াক আপোনাৰ আৰু গেলেৰী কিউৰেটৰসকলৰ বাহিৰে সকলোৰে পৰা *লুকুৱাই* দিয়ে।"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/bn-BD/bn-BD.json b/static/locales/bn-BD/bn-BD.json
index 5b70c0e44..aefe28399 100644
--- a/static/locales/bn-BD/bn-BD.json
+++ b/static/locales/bn-BD/bn-BD.json
@@ -8206,7 +8206,8 @@
"unowned": "$~এই প্রকল্পটি অনলাইনে সংরক্ষিত নেই, তাই এটির কোনো আড্ডা বা সহযোগী থাকতে পারে না।",
"offline": "$~এই আড্ডাটি লোড করা যাচ্ছে না।",
"empty": "$~কোনো বার্তা নেই।",
- "deleted": "$~এই বার্তাটি মুছে ফেলা হয়েছে।"
+ "deleted": "$~এই বার্তাটি মুছে ফেলা হয়েছে।",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~নিজের সাথে আড্ডা দাও বা যোগ করো...",
@@ -8233,6 +8234,16 @@
"commenters": "$~/মন্তব্যকারীরা/ তোমার সাথে প্রকল্পটি নিয়ে *আলোচনা* করতে পারে, কিন্তু তারা এটি *সম্পাদনা* করতে পারে না।",
"viewers": "$~/দর্শকরা/ প্রকল্প ও এর *কোড* *দেখতে* পারে, কিন্তু তারা এটি *আলোচনা* বা *সম্পাদনা* করতে পারে না।",
"restrict": "$~যখন একটি প্রকল্প একটি *গ্যালারিতে* থাকে, তখন ডিফল্টভাবে যে *সবাই* গ্যালারি দেখতে পারে সে তোমার প্রকল্প দেখতে পারে। /সীমাবদ্ধ/ টগলটি এটি তুমি ও গ্যালারি কিউরেটরদের ছাড়া সবার কাছ থেকে *লুকিয়ে* রাখে।"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "$?",
+ "error": "$?",
+ "messageError": "$?",
+ "messageErrors": "$?",
+ "direction": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/de-DE/de-DE.json b/static/locales/de-DE/de-DE.json
index 9ac46f1ef..b1c30ab0d 100644
--- a/static/locales/de-DE/de-DE.json
+++ b/static/locales/de-DE/de-DE.json
@@ -9005,7 +9005,8 @@
"unowned": "$~Dieses Projekt hat keinen Eigentümer, daher ist kein Chat möglich.",
"offline": "$~Dieser Chat konnte nicht geladen werden.",
"empty": "$~Keine Nachrichten.",
- "deleted": "$~Diese Nachricht wurde gelöscht."
+ "deleted": "$~Diese Nachricht wurde gelöscht.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Chatten Sie mit sich selbst oder fügen Sie einen Mitarbeiter hinzu, der bearbeiten und chatten kann.",
@@ -9047,6 +9048,16 @@
"commenters": "$~Die Kommentatoren können das Projekt mit Ihnen *diskutieren*, aber sie können es nicht *bearbeiten*.",
"viewers": "$~Betrachter können das Projekt und seinen Code ansehen, aber sie können ihn weder diskutieren noch bearbeiten.",
"restrict": "$~Wenn sich ein Projekt in einer Galerie befindet, kann standardmäßig jeder, der die Galerie sehen kann, auch Ihr Projekt sehen. Mit dem Schalter /restrict/ wird es für alle außer Ihnen und den Kuratoren der Galerie ausgeblendet."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/el-GR/el-GR.json b/static/locales/el-GR/el-GR.json
index 34c581f5f..3dd9078df 100644
--- a/static/locales/el-GR/el-GR.json
+++ b/static/locales/el-GR/el-GR.json
@@ -8173,7 +8173,8 @@
"unowned": "$~Αυτό το έργο δεν είναι αποθηκευμένο στο διαδίκτυο, επομένως δεν μπορεί να έχει συνομιλία ή συνεργάτες.",
"offline": "$~Δεν είναι δυνατή η φόρτωση αυτής της συνομιλίας.",
"empty": "$~Δεν υπάρχουν μηνύματα.",
- "deleted": "$~Αυτό το μήνυμα διαγράφηκε."
+ "deleted": "$~Αυτό το μήνυμα διαγράφηκε.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Συνομιλήστε με τον εαυτό σας ή προσθέστε...\n\n• έναν συνεργάτη, ο οποίος μπορεί να επεξεργάζεται και να συνομιλεί\n\n• έναν σχολιαστή, ο οποίος μπορεί να συνομιλεί αλλά δεν μπορεί να επεξεργάζεται\n\n• έναν θεατή, ο οποίος μπορεί να βλέπει το έργο σας αλλά δεν μπορεί να επεξεργάζεται ή να συνομιλεί",
@@ -8215,6 +8216,16 @@
"commenters": "$~Οι /σχολιαστές/ μπορούν να *συζητήσουν* το έργο μαζί σας, αλλά δεν μπορούν να το *επεξεργαστούν*.",
"viewers": "$~Οι /θεατές/ μπορούν να *δουν* το έργο και τον *κώδικά* του, αλλά δεν μπορούν να *συζητήσουν* ή να *το επεξεργαστούν*.",
"restrict": "$~Όταν ένα έργο βρίσκεται σε μια *γκαλερί*, από προεπιλογή *όλοι* όσοι μπορούν να δουν τη συλλογή μπορούν να δουν το δικό σας έργο. Η εναλλαγή /restrict/ το *κρύβει* από όλους εκτός από εσάς και τους επιμελητές της συλλογής."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/es-MX/es-MX.json b/static/locales/es-MX/es-MX.json
index ee23257af..b087d9353 100644
--- a/static/locales/es-MX/es-MX.json
+++ b/static/locales/es-MX/es-MX.json
@@ -9003,7 +9003,8 @@
"unowned": "$~Este proyecto no se guarda en línea, así que no puede tener chat ni colaboradores.",
"offline": "$~No se pudo cargar este chat.",
"empty": "$~Sin mensajes.",
- "deleted": "$~Este mensaje fue borrado."
+ "deleted": "$~Este mensaje fue borrado.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Chatea contigo o agrega...",
@@ -9045,6 +9046,16 @@
"commenters": "$~Los /comentaristas/ pueden *conversar* sobre el proyecto contigo, pero no pueden *editarlo*.",
"viewers": "$~Los /espectadores/ pueden *ver* el proyecto y su *código*, pero no pueden *conversar* ni *editarlo*.",
"restrict": "$~Cuando un proyecto está en una *galería*, de forma predeterminada *todos* los que pueden ver la galería pueden ver tu proyecto. El interruptor /limitar/ lo *oculta* de todos excepto de ti y de los curadores de la galería."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "$?",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$?",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/fr-FR/fr-FR.json b/static/locales/fr-FR/fr-FR.json
index 008b79a73..8cc4b1330 100644
--- a/static/locales/fr-FR/fr-FR.json
+++ b/static/locales/fr-FR/fr-FR.json
@@ -9012,7 +9012,8 @@
"unowned": "$~Ce projet n'a pas de propriétaire, il ne peut donc pas avoir de chat.",
"offline": "$~Impossible de charger ce chat.",
"empty": "$~Aucun message.",
- "deleted": "$~Ce message a été supprimé."
+ "deleted": "$~Ce message a été supprimé.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Discutez avec vous-même ou ajoutez un collaborateur, qui peut éditer et discuter.",
@@ -9054,6 +9055,16 @@
"commenters": "$~Les commentateurs peuvent discuter du projet avec vous, mais ils ne peuvent pas le modifier.",
"viewers": "$~Les /Viewers/ peuvent *voir* le projet et son *code*, mais ils ne peuvent ni le *discuter* ni le *modifier*.",
"restrict": "$~Lorsqu'un projet est dans une galerie, par défaut, toute personne ayant accès à la galerie peut le voir. L'option /restrict/ le masque à tous, sauf à vous et aux responsables de la galerie."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/gu-IN/gu-IN.json b/static/locales/gu-IN/gu-IN.json
index 6893c4633..e2bcdf308 100644
--- a/static/locales/gu-IN/gu-IN.json
+++ b/static/locales/gu-IN/gu-IN.json
@@ -7952,7 +7952,8 @@
"unowned": "$~આ પ્રોજેક્ટ ઑનલાઇન સંગ્રહિત નથી, તેથી તેમાં ચેટ અથવા સહયોગીઓ હોઈ શકતા નથી.",
"offline": "$~આ ચેટ લોડ કરવામાં અસમર્થ.",
"empty": "$~કોઈ સંદેશા નથી.",
- "deleted": "$~આ સંદેશ કાઢી નાખવામાં આવ્યો હતો."
+ "deleted": "$~આ સંદેશ કાઢી નાખવામાં આવ્યો હતો.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~તમારી સાથે ચેટ કરો અથવા કોઈ સહયોગી ઉમેરો, જે સંપાદિત કરી શકે અને ચેટ કરી શકે.",
@@ -7994,6 +7995,16 @@
"commenters": "$~/ટિપ્પણીકર્તાઓ/ તમારી સાથે પ્રોજેક્ટ વિશે *ચર્ચા* કરી શકે છે, પરંતુ તેઓ તેને *સંપાદિત* કરી શકતા નથી.",
"viewers": "$~/દર્શકો/ પ્રોજેક્ટ અને તેનો *કોડ* *જોઈ* શકે છે, પરંતુ તેઓ તેની *ચર્ચા* અથવા *સંપાદન* કરી શકતા નથી.",
"restrict": "$~જ્યારે કોઈ પ્રોજેક્ટ *ગેલેરી* માં હોય છે, ત્યારે ડિફૉલ્ટ રૂપે *દરેક* જે ગેલેરી જોઈ શકે છે તે તમારા પ્રોજેક્ટને જોઈ શકે છે. /restrict/ ટૉગલ તેને તમારા અને ગેલેરી ક્યુરેટર્સ સિવાય બધાથી *છુપાવે છે*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/he-IL/he-IL.json b/static/locales/he-IL/he-IL.json
index 79dd33a30..ed62341a3 100644
--- a/static/locales/he-IL/he-IL.json
+++ b/static/locales/he-IL/he-IL.json
@@ -8188,7 +8188,8 @@
"unowned": "$~פרויקט זה אינו מאוחסן באינטרנט, כך שלא ניתן לשלב בו צ'אט או משתפי פעולה.",
"offline": "$~לא ניתן לטעון את הצ'אט הזה.",
"empty": "$~אין הודעות.",
- "deleted": "$~הודעה זו נמחקה."
+ "deleted": "$~הודעה זו נמחקה.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~צ'אט עם עצמך או הוסף...\n\n• משתף פעולה, שיכול לערוך ולצ'אט\n\n• מגיב, שיכול לצ'אט אך לא יכול לערוך\n\n• צופה, שיכול לראות את הפרויקט שלך אך לא יכול לערוך או לצ'אט",
@@ -8215,6 +8216,16 @@
"commenters": "$~/מגיבים/ יכולים *לדון* איתך על הפרויקט, אבל הם לא יכולים *לערוך* אותו.",
"viewers": "$~/צופים/ יכולים *לראות* את הפרויקט ואת *הקוד* שלו, אך הם אינם יכולים *לדון* בו או *לערוך* אותו.",
"restrict": "$~כאשר פרויקט נמצא ב*גלריה*, כברירת מחדל *כל מי* שיכול לראות את הגלריה יכול לראות את הפרויקט שלך. כפתור ההפעלה /restrict/ *מסתיר* אותו מכולם מלבדך וממנהלי הגלריה."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/hi-IN/hi-IN.json b/static/locales/hi-IN/hi-IN.json
index a2b2b459e..a974a444d 100644
--- a/static/locales/hi-IN/hi-IN.json
+++ b/static/locales/hi-IN/hi-IN.json
@@ -8979,7 +8979,8 @@
"unowned": "$~यह कार्यक्रम ऑनलाइन संग्रहीत नहीं है, इसलिए इसमें चैट या सहयोगी नहीं हो सकते।",
"offline": "$~इस चैट को लोड नहीं किया जा सका।",
"empty": "$~कोई संदेश नहीं।",
- "deleted": "$~यह संदेश मिटा दिया गया था।"
+ "deleted": "$~यह संदेश मिटा दिया गया था।",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~खुद से चैट करो या जोड़ो...",
@@ -9021,6 +9022,16 @@
"commenters": "$~/टिप्पणीकार/ आपके साथ कार्यक्रम पर *चर्चा* कर सकते हैं, पर वे इसे *संपादित* नहीं कर सकते।",
"viewers": "$~/दर्शक/ कार्यक्रम और उसके *कोड* को *देख* सकते हैं, पर वे इस पर *चर्चा* या *संपादन* नहीं कर सकते।",
"restrict": "$~जब कोई कार्यक्रम किसी *गैलरी* में होता है, तो डिफ़ॉल्ट रूप से *हर वह व्यक्ति* जो गैलरी को देख सकता है, आपके कार्यक्रम को देख सकता है। /सीमित/ बटन इसे आपके और गैलरी के देखभालकर्ताओं को छोड़कर सभी से *छिपा* देता है।"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/id-ID/id-ID.json b/static/locales/id-ID/id-ID.json
index af84ea207..77c376ffd 100644
--- a/static/locales/id-ID/id-ID.json
+++ b/static/locales/id-ID/id-ID.json
@@ -8213,7 +8213,8 @@
"unowned": "$~Proyek ini tidak disimpan secara online, jadi tidak dapat memiliki fitur obrolan atau kolaborator.",
"offline": "$~Obrolan ini tidak dapat dimuat.",
"empty": "$~Tidak ada pesan.",
- "deleted": "$~Pesan ini telah dihapus."
+ "deleted": "$~Pesan ini telah dihapus.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Berbincanglah dengan diri sendiri atau tambahkan...",
@@ -8240,6 +8241,16 @@
"commenters": "$~Para pemberi komentar dapat *mendiskusikan* proyek tersebut dengan Anda, tetapi mereka tidak dapat *mengedit*nya.",
"viewers": "$~Para penonton dapat *melihat* proyek dan *kode*-nya, tetapi mereka tidak dapat *mendiskusikan* atau *mengedit*nya.",
"restrict": "$~Saat sebuah proyek berada di *galeri*, secara default *semua orang* yang dapat melihat galeri dapat melihat proyek Anda. Tombol /restrict/ *menyembunyikannya* dari semua orang kecuali Anda dan kurator galeri."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/ja-JP/ja-JP.json b/static/locales/ja-JP/ja-JP.json
index 9bc91d0b2..5456d1f18 100644
--- a/static/locales/ja-JP/ja-JP.json
+++ b/static/locales/ja-JP/ja-JP.json
@@ -7951,7 +7951,8 @@
"unowned": "$~このプロジェクトはオンラインに保存されていないため、チャットや共同編集者を持つことができません。",
"offline": "$~このチャットを読み込めません。",
"empty": "$~メッセージがありません。",
- "deleted": "$~このメッセージは削除されました。"
+ "deleted": "$~このメッセージは削除されました。",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~自分とチャットするか、追加する...",
@@ -7993,6 +7994,16 @@
"commenters": "$~/コメント者/はあなたとプロジェクトについて*話し合う*ことができますが、*編集*はできません。",
"viewers": "$~/閲覧者/はプロジェクトとそのコードを*見る*ことができますが、*話し合う*ことも*編集*することもできません。",
"restrict": "$~プロジェクトが*ギャラリー*内にあるとき、初期設定ではギャラリーを見られる*全員*があなたのプロジェクトを見られます。/制限/の切り替えは、あなたとギャラリーのキュレーター以外のすべての人からプロジェクトを*隠します*。"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/kn-IN/kn-IN.json b/static/locales/kn-IN/kn-IN.json
index 3244d6bbc..c14616785 100644
--- a/static/locales/kn-IN/kn-IN.json
+++ b/static/locales/kn-IN/kn-IN.json
@@ -8167,7 +8167,8 @@
"unowned": "$~ಈ ಯೋಜನೆಯು ಆನ್ಲೈನ್ನಲ್ಲಿ ಸಂಗ್ರಹಿಸಲ್ಪಟ್ಟಿಲ್ಲ, ಆದ್ದರಿಂದ ಇದು ಚಾಟ್ ಅಥವಾ ಸಹಯೋಗಿಗಳನ್ನು ಹೊಂದಲು ಸಾಧ್ಯವಿಲ್ಲ.",
"offline": "$~ಈ ಚಾಟ್ ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ.",
"empty": "$~ಯಾವುದೇ ಸಂದೇಶಗಳಿಲ್ಲ.",
- "deleted": "$~ಈ ಸಂದೇಶವನ್ನು ಅಳಿಸಲಾಗಿದೆ."
+ "deleted": "$~ಈ ಸಂದೇಶವನ್ನು ಅಳಿಸಲಾಗಿದೆ.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~ನಿಮ್ಮೊಂದಿಗೆ ಚಾಟ್ ಮಾಡಿ ಅಥವಾ ಸೇರಿಸಿ...\n\n• ಸಂಪಾದಿಸಬಹುದಾದ ಮತ್ತು ಚಾಟ್ ಮಾಡಬಹುದಾದ ಸಹಯೋಗಿ\n\n• ಚಾಟ್ ಮಾಡಬಹುದಾದ ಆದರೆ ಸಂಪಾದಿಸಲು ಸಾಧ್ಯವಾಗದ ಕಾಮೆಂಟ್ ಮಾಡುವವರು\n\n• ನಿಮ್ಮ ಯೋಜನೆಯನ್ನು ನೋಡಬಹುದಾದ ಆದರೆ ಸಂಪಾದಿಸಲು ಅಥವಾ ಚಾಟ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗದ ವೀಕ್ಷಕರು",
@@ -8209,6 +8210,16 @@
"commenters": "$~/ಟಿಪ್ಪಣಿದಾರರು/ ಯೋಜನೆಯ ಬಗ್ಗೆ ನಿಮ್ಮೊಂದಿಗೆ *ಚರ್ಚಿಸಬಹುದು*, ಆದರೆ ಅವರು ಅದನ್ನು *ಸಂಪಾದಿಸಲು* ಸಾಧ್ಯವಿಲ್ಲ.",
"viewers": "$~/ವೀಕ್ಷಕರು/ ಪ್ರಾಜೆಕ್ಟ್ ಮತ್ತು ಅದರ *ಕೋಡ್* ಅನ್ನು *ವೀಕ್ಷಿಸಬಹುದು*, ಆದರೆ ಅವರು ಅದನ್ನು *ಚರ್ಚಿಸಲು* ಅಥವಾ *ಸಂಪಾದಿಸಲು* ಸಾಧ್ಯವಿಲ್ಲ.",
"restrict": "$~ಒಂದು ಪ್ರಾಜೆಕ್ಟ್ *ಗ್ಯಾಲರಿ*ಯಲ್ಲಿರುವಾಗ, ಪೂರ್ವನಿಯೋಜಿತವಾಗಿ ಗ್ಯಾಲರಿಯನ್ನು ನೋಡಬಹುದಾದ *ಪ್ರತಿಯೊಬ್ಬರೂ* ನಿಮ್ಮ ಪ್ರಾಜೆಕ್ಟ್ ಅನ್ನು ನೋಡಬಹುದು. /restrict/ ಟಾಗಲ್ *ನಿಮ್ಮನ್ನು ಮತ್ತು ಗ್ಯಾಲರಿ ಕ್ಯುರೇಟರ್ಗಳನ್ನು ಹೊರತುಪಡಿಸಿ ಎಲ್ಲರಿಂದ ಅದನ್ನು ಮರೆಮಾಡುತ್ತದೆ*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/ko-KR/ko-KR.json b/static/locales/ko-KR/ko-KR.json
index e8abe11a2..f5276edee 100644
--- a/static/locales/ko-KR/ko-KR.json
+++ b/static/locales/ko-KR/ko-KR.json
@@ -8980,7 +8980,8 @@
"unowned": "$~이 프로젝트에는 소유자가 없으므로 채팅을 할 수 없습니다.",
"offline": "$~이 채팅을 불러올 수 없습니다.",
"empty": "$~메시지가 없습니다.",
- "deleted": "$~이 메시지는 삭제되었습니다."
+ "deleted": "$~이 메시지는 삭제되었습니다.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~본인과 채팅을 하거나, 편집하고 채팅할 수 있는 공동 작업자를 추가하세요.",
@@ -9022,6 +9023,16 @@
"commenters": "$~댓글 작성자는 프로젝트에 대해 *토론*할 수는 있지만 *편집*할 수는 없습니다.",
"viewers": "$~/뷰어/는 프로젝트와 그 *코드*를 *볼* 수는 있지만, *토론*하거나 *편집*할 수는 없습니다.",
"restrict": "$~프로젝트가 갤러리에 등록되면 기본적으로 갤러리를 볼 수 있는 *모든* 사용자가 해당 프로젝트를 볼 수 있습니다. /제한/ 토글 버튼을 사용하면 프로젝트 소유자와 갤러리 관리자를 제외한 모든 사용자에게 프로젝트가 *숨겨집니다*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/mr-IN/mr-IN.json b/static/locales/mr-IN/mr-IN.json
index 3dd6b766b..242560a5c 100644
--- a/static/locales/mr-IN/mr-IN.json
+++ b/static/locales/mr-IN/mr-IN.json
@@ -7942,7 +7942,8 @@
"unowned": "$~हा प्रकल्प ऑनलाइन साठवलेला नाही, म्हणून त्यात गप्पा किंवा सहकारी असू शकत नाहीत.",
"offline": "$~या गप्पा उघडता आल्या नाहीत.",
"empty": "$~संदेश नाहीत.",
- "deleted": "$~हा संदेश हटवला होता."
+ "deleted": "$~हा संदेश हटवला होता.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~स्वतःशी गप्पा मारा किंवा जोडा...",
@@ -7984,6 +7985,16 @@
"commenters": "$~/टिप्पणीकार/ तुमच्याशी प्रकल्पावर *चर्चा* करू शकतात, पण ते तो *संपादित* करू शकत नाहीत.",
"viewers": "$~/पाहणारे/ प्रकल्प आणि त्याचा *संकेत* *पाहू* शकतात, पण ते त्यावर *चर्चा* किंवा *संपादन* करू शकत नाहीत.",
"restrict": "$~जेव्हा प्रकल्प एका *दालनात* असतो, तेव्हा मूलभूतपणे जो कोणी दालन पाहू शकतो तो *प्रत्येक* जण तुमचा प्रकल्प पाहू शकतो. /मर्यादित/ स्विच तो तुम्ही आणि दालन व्यवस्थापक सोडून इतर सर्वांपासून *लपवतो*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/ne-NP/ne-NP.json b/static/locales/ne-NP/ne-NP.json
index 2b2cba56e..1dbc64e5f 100644
--- a/static/locales/ne-NP/ne-NP.json
+++ b/static/locales/ne-NP/ne-NP.json
@@ -8231,7 +8231,8 @@
"unowned": "$~यो परियोजना अनलाइन भण्डारण गरिएको छैन, त्यसैले यसमा कुराकानी वा सहकर्मीहरू हुन सक्दैनन्।",
"offline": "$~यो कुराकानी लोड गर्न सकिएन।",
"empty": "$~कुनै सन्देश छैन।",
- "deleted": "$~यो सन्देश मेटाइयो।"
+ "deleted": "$~यो सन्देश मेटाइयो।",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~आफैंसँग कुराकानी गर्नुहोस् वा थप्नुहोस्...",
@@ -8258,6 +8259,16 @@
"commenters": "$~/टिप्पणीकर्ताहरू/ले तपाईंसँग परियोजनाबारे *छलफल* गर्न सक्छन्, तर तिनीहरूले यसलाई *सम्पादन* गर्न सक्दैनन्।",
"viewers": "$~/दर्शकहरू/ले परियोजना र यसको *कोड* *हेर्न* सक्छन्, तर तिनीहरूले *छलफल* वा *सम्पादन* गर्न सक्दैनन्।",
"restrict": "$~परियोजना *ग्यालरी*मा हुँदा, पूर्वनिर्धारित रूपमा ग्यालरी देख्न सक्ने *सबैले* तपाईंको परियोजना देख्न सक्छन्। /सीमित/ टगलले यसलाई तपाईं र ग्यालरी क्युरेटरहरू बाहेक सबैबाट *लुकाउँछ*।"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "$?",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$?",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"presence": {
diff --git a/static/locales/pa-IN/pa-IN.json b/static/locales/pa-IN/pa-IN.json
index fde890422..b4be7305e 100644
--- a/static/locales/pa-IN/pa-IN.json
+++ b/static/locales/pa-IN/pa-IN.json
@@ -7939,7 +7939,8 @@
"unowned": "$~ਇਹ ਪ੍ਰੋਜੈਕਟ ਔਨਲਾਈਨ ਸਟੋਰ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ, ਇਸਲਈ ਇਸ ਵਿੱਚ ਚੈਟ ਜਾਂ ਸਹਿਯੋਗੀ ਨਹੀਂ ਹੋ ਸਕਦੇ ਹਨ।",
"offline": "$~ਇਸ ਚੈਟ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ।",
"empty": "$~ਕੋਈ ਸੰਦੇਸ਼ ਨਹੀਂ।",
- "deleted": "$~ਇਹ ਸੁਨੇਹਾ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ।"
+ "deleted": "$~ਇਹ ਸੁਨੇਹਾ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ।",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~ਆਪਣੇ ਨਾਲ ਚੈਟ ਕਰੋ ਜਾਂ ਕੋਈ ਸਹਿਯੋਗੀ ਸ਼ਾਮਲ ਕਰੋ, ਜੋ ਸੰਪਾਦਿਤ ਅਤੇ ਚੈਟ ਕਰ ਸਕਦਾ ਹੈ।",
@@ -7981,6 +7982,16 @@
"commenters": "$~/ਟਿੱਪਣੀਕਾਰ/ ਤੁਹਾਡੇ ਨਾਲ ਪ੍ਰੋਜੈਕਟ ਬਾਰੇ *ਚਰਚਾ* ਕਰ ਸਕਦੇ ਹਨ, ਪਰ ਉਹ ਇਸਨੂੰ *ਸੰਪਾਦਿਤ* ਨਹੀਂ ਕਰ ਸਕਦੇ।",
"viewers": "$~/ਦਰਸ਼ਕ/ ਪ੍ਰੋਜੈਕਟ ਅਤੇ ਇਸਦੇ *ਕੋਡ* ਨੂੰ *ਦੇਖ* ਸਕਦੇ ਹਨ, ਪਰ ਉਹ ਇਸਦੀ *ਚਰਚਾ* ਜਾਂ *ਸੰਪਾਦਨ* ਨਹੀਂ ਕਰ ਸਕਦੇ।",
"restrict": "$~ਜਦੋਂ ਕੋਈ ਪ੍ਰੋਜੈਕਟ *ਗੈਲਰੀ* ਵਿੱਚ ਹੁੰਦਾ ਹੈ, ਤਾਂ ਡਿਫਾਲਟ ਤੌਰ 'ਤੇ *ਹਰ ਕੋਈ* ਜੋ ਗੈਲਰੀ ਦੇਖ ਸਕਦਾ ਹੈ, ਉਹ ਤੁਹਾਡੇ ਪ੍ਰੋਜੈਕਟ ਨੂੰ ਦੇਖ ਸਕਦਾ ਹੈ। /restrict/ ਟੌਗਲ ਇਸਨੂੰ ਤੁਹਾਡੇ ਅਤੇ ਗੈਲਰੀ ਕਿਊਰੇਟਰਾਂ ਤੋਂ ਇਲਾਵਾ ਹਰ ਕਿਸੇ ਤੋਂ *ਲੁਕਾਉਂਦਾ ਹੈ*।"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/pl-PL/pl-PL.json b/static/locales/pl-PL/pl-PL.json
index 83922b556..01c71da62 100644
--- a/static/locales/pl-PL/pl-PL.json
+++ b/static/locales/pl-PL/pl-PL.json
@@ -7972,7 +7972,8 @@
"unowned": "$~Ten projekt nie jest przechowywany online, więc nie można na nim prowadzić czatu ani współpracować z innymi osobami.",
"offline": "$~Nie można załadować tego czatu.",
"empty": "$~Brak wiadomości.",
- "deleted": "$~Ta wiadomość została usunięta."
+ "deleted": "$~Ta wiadomość została usunięta.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Porozmawiaj sam ze sobą lub dodaj współpracownika, który może edytować i rozmawiać.",
@@ -8014,6 +8015,16 @@
"commenters": "$~/Komentujący/ mogą *dyskutować* z Tobą o projekcie, ale nie mogą go *edytować*.",
"viewers": "$~/Widzowie/ mogą *przeglądać* projekt i jego *kod*, ale nie mogą go *dyskutować* ani *edytować*.",
"restrict": "$~Gdy projekt znajduje się w *galerii*, domyślnie *każdy*, kto może zobaczyć galerię, może zobaczyć Twój projekt. Przełącznik /restrict/ *ukrywa* go przed wszystkimi oprócz Ciebie i kuratorów galerii."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/pt-PT/pt-PT.json b/static/locales/pt-PT/pt-PT.json
index f4386b26a..be2e35736 100644
--- a/static/locales/pt-PT/pt-PT.json
+++ b/static/locales/pt-PT/pt-PT.json
@@ -8198,7 +8198,8 @@
"unowned": "Este projeto não está armazenado online, pelo que não pode ter chat nem colaboradores.",
"offline": "Não foi possível carregar este chat.",
"empty": "Nenhuma mensagem.",
- "deleted": "Esta mensagem foi apagada."
+ "deleted": "Esta mensagem foi apagada.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "Converse consigo mesmo ou adicione...",
@@ -8225,6 +8226,16 @@
"commenters": "Os comentadores podem *discutir* o projeto consigo, mas não podem *editá-lo*.",
"viewers": "Os /Visualizadores/ podem *visualizar* o projeto e o seu *código*, mas não podem *discuti-lo* ou *editá-lo*.",
"restrict": "Quando um projeto está numa *galeria*, por defeito, *todos* os que conseguem ver a galeria podem também ver o seu projeto. A opção /restrict/ *oculta* o projeto de todos, exceto de si e dos curadores da galeria."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/ro-RO/ro-RO.json b/static/locales/ro-RO/ro-RO.json
index f82da9a1b..33e26fd6b 100644
--- a/static/locales/ro-RO/ro-RO.json
+++ b/static/locales/ro-RO/ro-RO.json
@@ -8216,7 +8216,8 @@
"unowned": "$~Acest proiect nu este stocat online, deci nu poate avea chat sau colaboratori.",
"offline": "$~Nu se poate încărca acest chat.",
"empty": "$~Niciun mesaj.",
- "deleted": "$~Acest mesaj a fost șters."
+ "deleted": "$~Acest mesaj a fost șters.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Discutați cu dumneavoastră înșivă sau adăugați...",
@@ -8243,6 +8244,16 @@
"commenters": "$~/Comentatorii/ pot *discuta* proiectul cu tine, dar nu îl pot *edita*.",
"viewers": "$~/Vizualizatorii/ pot *vizualiza* proiectul și *codul* său, dar nu îl pot *discuta* sau *edita*.",
"restrict": "$~Când un proiect se află într-o *galerie*, în mod implicit, *toți* cei care pot vedea galeria pot vedea proiectul dvs. Comutatorul /restricționare/ îl *ascunde* de toată lumea, cu excepția dvs. și a curatorilor galeriei."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/sr-RS/sr-RS.json b/static/locales/sr-RS/sr-RS.json
index 3ebe28b30..07f6f5c89 100644
--- a/static/locales/sr-RS/sr-RS.json
+++ b/static/locales/sr-RS/sr-RS.json
@@ -7953,7 +7953,8 @@
"unowned": "$~Овај пројекат се не чува на мрежи, тако да не може да има ћаскање или сараднике.",
"offline": "$~Није могуће учитати ово ћаскање.",
"empty": "$~Нема порука.",
- "deleted": "$~Ова порука је обрисана."
+ "deleted": "$~Ова порука је обрисана.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Разговарајте са собом или додајте сарадника који може да уређује и ћаска.",
@@ -7995,6 +7996,16 @@
"commenters": "$~/Коментатори/ могу *разговарати* о пројекту са вама, али га не могу *уређивати*.",
"viewers": "$~/Гледаоци/ могу *видети* пројекат и његов *код*, али не могу *дискутовати* о њему или га *уређивати*.",
"restrict": "$~Када се пројекат налази у *галерији*, подразумевано *сви* који могу да виде галерију могу да виде и ваш пројекат. Прекидач /ограничи/ *скрива* га од свих осим од вас и кустоса галерије."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/sv-SE/sv-SE.json b/static/locales/sv-SE/sv-SE.json
index d7fa57880..cc0f992f2 100644
--- a/static/locales/sv-SE/sv-SE.json
+++ b/static/locales/sv-SE/sv-SE.json
@@ -7936,7 +7936,8 @@
"unowned": "$~Det här projektet lagras inte online, så det kan inte ha en chatt eller samarbetspartner.",
"offline": "$~Det gick inte att ladda den här chatten.",
"empty": "$~Inga meddelanden.",
- "deleted": "$~Detta meddelande raderades."
+ "deleted": "$~Detta meddelande raderades.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Chatta med dig själv eller lägg till en medarbetare som kan redigera och chatta.",
@@ -7978,6 +7979,16 @@
"commenters": "$~/Kommentatorer/ kan *diskutera* projektet med dig, men de kan inte *redigera* det.",
"viewers": "$~/Tittare/ kan *se* projektet och dess *kod*, men de kan inte *diskutera* eller *redigera* det.",
"restrict": "$~När ett projekt finns i ett *galleri* kan som standard *alla* som kan se galleriet se ditt projekt. /restrict/-knappen *döljer* det för alla utom dig och galleriets kuratorer."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/ta-IN-LK-SG/ta-IN-LK-SG.json b/static/locales/ta-IN-LK-SG/ta-IN-LK-SG.json
index 61aed92c2..f6bfc3374 100644
--- a/static/locales/ta-IN-LK-SG/ta-IN-LK-SG.json
+++ b/static/locales/ta-IN-LK-SG/ta-IN-LK-SG.json
@@ -7954,7 +7954,8 @@
"unowned": "$~இந்தத் திட்டம் ஆன்லைனில் சேமிக்கப்படவில்லை, எனவே அரட்டை அல்லது கூட்டுப்பணியாளர்களை இதில் வைத்திருக்க முடியாது.",
"offline": "$~இந்த அரட்டையை ஏற்ற முடியவில்லை.",
"empty": "$~செய்திகள் இல்லை.",
- "deleted": "$~இந்த செய்தி நீக்கப்பட்டது."
+ "deleted": "$~இந்த செய்தி நீக்கப்பட்டது.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~உங்களுடன் அரட்டையடிக்கவும் அல்லது ஒரு கூட்டுப்பணியாளரைச் சேர்க்கவும், அவர் திருத்தவும் அரட்டையடிக்கவும் முடியும்.",
@@ -7996,6 +7997,16 @@
"commenters": "$~கருத்துரையாளர்கள் உங்களுடன் திட்டம் குறித்துக் கலந்துரையாடலாம், ஆனால் அவர்களால் அதைத் திருத்த முடியாது.",
"viewers": "$~பார்வையாளர்கள் திட்டத்தையும் அதன் குறியீட்டையும் *பார்க்க* முடியும், ஆனால் அவர்களால் அதைப் பற்றி *விவாதிக்க* அல்லது *திருத்த* முடியாது.",
"restrict": "$~ஒரு ப்ராஜெக்ட் கேலரியில் இருக்கும்போது, இயல்பாகவே கேலரியைப் பார்க்கக்கூடிய அனைவராலும் உங்கள் ப்ராஜெக்ட்டையும் பார்க்க முடியும். /restrict/ டோகிள், உங்களையும் கேலரி கியூரேட்டர்களையும் தவிர மற்ற அனைவரிடமிருந்தும் அதை மறைக்கிறது."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/te-IN/te-IN.json b/static/locales/te-IN/te-IN.json
index 3924ace60..d3ec61bcb 100644
--- a/static/locales/te-IN/te-IN.json
+++ b/static/locales/te-IN/te-IN.json
@@ -8167,7 +8167,8 @@
"unowned": "$~ఈ ప్రాజెక్ట్ ఆన్లైన్లో నిల్వ చేయబడలేదు, కాబట్టి దీనికి చాట్ లేదా సహకారులు ఉండలేరు.",
"offline": "$~ఈ చాట్ను లోడ్ చేయడం సాధ్యం కాలేదు.",
"empty": "$~సందేశాలు లేవు.",
- "deleted": "$~ఈ సందేశం తొలగించబడింది."
+ "deleted": "$~ఈ సందేశం తొలగించబడింది.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~మీతో చాట్ చేయండి లేదా జోడించండి...\n\n• సవరించగల మరియు చాట్ చేయగల సహకారి\n\n• వ్యాఖ్యాత, చాట్ చేయగలరు కానీ సవరించలేరు\n\n• మీ ప్రాజెక్ట్ను చూడగల వీక్షకుడు కానీ సవరించలేరు లేదా చాట్ చేయలేరు",
@@ -8209,6 +8210,16 @@
"commenters": "$~వ్యాఖ్యాతలు మీతో ప్రాజెక్ట్ గురించి చర్చించగలరు, కానీ దానిని సవరించలేరు.",
"viewers": "$~వీక్షకులు ప్రాజెక్ట్ను మరియు దాని కోడ్ను చూడగలరు, కానీ దానిని చర్చించడం లేదా సవరించడం చేయలేరు.",
"restrict": "$~ఒక ప్రాజెక్ట్ *గ్యాలరీ*లో ఉన్నప్పుడు, డిఫాల్ట్గా గ్యాలరీని చూడగలిగే *ప్రతిఒక్కరూ* మీ ప్రాజెక్ట్ను చూడగలరు. /restrict/ టోగుల్ దానిని మీకు మరియు గ్యాలరీ క్యూరేటర్లకు తప్ప మిగతా అందరికీ కనిపించకుండా *దాచిపెడుతుంది*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/tl-PH/tl-PH.json b/static/locales/tl-PH/tl-PH.json
index 99540f625..03a16f364 100644
--- a/static/locales/tl-PH/tl-PH.json
+++ b/static/locales/tl-PH/tl-PH.json
@@ -8196,7 +8196,8 @@
"unowned": "$~Hindi nakaimbak online ang proyektong ito, kaya hindi ito maaaring magkaroon ng chat o mga collaborator.",
"offline": "$~Hindi ma-load ang chat na ito.",
"empty": "$~Walang mga mensahe.",
- "deleted": "$~Binura na ang mensaheng ito."
+ "deleted": "$~Binura na ang mensaheng ito.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Makipag-chat sa sarili mo o magdagdag...",
@@ -8223,6 +8224,16 @@
"commenters": "$~Maaaring *talakayin* ng mga /tagapagkomento/ ang proyekto sa iyo, ngunit hindi nila ito maaaring *i-edit*.",
"viewers": "$~Maaaring *tingnan* ng mga /tumitingin/ ang proyekto at ang *code* nito, ngunit hindi nila ito maaaring *talakayin* o *i-edit*.",
"restrict": "$~Kapag ang isang proyekto ay nasa isang *gallery*, bilang default, *lahat* ng makakakita sa gallery ay makakakita ng iyong proyekto. *Itinatago* ito ng /restrict/ toggle mula sa lahat maliban sa iyo at sa mga curator ng gallery."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/tr-TR/tr-TR.json b/static/locales/tr-TR/tr-TR.json
index 14d88debd..a50d6ccf8 100644
--- a/static/locales/tr-TR/tr-TR.json
+++ b/static/locales/tr-TR/tr-TR.json
@@ -7949,7 +7949,8 @@
"unowned": "$~Bu proje çevrimiçi olarak saklanmadığından, bir sohbet veya işbirlikçileri olamaz.",
"offline": "$~Bu sohbet yüklenemedi.",
"empty": "$~Mesaj yok.",
- "deleted": "$~Bu mesaj silindi."
+ "deleted": "$~Bu mesaj silindi.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Kendinizle sohbet edin veya düzenleme ve sohbet etme yeteneğine sahip bir işbirlikçi ekleyin.",
@@ -7991,6 +7992,16 @@
"commenters": "$~Yorum yapanlar projeyi sizinle *tartışabilirler*, ancak *düzenleyemezler*.",
"viewers": "$~İzleyiciler projeyi ve kodunu görüntüleyebilirler, ancak üzerinde tartışamazlar veya düzenleyemezler.",
"restrict": "$~Bir proje bir *galeride* olduğunda, varsayılan olarak galeriyi görebilen *herkes* projenizi görebilir. /restrict/ seçeneği, projeyi sizden ve galeri yöneticilerinden başka herkesten *gizler*."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/vi-VN/vi-VN.json b/static/locales/vi-VN/vi-VN.json
index c587fc038..7f34de5d5 100644
--- a/static/locales/vi-VN/vi-VN.json
+++ b/static/locales/vi-VN/vi-VN.json
@@ -8204,7 +8204,8 @@
"unowned": "$~Dự án này không được lưu trữ trực tuyến, vì vậy không thể có chức năng trò chuyện hoặc cộng tác viên.",
"offline": "$~Không thể tải cuộc trò chuyện này.",
"empty": "$~Không có tin nhắn nào.",
- "deleted": "$~Tin nhắn này đã bị xóa."
+ "deleted": "$~Tin nhắn này đã bị xóa.",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~Tự trò chuyện với chính mình hoặc thêm...\n\n• một cộng tác viên, người có thể chỉnh sửa và trò chuyện\n\n• một người bình luận, người có thể trò chuyện nhưng không thể chỉnh sửa\n\n• một người xem, người có thể xem dự án của bạn nhưng không thể chỉnh sửa hoặc trò chuyện",
@@ -8231,6 +8232,16 @@
"commenters": "$~Người bình luận có thể *thảo luận* về dự án với bạn, nhưng không được phép *chỉnh sửa* nó.",
"viewers": "$~Người xem có thể xem dự án và mã nguồn của nó, nhưng không thể thảo luận hoặc chỉnh sửa.",
"restrict": "$~Khi một dự án nằm trong *thư viện ảnh*, theo mặc định *mọi người* có thể xem thư viện đều có thể thấy dự án của bạn. Nút /restrict/ sẽ *ẩn* dự án đó khỏi mọi người ngoại trừ bạn và người quản lý thư viện."
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "—",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$from → $to",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"palette": {
diff --git a/static/locales/zh-CN/zh-CN.json b/static/locales/zh-CN/zh-CN.json
index cfba11fa2..72fb98e85 100644
--- a/static/locales/zh-CN/zh-CN.json
+++ b/static/locales/zh-CN/zh-CN.json
@@ -8946,7 +8946,8 @@
"unowned": "$~这个项目没有保存在网上,所以不能有聊天或协作者。",
"offline": "$~无法加载这个聊天。",
"empty": "$~没有信息。",
- "deleted": "$~这条信息已被删除。"
+ "deleted": "$~这条信息已被删除。",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~和自己聊天或添加……",
@@ -8988,6 +8989,16 @@
"commenters": "$~/评论者/可以和你*讨论*项目,但他们不能*编辑*它。",
"viewers": "$~/查看者/可以*查看*项目和它的*代码*,但他们不能*讨论*或*编辑*它。",
"restrict": "$~当一个项目在*作品库*里时,默认情况下*每个*能看到这个作品库的人都能看到你的项目。/限制/开关会把它对除你自己和作品库策展人以外的所有人*隐藏*起来。"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "$?",
+ "error": "$?",
+ "messageError": "$?",
+ "direction": "$?",
+ "messageErrors": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/locales/zh-TW/zh-TW.json b/static/locales/zh-TW/zh-TW.json
index 9d029beaf..fd753da9a 100644
--- a/static/locales/zh-TW/zh-TW.json
+++ b/static/locales/zh-TW/zh-TW.json
@@ -8949,7 +8949,8 @@
"unowned": "$~這個工程沒有存在線上,所以無法有聊天或協作者。",
"offline": "$~無法載入這個聊天。",
"empty": "$~沒有訊息。",
- "deleted": "$~這則訊息已被刪除。"
+ "deleted": "$~這則訊息已被刪除。",
+ "untaggedMessage": "$?"
},
"prompt": {
"solo": "$~和自己聊天或新增...",
@@ -8991,6 +8992,16 @@
"commenters": "$~/留言者/ 可以和你 *討論* 工程,但不能 *編輯* 它。",
"viewers": "$~/觀看者/ 可以 *檢視* 工程和它的 *程式碼*,但不能 *討論* 或 *編輯* 它。",
"restrict": "$~當工程在 *作品集* 裡時,預設 *所有* 能看到作品集的人都能看到你的工程。/限制/ 開關會把它從所有人 *隱藏*,只留下你和作品集策展人。"
+ },
+ "translate": {
+ "label": "$?",
+ "language": "$?",
+ "off": "$?",
+ "error": "$?",
+ "messageError": "$?",
+ "messageErrors": "$?",
+ "direction": "$?",
+ "results": "$?"
}
},
"checkpoints": {
diff --git a/static/schemas/LocaleText.json b/static/schemas/LocaleText.json
index ec9759d6f..0506b1af3 100644
--- a/static/schemas/LocaleText.json
+++ b/static/schemas/LocaleText.json
@@ -16031,13 +16031,18 @@
"unowned": {
"description": "[plain] The project isn't owned by a person",
"type": "string"
+ },
+ "untaggedMessage": {
+ "description": "[plain] Shown when the user tries to send a message without tagging its language",
+ "type": "string"
}
},
"required": [
"unowned",
"offline",
"empty",
- "deleted"
+ "deleted",
+ "untaggedMessage"
],
"type": "object"
},
@@ -16232,10 +16237,60 @@
"restrict"
],
"type": "object"
+ },
+ "translate": {
+ "additionalProperties": false,
+ "description": "Controls for translating received messages into another language",
+ "properties": {
+ "direction": {
+ "$ref": "#/definitions/Template%3C%5B%22from%22%2C%22to%22%5D%3E",
+ "description": "[plain] Translation direction label; $from is the source language name, $to is the target language name"
+ },
+ "error": {
+ "$ref": "#/definitions/Template%3C%5B%22to%22%5D%3E",
+ "description": "[plain] Shown below the translate control when the whole chat couldn't be translated; $to is the target language name"
+ },
+ "label": {
+ "description": "[plain] Label for the control that translates received messages",
+ "type": "string"
+ },
+ "language": {
+ "description": "[plain] The ARIA label for the message language selector",
+ "type": "string"
+ },
+ "messageError": {
+ "$ref": "#/definitions/Template%3C%5B%22sender%22%5D%3E",
+ "description": "[plain] Shown when exactly one message couldn't be translated; $sender is the sender's username"
+ },
+ "messageErrors": {
+ "$ref": "#/definitions/Template%3C%5B%22%23count%22%5D%3E",
+ "description": "[plain] Shown when multiple messages couldn't be translated; $count is the number of failed messages"
+ },
+ "off": {
+ "description": "[plain] Button label and tip to clear the current translation target",
+ "type": "string"
+ },
+ "results": {
+ "$ref": "#/definitions/Template%3C%5B%22%23count%22%5D%3E",
+ "description": "[plain] Announced to screen readers with the number of languages matching the search; $count is the match count"
+ }
+ },
+ "required": [
+ "label",
+ "language",
+ "off",
+ "error",
+ "messageError",
+ "messageErrors",
+ "direction",
+ "results"
+ ],
+ "type": "object"
}
},
"required": [
"label",
+ "translate",
"field",
"role",
"button",
@@ -25327,6 +25382,9 @@
"Template<[\"rows\",\"columns\"]>": {
"type": "string"
},
+ "Template<[\"sender\"]>": {
+ "type": "string"
+ },
"Template<[\"setOrMap\",\"key\"]>": {
"type": "string"
},
@@ -25396,6 +25454,9 @@
"Template<[\"title\"]>": {
"type": "string"
},
+ "Template<[\"to\"]>": {
+ "type": "string"
+ },
"Template<[\"token\",\"before\",\"after\"]>": {
"type": "string"
},