diff --git a/.changeset/gold-dogs-tease.md b/.changeset/gold-dogs-tease.md new file mode 100644 index 0000000000..1336fcd302 --- /dev/null +++ b/.changeset/gold-dogs-tease.md @@ -0,0 +1,5 @@ +--- +"vs-code-extension": minor +--- + +add machine translate diff --git a/inlang/source-code/ide-extension/package.json b/inlang/source-code/ide-extension/package.json index 8248643aba..5fb2515b3b 100644 --- a/inlang/source-code/ide-extension/package.json +++ b/inlang/source-code/ide-extension/package.json @@ -155,6 +155,7 @@ }, "dependencies": { "@inlang/result": "workspace:*", + "@inlang/rpc": "workspace:*", "@inlang/sdk": "workspace:*", "@inlang/telemetry": "workspace:*", "@lix-js/client": "workspace:*", diff --git a/inlang/source-code/ide-extension/src/commands/copyError.ts b/inlang/source-code/ide-extension/src/commands/copyError.ts index a5faf374d3..b0e8b7a04b 100644 --- a/inlang/source-code/ide-extension/src/commands/copyError.ts +++ b/inlang/source-code/ide-extension/src/commands/copyError.ts @@ -4,7 +4,7 @@ import { msg } from "../utilities/messages/msg.js" export const copyErrorCommand = { command: "sherlock.copyError", - title: "Inlang: Copy error", + title: "Sherlock: Copy error", register: vscode.commands.registerCommand, callback: async (error: ErrorNode) => { vscode.env.clipboard.writeText(`${error.label}: ${error.tooltip}`) diff --git a/inlang/source-code/ide-extension/src/commands/editMessage.ts b/inlang/source-code/ide-extension/src/commands/editMessage.ts index 1bf8ed8475..f042bc7441 100644 --- a/inlang/source-code/ide-extension/src/commands/editMessage.ts +++ b/inlang/source-code/ide-extension/src/commands/editMessage.ts @@ -7,7 +7,7 @@ import { CONFIGURATION } from "../configuration.js" export const editMessageCommand = { command: "sherlock.editMessage", - title: "Inlang: Edit a Message", + title: "Sherlock: Edit a Message", register: commands.registerCommand, callback: async function ({ messageId, diff --git a/inlang/source-code/ide-extension/src/commands/extractMessage.ts b/inlang/source-code/ide-extension/src/commands/extractMessage.ts index fb0c526a54..b6e318d820 100644 --- a/inlang/source-code/ide-extension/src/commands/extractMessage.ts +++ b/inlang/source-code/ide-extension/src/commands/extractMessage.ts @@ -11,7 +11,7 @@ import { isQuoted, stripQuotes } from "../utilities/messages/isQuoted.js" */ export const extractMessageCommand = { command: "sherlock.extractMessage", - title: "Inlang: Extract Message", + title: "Sherlock: Extract Message", register: commands.registerTextEditorCommand, callback: async function (textEditor: TextEditor) { const ideExtension = state().project.customApi()["app.inlang.ideExtension"] diff --git a/inlang/source-code/ide-extension/src/commands/jumpToPosition.ts b/inlang/source-code/ide-extension/src/commands/jumpToPosition.ts index 3389be30a3..ac4b1030a8 100644 --- a/inlang/source-code/ide-extension/src/commands/jumpToPosition.ts +++ b/inlang/source-code/ide-extension/src/commands/jumpToPosition.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode" export const jumpToPositionCommand = { command: "sherlock.jumpToPosition", - title: "Inlang: Jump to position in editor", + title: "Sherlock: Jump to position in editor", register: commands.registerCommand, callback: async function (args: { messageId: Message["id"] diff --git a/inlang/source-code/ide-extension/src/commands/machineTranslate.test.ts b/inlang/source-code/ide-extension/src/commands/machineTranslate.test.ts new file mode 100644 index 0000000000..f1a621085b --- /dev/null +++ b/inlang/source-code/ide-extension/src/commands/machineTranslate.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { rpc } from "@inlang/rpc" +import { CONFIGURATION } from "../configuration.js" +import { machineTranslateMessageCommand } from "./machineTranslate.js" +import { msg } from "../utilities/messages/msg.js" + +vi.mock("vscode", () => ({ + commands: { + registerCommand: vi.fn(), + }, + StatusBarAlignment: { + Left: 1, + Right: 2, + }, + window: { + createStatusBarItem: vi.fn(() => ({ + show: vi.fn(), + text: "", + })), + }, +})) + +vi.mock("@inlang/rpc", () => ({ + rpc: { + machineTranslateMessage: vi.fn(), + }, +})) + +vi.mock("../configuration", () => ({ + CONFIGURATION: { + EVENTS: { + ON_DID_EDIT_MESSAGE: { + fire: vi.fn(), + }, + }, + }, +})) + +vi.mock("../utilities/messages/msg", () => ({ + msg: vi.fn(), +})) + +vi.mock("../utilities/state", () => ({ + state: () => ({ + project: { + query: { + messages: { + get: (args: any) => { + if (args.where && args.where.id === "validId") { + return mockMessage + } + return undefined + }, + upsert: vi.fn(), + }, + }, + }, + }), +})) + +const mockMessage = { + id: "validId", + alias: {}, + selectors: [], + variants: [ + { + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "Original content", + }, + ], + }, + ], +} + +describe("machineTranslateMessageCommand", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return a message if messageId is not found", async () => { + await machineTranslateMessageCommand.callback({ + messageId: "nonexistent", + sourceLanguageTag: "en", + targetLanguageTags: ["es"], + }) + + expect(msg).toHaveBeenCalledWith("Message with id nonexistent not found.") + }) + + it("should return an error message on RPC error", async () => { + // @ts-expect-error + rpc.machineTranslateMessage.mockResolvedValueOnce({ error: "RPC Error" }) + + await machineTranslateMessageCommand.callback({ + messageId: "validId", + sourceLanguageTag: "en", + targetLanguageTags: ["es"], + }) + + expect(msg).toHaveBeenCalledWith("Error translating message: RPC Error") + }) + + it("should return a message if no translation is available", async () => { + // @ts-expect-error + rpc.machineTranslateMessage.mockResolvedValueOnce({ data: undefined }) + + await machineTranslateMessageCommand.callback({ + messageId: "validId", + sourceLanguageTag: "en", + targetLanguageTags: ["es"], + }) + + expect(msg).toHaveBeenCalledWith("No translation available.") + }) + + it("should successfully translate and update a message", async () => { + const mockTranslation = { translatedText: "Translated content" } + // @ts-expect-error + rpc.machineTranslateMessage.mockResolvedValueOnce({ data: mockTranslation }) + + await machineTranslateMessageCommand.callback({ + messageId: "validId", + sourceLanguageTag: "en", + targetLanguageTags: ["es"], + }) + + expect(msg).toHaveBeenCalledWith("Message translated.") + }) + + it("should emit ON_DID_EDIT_MESSAGE event after successful translation", async () => { + const mockTranslation = { translatedText: "Translated content" } + // @ts-expect-error + rpc.machineTranslateMessage.mockResolvedValueOnce({ data: mockTranslation }) + + await machineTranslateMessageCommand.callback({ + messageId: "validId", + sourceLanguageTag: "en", + targetLanguageTags: ["es"], + }) + + expect(CONFIGURATION.EVENTS.ON_DID_EDIT_MESSAGE.fire).toHaveBeenCalled() + }) +}) diff --git a/inlang/source-code/ide-extension/src/commands/machineTranslate.ts b/inlang/source-code/ide-extension/src/commands/machineTranslate.ts new file mode 100644 index 0000000000..82bc3fdd27 --- /dev/null +++ b/inlang/source-code/ide-extension/src/commands/machineTranslate.ts @@ -0,0 +1,55 @@ +import { commands } from "vscode" +import { LanguageTag, Message } from "@inlang/sdk" +import { state } from "../utilities/state.js" +import { msg } from "../utilities/messages/msg.js" +import { rpc } from "@inlang/rpc" +import { CONFIGURATION } from "../configuration.js" + +export const machineTranslateMessageCommand = { + command: "sherlock.machineTranslateMessage", + title: "Sherlock: Machine Translate Message", + register: commands.registerCommand, + callback: async function ({ + messageId, + sourceLanguageTag, + targetLanguageTags, + }: { + messageId: Message["id"] + sourceLanguageTag: LanguageTag + targetLanguageTags: LanguageTag[] + }) { + // Get the message from the state + const message = state().project.query.messages.get({ where: { id: messageId } }) + if (!message) { + return msg(`Message with id ${messageId} not found.`) + } + + // Call machine translation RPC function + const result = await rpc.machineTranslateMessage({ + message, + sourceLanguageTag, + targetLanguageTags, + }) + + if (result.error) { + return msg(`Error translating message: ${result.error}`) + } + + // Update the message with the translated content + const updatedMessage = result.data + if (!updatedMessage) { + return msg("No translation available.") + } + + state().project.query.messages.upsert({ + where: { id: messageId }, + data: updatedMessage, + }) + + // Emit event to notify that a message was edited + CONFIGURATION.EVENTS.ON_DID_EDIT_MESSAGE.fire() + + // Return success message + return msg("Message translated.") + }, +} as const diff --git a/inlang/source-code/ide-extension/src/commands/openInEditor.ts b/inlang/source-code/ide-extension/src/commands/openInEditor.ts index b9a84818ff..97ddd0adaf 100644 --- a/inlang/source-code/ide-extension/src/commands/openInEditor.ts +++ b/inlang/source-code/ide-extension/src/commands/openInEditor.ts @@ -6,7 +6,7 @@ import { getGitOrigin } from "../utilities/settings/getGitOrigin.js" export const openInEditorCommand = { command: "sherlock.openInEditor", - title: "Inlang: Open in Editor", + title: "Sherlock: Open in Editor", register: commands.registerCommand, callback: async function (args: { messageId: Message["id"]; selectedProjectPath: string }) { // TODO: Probably the origin should be configurable via the config. diff --git a/inlang/source-code/ide-extension/src/commands/openProject.ts b/inlang/source-code/ide-extension/src/commands/openProject.ts index 0b04c9e4e5..3899091bdd 100644 --- a/inlang/source-code/ide-extension/src/commands/openProject.ts +++ b/inlang/source-code/ide-extension/src/commands/openProject.ts @@ -4,7 +4,7 @@ import type { NodeishFilesystem } from "@lix-js/fs" export const openProjectCommand = { command: "sherlock.openProject", - title: "Inlang: Open project", + title: "Sherlock: Open project", register: vscode.commands.registerCommand, callback: async ( node: ProjectViewNode, diff --git a/inlang/source-code/ide-extension/src/commands/openSettingsFile.ts b/inlang/source-code/ide-extension/src/commands/openSettingsFile.ts index be9a8ede74..0b7919f7fd 100644 --- a/inlang/source-code/ide-extension/src/commands/openSettingsFile.ts +++ b/inlang/source-code/ide-extension/src/commands/openSettingsFile.ts @@ -4,7 +4,7 @@ import * as path from "node:path" export const openSettingsFileCommand = { command: "sherlock.openSettingsFile", - title: "Inlang: Open settings file", + title: "Sherlock: Open settings file", register: vscode.commands.registerCommand, callback: async (node: ProjectViewNode) => { const settingsFile = vscode.Uri.file(path.join(node.path, "settings.json")) diff --git a/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.test.ts b/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.test.ts index dd7a36db21..00160ef6d0 100644 --- a/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.test.ts +++ b/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.test.ts @@ -42,7 +42,7 @@ describe("previewLanguageTagCommand", () => { it("should register the command", () => { expect(previewLanguageTagCommand.command).toBe("sherlock.previewLanguageTag") - expect(previewLanguageTagCommand.title).toBe("Inlang: Change preview language tag") + expect(previewLanguageTagCommand.title).toBe("Sherlock: Change preview language tag") expect(previewLanguageTagCommand.register).toBe(vscode.commands.registerCommand) }) diff --git a/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.ts b/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.ts index 679f816516..34149685a4 100644 --- a/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.ts +++ b/inlang/source-code/ide-extension/src/commands/previewLanguageTagCommand.ts @@ -5,7 +5,7 @@ import { CONFIGURATION } from "../configuration.js" export const previewLanguageTagCommand = { command: "sherlock.previewLanguageTag", - title: "Inlang: Change preview language tag", + title: "Sherlock: Change preview language tag", register: vscode.commands.registerCommand, callback: async () => { const settings = state().project?.settings() diff --git a/inlang/source-code/ide-extension/src/configuration.ts b/inlang/source-code/ide-extension/src/configuration.ts index d57d9520b4..e034fcbc49 100644 --- a/inlang/source-code/ide-extension/src/configuration.ts +++ b/inlang/source-code/ide-extension/src/configuration.ts @@ -9,6 +9,7 @@ import type { ErrorNode } from "./utilities/errors/errors.js" import { copyErrorCommand } from "./commands/copyError.js" import { previewLanguageTagCommand } from "./commands/previewLanguageTagCommand.js" import { jumpToPositionCommand } from "./commands/jumpToPosition.js" +import { machineTranslateMessageCommand } from "./commands/machineTranslate.js" export const CONFIGURATION = { EVENTS: { @@ -27,6 +28,7 @@ export const CONFIGURATION = { OPEN_PROJECT: openProjectCommand, OPEN_SETTINGS_FILE: openSettingsFileCommand, COPY_ERROR: copyErrorCommand, + MACHINE_TRANSLATE_MESSAGE: machineTranslateMessageCommand, }, FILES: { // TODO: remove this hardcoded assumption for multi project support diff --git a/inlang/source-code/ide-extension/src/utilities/messages/messages.ts b/inlang/source-code/ide-extension/src/utilities/messages/messages.ts index 6dea38557b..d0613e388c 100644 --- a/inlang/source-code/ide-extension/src/utilities/messages/messages.ts +++ b/inlang/source-code/ide-extension/src/utilities/messages/messages.ts @@ -294,6 +294,9 @@ export function getTranslationsTableHtml(args: { } const editCommand = `editMessage('${args.message.id}', '${escapeHtml(languageTag)}')` + const machineTranslateCommand = `machineTranslate('${args.message.id}', '${ + state().project.settings()?.sourceLanguageTag + }', ['${languageTag}'])` return `