This commit is contained in:
lbenedar
2026-03-07 18:03:00 +03:00
commit 7d612f32ee
20 changed files with 6140 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
packs/** binary

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.git/
node_modules/
Dockerfile
docker-compose.yaml
.dockerignore

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npm run lint:fix

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 120,
"tabWidth": 2
}

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# 1.2.1
- added support for custom file prompt
# 1.2.0
- added support for different API endpoints
# 1.1.1
- added support journal for 5e
- added dropdown selecting GPT model
# 1.1.0
- added support for 5E items
- added dropdown for system selection
# 1.0.7
- fixed problem with last foundry version 13.346
# 1.0.6
- Adding Github action to push release
# 1.0.5
- First working release
- Removed debug
# 1.0.1
- Kick off

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# FoundryVTT Translate ALL
![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/AlessandroSantarini/translate-all?style=for-the-badge)
![Total downloads](https://img.shields.io/github/downloads/AlessandroSantarini/translate-all/total?style=for-the-badge)
A small module for FoundryVTT that allows you to translate:
- Spells
- Items
- Abilities
- Journal Entries
into your specified language.
---
## Setup Instructions
1. Visit [https://platform.openai.com/](https://platform.openai.com/)
2. Get your API key: [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)
- ⚠️ **Important:** The key will only be shown once. Copy it and store it somewhere safe.
- Set a **spending limit**. I'm using `o4-mini`, which is very affordable. However, set a budget—I'm not responsible for any charges.
- Youll get a free trial with some usage credits.
- Costs are generally low and depend on how many words you translate. Check [OpenAI pricing](https://openai.com/pricing).
3. Enter the API key in the FoundryVTT module settings.
4. You're ready to Translate ALL!
---
## How It Works
A new **"Translate"** button will appear where translation is supported:
![Before translation](./images/before_translation.png)
After clicking it, wait a few seconds and the content will be automatically translated:
![After translation](./images/after_translation.png)
---
## Options
- if you would like to use the default prompt leave empty the selection of the prompt file
---
## Changelog
View the full changelog [HERE](./CHANGELOG.md)
---
## Contributions
Contributions are welcome! Feature development will be slow and based on community interest. For personal use, the module is already sufficient.
You can find the current to-do list [HERE](./TODO.md). Tasks are not listed in order of priority.

10
TODO.md Normal file
View File

@@ -0,0 +1,10 @@
- Fix Types
- Add spinner while translating and prevent user the interaction
- better button (use CSS and module style)
- caching same chatgpt request locally
- journal need to be manually closed (Pf2e)
- transform CRLF to LF on pre-commit
- 5ed journal not working (markdowns type)
Possible additional feature:
- text to speech

91
eslint.config.mjs Normal file
View File

@@ -0,0 +1,91 @@
import { defineConfig, globalIgnores } from "eslint/config";
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import prettier from "eslint-plugin-prettier";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import _import from "eslint-plugin-import";
import globals from "globals";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import tsParser from "@typescript-eslint/parser";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default defineConfig([globalIgnores([
"node_modules/",
"**/node_modules/",
"**/script/",
"**/eslint.config.mjs",
"**/.eslintrc.js",
"**/types/",
]), {
extends: fixupConfigRules(compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/errors",
"plugin:import/typescript",
"prettier",
)),
plugins: {
prettier,
"@typescript-eslint": fixupPluginRules(typescriptEslint),
import: fixupPluginRules(_import),
},
languageOptions: {
globals: {
...globals.browser,
},
parser: tsParser,
ecmaVersion: 2023,
sourceType: "module",
parserOptions: {
project: "./tsconfig.json",
},
},
settings: {
"import/resolver": {
node: {
paths: ["src", "types", "", "dist"],
extensions: [".css", ".js", ".json", ".jsx", ".scss", ".ts", ".tsx"],
},
"eslint-import-resolver-typescript": true,
typescript: true,
},
"import/parsers": {
"@typescript-eslint/parser": [".ts"],
},
},
rules: {
eqeqeq: ["error", "always"],
"import/no-default-export": "error",
"@typescript-eslint/no-explicit-any": "off",
'prettier/prettier': [
'error',
{
'endOfLine': 'auto',
}
],
"no-console": "error",
"spaced-comment": ["error", "always", {
markers: ["/"],
}],
},
}]);

14
lang/en.json Normal file
View File

@@ -0,0 +1,14 @@
{
"translate-all.settings.apiKey.name": "OpenAI API Key",
"translate-all.settings.apiKey.hint": "Your secret API key to access ChatGPT.",
"translate-all.settings.apiEndpoint.name": "API Endpoint",
"translate-all.settings.apiEndpoint.hint": "The endpoint to use for the API. Change this if you are using a proxy or a different compatible API.",
"translate-all.settings.language.name": "Language",
"translate-all.settings.language.hint": "The language to translate descriptions into.",
"translate-all.settings.game.system.name": "Select Game System",
"translate-all.settings.game.system.hint": "Choose the game system for which you want to translate descriptions.",
"translate-all.settings.model.name": "Target Model",
"translate-all.settings.model.hint": "The model to use for translation. Default is 'GPT-4o Mini'.",
"translate-all.settings.promptTemplatePath.name": "Prompt Template File",
"translate-all.settings.promptTemplatePath.hint": "Path to a text file containing a custom prompt (e.g., /modules/translate-all/prompts/dnd5e.txt)"
}

29
module.json Normal file
View File

@@ -0,0 +1,29 @@
{
"id": "translate-all",
"title": "Translate All",
"description": "Adds a button to translate item/spell and Journal descriptions using ChatGPT.",
"version": "1.0.0",
"type": "module",
"compatibility": {
"minimum": "13",
"maximum": "13",
"verified": "13"
},
"authors": [
{
"name": "https://gitea.local.lab/Lbenedar",
"discord": "Lbenedar"
}
],
"url": "https://gitea.local.lab/Lbenedar/translate-all",
"esmodules": [
"scripts/main.js"
],
"languages": [
{
"lang": "en",
"name": "English",
"path": "lang/en.json"
}
]
}

5406
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "translate-all",
"version": "1.2.1",
"description": "Foundry VTT module to translate item and spell descriptions using ChatGPT.",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint ./src --ext .ts",
"lint:fix": "eslint ./src --ext .ts --fix",
"build": "esbuild src/main.ts --bundle --keep-names --minify --sourcemap --outdir=scripts",
"dev": "esbuild src/main.ts --bundle --keep-names --watch --sourcemap --outdir=scripts",
"prepare": "husky"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.27.0",
"@league-of-foundry-developers/foundry-vtt-types": "^12.331.5",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.34.1",
"esbuild": "^0.27.3",
"eslint": "^9.27.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.4.1",
"fvtt-types": "npm:@league-of-foundry-developers/foundry-vtt-types@^12.331.5",
"globals": "^16.2.0",
"husky": "^9.1.7",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.0"
}
}

2
scripts/main.js Normal file

File diff suppressed because one or more lines are too long

7
scripts/main.js.map Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,58 @@
import { Directories, SupportedEntries, SupportedSystems, TranslateFunction } from "types";
import { TranslateAllSettingHandler } from "./settings-handler";
export class DataHandler {
static getDescription(app: JournalPageSheet | ItemSheet, type: SupportedEntries) {
const system = TranslateAllSettingHandler.getSetting("translate-all", "targetSystem") as SupportedSystems;
if (type === SupportedEntries.JOURNAL) {
return DataHandler.getDescriptionFromJournal(app as JournalPageSheet, system);
} else if (type === SupportedEntries.ITEM) {
return DataHandler.getDescriptionFromItem(app as ItemSheet, system);
}
return undefined;
}
static getDescriptionFromJournal(app: JournalPageSheet, system: SupportedSystems): string | undefined {
switch (system) {
case SupportedSystems.PATHFINDER2E:
return (app?.options as any).document.text.content || undefined;
case SupportedSystems.DND5E:
return (app?.options as any).document.text.content || undefined; // TODO: fix this type casting
default:
return undefined;
}
}
static getDescriptionFromItem(app: ItemSheet, system: SupportedSystems): string | undefined {
switch (system) {
case SupportedSystems.PATHFINDER2E:
return (app?.object?.system as any).description.value; // TODO: fix this type casting
case SupportedSystems.DND5E:
return (app?.options as any)?.document.system.description.value || undefined; // TODO: fix this type casting
default:
return undefined;
}
}
static getPathToUpdate(item: SupportedEntries): string {
const system = TranslateAllSettingHandler.getSetting("translate-all", "targetSystem") as SupportedSystems;
return Directories[system][item];
}
static async getTranslatedDescription(
app: JournalPageSheet | ItemSheet,
html: JQuery<HTMLElement>,
item: SupportedEntries,
translateFN: TranslateFunction,
) {
const description = DataHandler.getDescription(app, item);
if (!description) {
// Do not enable button to translate if there is no description
return;
}
const path = DataHandler.getPathToUpdate(item);
translateFN(app, html, description, path);
}
}

View File

@@ -0,0 +1,91 @@
import { Translator } from "translator";
import { SupportedSystems } from "types";
import { TranslateAllSettingHandler } from "./settings-handler";
export class HTMLHandler {
static async translateApp(
app: JournalPageSheet | ItemSheet,
html: JQuery<HTMLElement>,
description: string,
path: string,
): Promise<void> {
const htmlQuery: JQuery<HTMLElement> = html instanceof jQuery ? html : $(html);
const header = htmlQuery.find(".window-header");
if (header.find("button.translate-btn").length) return;
const btn = $(
`<button class="translate-btn header-control icon fa-solid fa-earth-americas translate-btn" type="button" data-tooltip="Перевести описание" aria-label="Перевести описание"/>`,
);
btn.on("click", async () => {
const translated = await Translator.translate(description);
if (!translated) {
ui?.notifications?.error("Translation failed or returned empty.");
return;
}
await HTMLHandler.updateDescription(app, translated, path);
});
const h1Tag = header.find("h1")
if (h1Tag) {
h1Tag.after(btn);
return
}
header.append(btn);
}
private static async updateDescription(
app: JournalPageSheet | ItemSheet,
translation: string,
path: string,
): Promise<void> {
const system = TranslateAllSettingHandler.getSetting("translate-all", "targetSystem") as SupportedSystems;
if (system === SupportedSystems.DND5E) {
await this.update5eDescription(app, translation, path);
} else if (system === SupportedSystems.PATHFINDER2E) {
await this.updatePF2EDescription(app, translation, path);
}
}
private static async update5eDescription(
app: JournalPageSheet | ItemSheet,
translation: string,
path: string,
): Promise<void> {
try {
const item = app.document;
await item.update({ [path]: translation });
app.render(true);
app.close();
} catch (error) {
ui?.notifications?.error(`Error updating item description: ${error}`);
}
}
private static async updatePF2EDescription(
app: JournalPageSheet | ItemSheet,
translation: string,
path: string,
): Promise<void> {
const item = app.object;
try {
if (path.includes("system")) {
const item = app.object
await item.update({ [path]: translation });
} else {
const item = app.options.document
await item.updateSource({ [path]: translation });
}
} catch (error) {
ui?.notifications?.error(`Error updating item description: ${error}`);
}
if (path.includes("system")) {
app.object.render(true);
await app.object.sheet?.close();
}
await app.render(true);
await app.close();
}
}

View File

@@ -0,0 +1,108 @@
import { Translator } from "../translator";
import { KeyFor, SupportedSystems, TranslateAllNamespace } from "../types";
export class TranslateAllSettingHandler {
gameSettings: Game["settings"] = game.settings!;
readonly settings = {
targetSystem: {
name: "translate-all.settings.game.system.name",
hint: "translate-all.settings.game.system.hint",
scope: "world",
config: true,
type: String,
default: SupportedSystems.PATHFINDER2E, // Default to Pathfinder 2e
choices: {
[SupportedSystems.DND5E]: "D&D 5e",
[SupportedSystems.PATHFINDER2E]: "Pathfinder 2e",
},
},
apiEndpoint: {
name: "translate-all.settings.apiEndpoint.name",
hint: "translate-all.settings.apiEndpoint.hint",
scope: "world",
config: true,
type: String,
default: "https://api.openai.com/v1",
},
targetLanguage: {
name: "translate-all.settings.language.name",
hint: "translate-all.settings.language.hint",
scope: "world",
config: true,
type: String,
default: "Italian", // Default to Italian
masked: true,
},
targetModel: {
name: "translate-all.settings.model.name",
hint: "translate-all.settings.model.hint",
scope: "world",
config: true,
type: String,
default: "gpt-4o-mini",
choices: {},
},
promptModel: {
name: "translate-all.settings.promptTemplatePath.name",
hint: "translate-all.settings.promptTemplatePath.hint",
scope: "world",
config: true,
type: String,
filePicker: true, // 👈 Enables the file picker
default: "",
},
};
constructor() {}
async init(): Promise<void> {
await this._registerSettings();
}
private async _registerSettings(): Promise<void> {
this._register(
"translate-all" as TranslateAllNamespace,
"targetSystem" as KeyFor<TranslateAllNamespace>,
this.settings.targetSystem,
);
this._register(
"translate-all" as TranslateAllNamespace,
"apiEndpoint" as KeyFor<TranslateAllNamespace>,
this.settings.apiEndpoint,
);
this._register(
"translate-all" as TranslateAllNamespace,
"targetLanguage" as KeyFor<TranslateAllNamespace>,
this.settings.targetLanguage,
);
const models = await Translator.getModels();
if (models) {
this.settings.targetModel.choices = models;
}
this._register(
"translate-all" as TranslateAllNamespace,
"targetModel" as KeyFor<TranslateAllNamespace>,
this.settings.targetModel,
);
this._register(
"translate-all" as TranslateAllNamespace,
"promptTemplatePath" as KeyFor<TranslateAllNamespace>,
this.settings.promptModel,
);
}
// TODO: Fix this type casting
_register(namespace: TranslateAllNamespace, key: KeyFor<TranslateAllNamespace>, config: any): void {
this.gameSettings.register(namespace as "core", key as KeyFor<"core">, config);
}
// TODO: Fix this type casting
static getSetting(
namespace: TranslateAllNamespace,
key: KeyFor<TranslateAllNamespace>,
): string | boolean | number | object | undefined {
const gameSettings = game.settings!;
return gameSettings.get(namespace as "core", key as KeyFor<"core">);
}
}

29
src/main.ts Normal file
View File

@@ -0,0 +1,29 @@
import { DataHandler } from "handlers/data-handler";
import { HTMLHandler } from "handlers/html-handler";
import { TranslateAllSettingHandler } from "handlers/settings-handler";
import { SupportedEntries, SupportedSystems } from "types";
Hooks.once("init", async () => {
if (!game.settings) {
ui?.notifications?.error(`Game settings are not available. This module requires Foundry VTT version 10 or later.`);
return;
}
const settingHandler = new TranslateAllSettingHandler();
await settingHandler.init();
});
Hooks.on("renderJournalPageSheet", async (app: JournalPageSheet, html: JQuery<HTMLElement>) => {
DataHandler.getTranslatedDescription(app, html, SupportedEntries.JOURNAL, HTMLHandler.translateApp);
});
Hooks.on("renderItemSheet", async (app: ItemSheet, html: JQuery<HTMLElement>) => {
DataHandler.getTranslatedDescription(app, html, SupportedEntries.ITEM, HTMLHandler.translateApp);
});
Hooks.on("renderItemSheet5e", async (app: ItemSheet, html: JQuery<HTMLElement>) => {
DataHandler.getTranslatedDescription(app, html, SupportedEntries.ITEM, HTMLHandler.translateApp);
});
Hooks.on("renderJournalEntryPageSheet", async (app: JournalPageSheet, html: JQuery<HTMLElement>) => {
DataHandler.getTranslatedDescription(app, html, SupportedEntries.JOURNAL, HTMLHandler.translateApp);
});

112
src/translator.ts Normal file
View File

@@ -0,0 +1,112 @@
import { TranslateAllSettingHandler } from "handlers/settings-handler";
import { SupportedLanguages, SupportedSystems } from "types";
export class Translator {
static async translate(description: string): Promise<string | undefined> {
return await Translator.translateWithChatLlama(description);
}
static async getPromptTemplate(path: string, description: string): Promise<string> {
const promptTemplatePath = TranslateAllSettingHandler.getSetting("translate-all", "promptTemplatePath") as string;
if (!promptTemplatePath) {
return "";
}
let promptTemplate = "";
if (promptTemplatePath) {
try {
const url = foundry.utils.getRoute(promptTemplatePath);
promptTemplate = await fetch(url).then((x) => x.text());
} catch (err) {
ui?.notifications?.warn(`Could not load prompt template. ${err}`);
}
}
return promptTemplate + `: ${description}`;
}
static async generatePrompt(
system: SupportedSystems,
language: SupportedLanguages,
description: string,
): Promise<string> {
const path = TranslateAllSettingHandler.getSetting("translate-all", "promptTemplatePath") as string;
let prompt = "";
if (path) {
prompt = await Translator.getPromptTemplate(path, description);
} else {
prompt = `Translate the following ${system} description into ${language}:\
You recieve data formatted in HTML. Don't change format and structure, like HTML tags. Don't add new HTML tags and don't change the nesting of HTML tags.\
If quotation marks are used, they must be closed. Don't add extra quotation marks.\
Do not add any additional code encapsulation or formatting. Just return the translated text.\
${description}.`;
}
return prompt;
}
static async getModels(): Promise<Record<string, string> | undefined> {
let response;
const apiEndpoint = TranslateAllSettingHandler.getSetting("translate-all", "apiEndpoint");
try {
response = await fetch(`${apiEndpoint}/api/tags`, {
method: "GET",
});
} catch (error) {
ui?.notifications?.error(`Ollama API call failed. ${error}`);
}
if (!response?.ok) {
ui?.notifications?.error("Ollama API call failed.");
return undefined;
}
const data = await response.json();
const models = data.models.reduce((acc: Record<string, string>, model: { model: string }) => {
acc[model.model] = model.model;
return acc;
}, {});
return models;
}
static async translateWithChatLlama(description: string): Promise<string | undefined> {
let response;
const apiEndpoint = TranslateAllSettingHandler.getSetting("translate-all", "apiEndpoint");
const system = TranslateAllSettingHandler.getSetting("translate-all", "targetSystem") as SupportedSystems;
const language = TranslateAllSettingHandler.getSetting("translate-all", "targetLanguage") as SupportedLanguages;
const model = TranslateAllSettingHandler.getSetting("translate-all", "targetModel");
const prompt = await Translator.generatePrompt(system, language, description);
try {
const body = JSON.stringify({
model: model,
prompt: prompt,
stream: false,
})
console.log(`Body. ${body}`);
response = await fetch(`${apiEndpoint}/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body,
});
} catch (error) {
ui?.notifications?.error(`Ollama API call failed. ${error}`);
}
if (!response?.ok) {
ui?.notifications?.error(`Ollama API call failed. ${response?.status}`);
return undefined;
}
const data = await response.json();
let responseData = data?.response
if (responseData.startsWith("```html")) {
// Get the part of the string starting from the index immediately after the prefix
responseData = responseData.substring("\`\`\`html\n".length, responseData.length - "\n\`\`\`".length);
}
console.log(`Response after cut: ${responseData}`);
return responseData ?? undefined;
}
}

57
src/types/index.ts Normal file
View File

@@ -0,0 +1,57 @@
export const MODULE_NAME = 'translate-all';
export interface TranslateConfigSettingConfig {
'translate-all.apiKey': string;
'translate-all.targetSystem': string;
'translate-all.targetLanguage': string;
'translate-all.targetModel': string;
'translate-all.apiEndpoint': string;
'translate-all.promptTemplatePath': string;
}
export type TranslateAllNamespace = typeof MODULE_NAME | ClientSettings.Namespace;
type GetKeys<
N extends string,
SettingPath extends PropertyKey,
> = SettingPath extends `${N}.${infer Name}` ? Name : never;
export type KeyFor<N extends TranslateAllNamespace> = GetKeys<
N,
keyof TranslateConfigSettingConfig
>;
export interface TranslateFunction {
(
app: JournalPageSheet | ItemSheet,
html: JQuery<HTMLElement>,
description: string,
path: string,
): Promise<void>;
}
export enum SupportedSystems {
DND5E = 'D&D5E',
PATHFINDER2E = 'PF2E',
}
export enum SupportedLanguages {
ENGLISH = 'english',
ITALIAN = 'italian',
RUSSIAN = 'russian',
}
export enum SupportedEntries {
JOURNAL = 'journal',
ITEM = 'item',
}
export const Directories = {
[SupportedSystems.DND5E]: {
[SupportedEntries.JOURNAL]: 'text.content',
[SupportedEntries.ITEM]: 'system.description.value',
},
[SupportedSystems.PATHFINDER2E]: {
[SupportedEntries.JOURNAL]: 'text.content',
[SupportedEntries.ITEM]: 'system.description.value',
},
};