minecodex 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ MineCodex 给 Codex Desktop 增加一组轻量、本地优先的扩展功能。
17
17
 
18
18
  ## 安装
19
19
 
20
- MineCodex 目前仅支持 macOS 系统,需要 Node.js 22.5 或更高版本。安装程序会自动扫描 `/Applications/ChatGPT.app` 或 `~/Applications/ChatGPT.app` 寻找 Codex Desktop;如果两处均未找到,安装将暂停并给出说明。
20
+ MineCodex 支持 macOS 与 Windows 系统,需要 Node.js 22.5 或更高版本。安装程序会自动扫描 `/Applications/ChatGPT.app` 或 `~/Applications/ChatGPT.app` 寻找 Codex Desktop;在 Windows 上则通过 MSIX 包(`OpenAI.Codex`)定位官方 ChatGPT 应用并校验 OpenAI 签名。如果均未找到,安装将暂停并给出说明。
21
21
 
22
22
  ```bash
23
23
  npm install -g minecodex
@@ -28,7 +28,7 @@ mcx install
28
28
 
29
29
  `mcx install`、`mcx update`、`mcx restart` 和 `mcx uninstall` 会在终端持续显示当前阶段;`mcx open` 仅在需要启动后台服务时显示进度。查询类命令保持简洁,便于直接阅读或用于脚本。
30
30
 
31
- MineCodex 只以后台辅助服务运行,前台应用始终是 OpenAI 签名的官方 `ChatGPT.app`,并继续使用官方默认用户数据。通过 Dock、Finder、Spotlight、登录项或会话恢复打开 ChatGPT 时,后台服务会验证官方应用与对应进程;若该进程尚未开放兼容的本机调试端口,则确认其完整退出后再通过 LaunchServices 重新打开官方应用并注入功能。MineCodex 不修改 `ChatGPT.app`、`app.asar`、Dock 项目或默认打开方式。
31
+ MineCodex 只以后台辅助服务运行,前台应用始终是 OpenAI 签名的官方 ChatGPT 应用,并继续使用官方默认用户数据。通过 Dock、Finder、Spotlight、登录项或会话恢复打开 ChatGPT 时,后台服务会验证官方应用与对应进程;若该进程尚未开放兼容的本机调试端口,则确认其完整退出后再重新打开官方应用并注入功能。MineCodex 不修改官方应用文件、`app.asar`、Dock 项目或默认打开方式。在 macOS 上,后台项目出现在"登录项与扩展"中;在 Windows 上,后台任务出现在任务计划程序(任务名 `MineCodex`)。
32
32
 
33
33
  打开本地控制台:
34
34
 
@@ -17,6 +17,7 @@ const MAX_EDIT_AUXILIARY_BYTES = 8 * 1024 * 1024;
17
17
  const STATIC_FILES = new Map([
18
18
  ["/", ["index.html", "text/html; charset=utf-8"]],
19
19
  ["/app.js", ["app.js", "text/javascript; charset=utf-8"]],
20
+ ["/i18n.mjs", ["i18n.mjs", "text/javascript; charset=utf-8"]],
20
21
  ["/styles.css", ["styles.css", "text/css; charset=utf-8"]],
21
22
  ]);
22
23
 
@@ -397,7 +398,7 @@ export async function createHttpServer({
397
398
  json(response, 404, { error: "Image not found" });
398
399
  return;
399
400
  }
400
- json(response, 200, await saveImageAs(image));
401
+ json(response, 200, await saveImageAs(image, { locale: url.searchParams.get("locale") ?? "en" }));
401
402
  return;
402
403
  }
403
404
 
@@ -7,7 +7,7 @@ const run = promisify(execFile);
7
7
  const SAVE_DIALOG_SCRIPT = `
8
8
  on run argv
9
9
  try
10
- set destinationFile to choose file name with prompt "Save image as" default name (item 1 of argv)
10
+ set destinationFile to choose file name with prompt (item 2 of argv) default name (item 1 of argv)
11
11
  return POSIX path of destinationFile
12
12
  on error number -128
13
13
  return ""
@@ -15,6 +15,23 @@ on run argv
15
15
  end run
16
16
  `;
17
17
 
18
+ // PowerShell 单引号字面量:内部单引号翻倍转义。
19
+ function psSingleQuoted(value) {
20
+ return "'" + String(value).replaceAll("'", "''") + "'";
21
+ }
22
+
23
+ function buildWindowsSaveDialogScript(fileName, title, chinese) {
24
+ return [
25
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
26
+ "Add-Type -AssemblyName System.Windows.Forms | Out-Null",
27
+ "$dialog = New-Object System.Windows.Forms.SaveFileDialog",
28
+ "$dialog.Title = " + psSingleQuoted(title),
29
+ "$dialog.FileName = " + psSingleQuoted(fileName),
30
+ "$dialog.Filter = " + psSingleQuoted(chinese ? "PNG 图片 (*.png)|*.png" : "PNG image (*.png)|*.png"),
31
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dialog.FileName) }",
32
+ ].join("; ");
33
+ }
34
+
18
35
  export function defaultImageFilename(threadTitle) {
19
36
  const rawTitle = String(threadTitle || "Generated image").replace(/\.png$/i, "");
20
37
  const safeTitle = rawTitle
@@ -26,13 +43,25 @@ export function defaultImageFilename(threadTitle) {
26
43
  return `${safeTitle || "Generated image"}.png`;
27
44
  }
28
45
 
29
- export async function saveImageAs(image) {
30
- const { stdout } = await run(
31
- "/usr/bin/osascript",
32
- ["-e", SAVE_DIALOG_SCRIPT, defaultImageFilename(image.threadTitle)],
33
- { encoding: "utf8" },
34
- );
35
- const destinationPath = stdout.trim();
46
+ export async function saveImageAs(image, { locale = "en" } = {}) {
47
+ const chinese = String(locale).toLowerCase().startsWith("zh");
48
+ const title = chinese ? "图片另存为" : "Save image as";
49
+ let destinationPath;
50
+ if (process.platform === "win32") {
51
+ const { stdout } = await run(
52
+ "powershell.exe",
53
+ ["-NoProfile", "-STA", "-Command", buildWindowsSaveDialogScript(defaultImageFilename(image.threadTitle), title, chinese)],
54
+ { encoding: "utf8" },
55
+ );
56
+ destinationPath = stdout.trim();
57
+ } else {
58
+ const { stdout } = await run(
59
+ "/usr/bin/osascript",
60
+ ["-e", SAVE_DIALOG_SCRIPT, defaultImageFilename(image.threadTitle), title],
61
+ { encoding: "utf8" },
62
+ );
63
+ destinationPath = stdout.trim();
64
+ }
36
65
  if (!destinationPath) return { saved: false, canceled: true };
37
66
 
38
67
  await copyFile(image.sourcePath, destinationPath);
@@ -1,3 +1,5 @@
1
+ import { getLocale, setLocale, t, translateDocument } from "./i18n.mjs";
2
+
1
3
  const shell = document.querySelector(".library-shell");
2
4
  const libraryHeader = document.querySelector(".library-header");
3
5
  const sidebarOpenButton = document.querySelector(".sidebar-open-button");
@@ -112,6 +114,13 @@ try {
112
114
 
113
115
  function applyHostTheme(payload) {
114
116
  if (!payload || !new Set(["light", "dark"]).has(payload.theme)) return;
117
+ if (setLocale(payload.locale ?? navigator.language)) {
118
+ translateDocument(document);
119
+ renderSourceMenu();
120
+ render();
121
+ if (activeImage) showImageDetails(activeImage);
122
+ if (editMode) renderEditMode();
123
+ }
115
124
  root.dataset.host = "codex";
116
125
  root.dataset.theme = payload.theme;
117
126
  root.style.colorScheme = payload.theme;
@@ -148,7 +157,7 @@ function applySidebarState(payload) {
148
157
  const open = Boolean(payload?.open);
149
158
  const label = typeof payload?.label === "string" && payload.label.trim()
150
159
  ? payload.label.trim()
151
- : "Show sidebar";
160
+ : t("Show sidebar");
152
161
  const visible = available && !open;
153
162
  const leading = Number(payload?.leading);
154
163
  sidebarOpenButton.hidden = !visible;
@@ -172,16 +181,16 @@ if (window.parent !== window) {
172
181
  window.parent.postMessage({ type: "codex-personal:ready" }, "*");
173
182
  }
174
183
 
175
- const dateFormatter = new Intl.DateTimeFormat(undefined, {
184
+ const dateFormatter = { format: (value) => new Intl.DateTimeFormat(getLocale(), {
176
185
  year: "numeric",
177
186
  month: "short",
178
187
  day: "numeric",
179
188
  hour: "2-digit",
180
189
  minute: "2-digit",
181
- });
190
+ }).format(value) };
182
191
 
183
192
  function formatBytes(bytes) {
184
- if (!Number.isFinite(bytes)) return "Original file";
193
+ if (!Number.isFinite(bytes)) return t("Original file");
185
194
  if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
186
195
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
187
196
  }
@@ -204,12 +213,12 @@ function createSourceIcon(kind) {
204
213
 
205
214
  function sourcePresentation(image) {
206
215
  if (image.sourceKind === "project") {
207
- return { kind: "project", label: image.projectName || "Project" };
216
+ return { kind: "project", label: image.projectName || t("Project") };
208
217
  }
209
218
  if (image.sourceKind === "work") {
210
- return { kind: "work", label: image.threadTitle || "Work" };
219
+ return { kind: "work", label: image.threadTitle || t("Work") };
211
220
  }
212
- return { kind: "chat", label: image.threadTitle || "Untitled chat" };
221
+ return { kind: "chat", label: image.threadTitle || t("Untitled chat") };
213
222
  }
214
223
 
215
224
  function createSourceValue(kind, label, className = "") {
@@ -222,13 +231,13 @@ function createSourceValue(kind, label, className = "") {
222
231
  }
223
232
 
224
233
  function selectedFacet() {
225
- if (selectedScope === "all") return { label: "All images", count: facets.total, kind: "all" };
226
- if (selectedScope === "chat") return { label: "Chats", count: facets.chats, kind: "chat" };
227
- if (selectedScope === "work") return { label: "Work", count: facets.works, kind: "work" };
234
+ if (selectedScope === "all") return { label: t("All images"), count: facets.total, kind: "all" };
235
+ if (selectedScope === "chat") return { label: t("Chats"), count: facets.chats, kind: "chat" };
236
+ if (selectedScope === "work") return { label: t("Work"), count: facets.works, kind: "work" };
228
237
  const project = facets.projects.find((item) => item.scope === selectedScope);
229
238
  return project
230
239
  ? { label: project.name, count: project.count, kind: "project" }
231
- : { label: "All images", count: facets.total, kind: "all" };
240
+ : { label: t("All images"), count: facets.total, kind: "all" };
232
241
  }
233
242
 
234
243
  function updateFilterTrigger() {
@@ -236,7 +245,7 @@ function updateFilterTrigger() {
236
245
  filterSourceIcon.replaceChildren(createSourceIcon(selected.kind));
237
246
  filterLabel.textContent = selected.label;
238
247
  filterCount.textContent = String(selected.count);
239
- filterButton.title = `${selected.label} · ${selected.count} images`;
248
+ filterButton.title = t("{source} · {count} images", { source: selected.label, count: selected.count });
240
249
  }
241
250
 
242
251
  function createSourceOption({ scope, label, count, kind }) {
@@ -263,9 +272,9 @@ function createSourceOption({ scope, label, count, kind }) {
263
272
  }
264
273
 
265
274
  function renderSourceMenu() {
266
- const all = createSourceOption({ scope: "all", label: "All images", count: facets.total, kind: "all" });
267
- const chats = createSourceOption({ scope: "chat", label: "Chats", count: facets.chats, kind: "chat" });
268
- const work = createSourceOption({ scope: "work", label: "Work", count: facets.works, kind: "work" });
275
+ const all = createSourceOption({ scope: "all", label: t("All images"), count: facets.total, kind: "all" });
276
+ const chats = createSourceOption({ scope: "chat", label: t("Chats"), count: facets.chats, kind: "chat" });
277
+ const work = createSourceOption({ scope: "work", label: t("Work"), count: facets.works, kind: "work" });
269
278
  const divider = document.createElement("div");
270
279
  divider.className = "source-menu-divider";
271
280
  divider.setAttribute("role", "separator");
@@ -310,7 +319,7 @@ function openSourceMenu({ focusIndex = null } = {}) {
310
319
 
311
320
  async function loadFacets() {
312
321
  const response = await fetch("/api/image-facets");
313
- if (!response.ok) throw new Error("Could not load image sources");
322
+ if (!response.ok) throw new Error(t("Could not load image sources"));
314
323
  facets = await response.json();
315
324
  if (
316
325
  selectedScope !== "all"
@@ -388,11 +397,11 @@ function createCard(image) {
388
397
  button.dataset.imageId = image.id;
389
398
  const createdAt = dateFormatter.format(new Date(image.createdAt));
390
399
  const source = sourcePresentation(image);
391
- button.setAttribute("aria-label", `Open image from ${source.label}, created ${createdAt}`);
400
+ button.setAttribute("aria-label", t("Open image from {source}, created {date}", { source: source.label, date: createdAt }));
392
401
 
393
402
  const preview = document.createElement("img");
394
403
  preview.src = image.fileUrl;
395
- preview.alt = image.prompt || "Generated image";
404
+ preview.alt = image.prompt || t("Generated image");
396
405
  preview.loading = "lazy";
397
406
  preview.decoding = "async";
398
407
  if (image.width && image.height) {
@@ -420,12 +429,12 @@ function createThreadBoard(group) {
420
429
  button.type = "button";
421
430
  button.dataset.threadId = group.threadId;
422
431
  const sourceLabel = group.sourceKind === "project"
423
- ? (group.projectName || "Project")
424
- : group.sourceKind === "work" ? "Work" : "Chat";
432
+ ? (group.projectName || t("Project"))
433
+ : group.sourceKind === "work" ? t("Work") : t("Chat");
425
434
  const threadTitle = group.threadTitle || (
426
- group.sourceKind === "project" ? "Untitled task" : group.sourceKind === "work" ? "Untitled work" : "Untitled chat"
435
+ group.sourceKind === "project" ? t("Untitled task") : group.sourceKind === "work" ? t("Untitled work") : t("Untitled chat")
427
436
  );
428
- button.setAttribute("aria-label", `Open ${threadTitle}, ${group.imageCount} images`);
437
+ button.setAttribute("aria-label", t("Open {title}, {count} images", { title: threadTitle, count: group.imageCount }));
429
438
 
430
439
  const preview = document.createElement("span");
431
440
  preview.className = "thread-board-preview";
@@ -464,7 +473,7 @@ function createThreadBoard(group) {
464
473
  const meta = document.createElement("span");
465
474
  meta.className = "thread-board-meta";
466
475
  const imageCount = document.createElement("span");
467
- imageCount.textContent = `${group.imageCount} ${group.imageCount === 1 ? "image" : "images"}`;
476
+ imageCount.textContent = t(group.imageCount === 1 ? "{count} image" : "{count} images", { count: group.imageCount });
468
477
  const updatedAt = document.createElement("span");
469
478
  updatedAt.textContent = dateFormatter.format(new Date(group.updatedAt));
470
479
  meta.append(imageCount, updatedAt);
@@ -478,17 +487,17 @@ function createThreadBoard(group) {
478
487
  function updateEmptyState() {
479
488
  empty.hidden = totalItems !== 0;
480
489
  if (viewContext === "thread") {
481
- emptyTitle.textContent = "No images in this Thread";
482
- emptyDescription.textContent = "Return to the library and choose another Thread.";
490
+ emptyTitle.textContent = t("No images in this Thread");
491
+ emptyDescription.textContent = t("Return to the library and choose another Thread.");
483
492
  return;
484
493
  }
485
494
  const selected = selectedFacet();
486
495
  if (selectedScope === "all") {
487
- emptyTitle.textContent = "No images yet";
488
- emptyDescription.textContent = "Generate an image in any Task and it will appear here automatically.";
496
+ emptyTitle.textContent = t("No images yet");
497
+ emptyDescription.textContent = t("Generate an image in any Task and it will appear here automatically.");
489
498
  } else {
490
- emptyTitle.textContent = `No images in ${selected.label}`;
491
- emptyDescription.textContent = "Choose another source or generate a new image in this Task.";
499
+ emptyTitle.textContent = t("No images in {source}", { source: selected.label });
500
+ emptyDescription.textContent = t("Choose another source or generate a new image in this Task.");
492
501
  }
493
502
  }
494
503
 
@@ -529,7 +538,7 @@ async function loadPage({ reset = false, preserveScroll = false } = {}) {
529
538
  const preservedScrollTop = reset && preserveScroll ? scrollArea.scrollTop : null;
530
539
  const request = (async () => {
531
540
  const response = await fetch(pageUrl({ offset }));
532
- if (!response.ok) throw new Error("Could not load images");
541
+ if (!response.ok) throw new Error(t("Could not load images"));
533
542
  const payload = await response.json();
534
543
  if (generation !== loadGeneration) return;
535
544
 
@@ -584,7 +593,7 @@ async function toggleThreadGrouping() {
584
593
  async function checkForUpdates() {
585
594
  if (!canPollForUpdates()) return;
586
595
  const response = await fetch(pageUrl({ offset: 0, limit: 1 }));
587
- if (!response.ok) throw new Error("Could not check for new images");
596
+ if (!response.ok) throw new Error(t("Could not check for new images"));
588
597
  const payload = await response.json();
589
598
  const nextItems = groupByThread ? payload.groups : payload.images;
590
599
  const nextFirst = nextItems[0];
@@ -671,8 +680,8 @@ function resetCopyFeedback() {
671
680
  promptCopyIcon.removeAttribute("hidden");
672
681
  promptCheckIcon.setAttribute("hidden", "");
673
682
  promptCopyButton.classList.remove("is-copied");
674
- promptCopyButton.setAttribute("aria-label", "Copy generation prompt");
675
- promptCopyButton.title = "Copy prompt";
683
+ promptCopyButton.setAttribute("aria-label", t("Copy generation prompt"));
684
+ promptCopyButton.title = t("Copy prompt");
676
685
  }
677
686
 
678
687
  function updateSourceAction(image) {
@@ -684,11 +693,11 @@ function updateSourceAction(image) {
684
693
  if (unavailable) {
685
694
  sourceButton.setAttribute("aria-describedby", sourceTooltip.id);
686
695
  sourceTooltip.textContent = archived
687
- ? "This Thread has been archived and can’t be opened."
688
- : "This ChatGPT conversation cannot be opened from Images.";
696
+ ? t("This Thread has been archived and can’t be opened.")
697
+ : t("This ChatGPT conversation cannot be opened from Images.");
689
698
  sourceButton.setAttribute("aria-label", archived
690
- ? "Open Thread unavailable: this Thread has been archived"
691
- : "Open conversation unavailable");
699
+ ? t("Open Thread unavailable: this Thread has been archived")
700
+ : t("Open conversation unavailable"));
692
701
  } else {
693
702
  sourceButton.removeAttribute("aria-describedby");
694
703
  sourceButton.removeAttribute("aria-label");
@@ -796,11 +805,11 @@ function showImageDetails(image) {
796
805
  const isProject = image.sourceKind === "project";
797
806
  const isWork = image.sourceKind === "work";
798
807
  drawerProjectRow.hidden = !isProject;
799
- if (isProject) drawerProject.textContent = image.projectName || "Project";
800
- drawerTaskLabel.textContent = isProject ? "Thread" : isWork ? "Work" : "Chat";
801
- drawerThread.textContent = image.threadTitle || (isProject ? "Untitled task" : isWork ? "Untitled work" : "Untitled chat");
808
+ if (isProject) drawerProject.textContent = image.projectName || t("Project");
809
+ drawerTaskLabel.textContent = isProject ? t("Thread") : isWork ? t("Work") : t("Chat");
810
+ drawerThread.textContent = image.threadTitle || (isProject ? t("Untitled task") : isWork ? t("Untitled work") : t("Untitled chat"));
802
811
  drawerImage.src = image.fileUrl;
803
- drawerImage.alt = image.prompt || "Generated image";
812
+ drawerImage.alt = image.prompt || t("Generated image");
804
813
  if (image.width && image.height) {
805
814
  drawerImage.width = image.width;
806
815
  drawerImage.height = image.height;
@@ -809,9 +818,9 @@ function showImageDetails(image) {
809
818
  drawerImage.removeAttribute("height");
810
819
  }
811
820
  drawerDate.textContent = dateFormatter.format(new Date(image.createdAt));
812
- drawerSize.textContent = image.width && image.height ? `${image.width} × ${image.height}` : "Original";
821
+ drawerSize.textContent = image.width && image.height ? `${image.width} × ${image.height}` : t("Original");
813
822
  drawerFileSize.textContent = formatBytes(image.byteLength);
814
- drawerPrompt.textContent = image.prompt || "Prompt is not available for this image.";
823
+ drawerPrompt.textContent = image.prompt || t("Prompt is not available for this image.");
815
824
  drawerPrompt.classList.toggle("is-missing", !image.prompt);
816
825
  promptCopyButton.disabled = !image.prompt;
817
826
  updateSourceAction(image);
@@ -822,7 +831,7 @@ async function openImage(image, originatingElement = null) {
822
831
  if (originatingElement) rememberDrawerOrigin(originatingElement);
823
832
  const request = ++detailRequest;
824
833
  showImageDetails(image);
825
- drawerPrompt.textContent = "Loading generation prompt…";
834
+ drawerPrompt.textContent = t("Loading generation prompt…");
826
835
  drawerPrompt.classList.add("is-loading");
827
836
  drawerPrompt.classList.remove("is-missing");
828
837
  promptCopyButton.disabled = true;
@@ -836,15 +845,15 @@ async function openImage(image, originatingElement = null) {
836
845
 
837
846
  try {
838
847
  const response = await fetch(`/api/images/${image.id}`);
839
- if (!response.ok) throw new Error("Could not load image details");
848
+ if (!response.ok) throw new Error(t("Could not load image details"));
840
849
  const payload = await response.json();
841
850
  if (request !== detailRequest) return;
842
851
  showImageDetails(payload.image);
843
852
  } catch (error) {
844
853
  if (request !== detailRequest) return;
845
- drawerPrompt.textContent = "Prompt could not be loaded.";
854
+ drawerPrompt.textContent = t("Prompt could not be loaded.");
846
855
  drawerPrompt.classList.add("is-missing");
847
- announce("Prompt could not be loaded.");
856
+ announce(t("Prompt could not be loaded."));
848
857
  console.error(error);
849
858
  } finally {
850
859
  if (request === detailRequest) drawerPrompt.classList.remove("is-loading");
@@ -1017,9 +1026,9 @@ rescan.addEventListener("click", async () => {
1017
1026
  await Promise.all([
1018
1027
  (async () => {
1019
1028
  const response = await fetch("/api/rescan", { method: "POST" });
1020
- if (!response.ok) throw new Error("Could not refresh images");
1029
+ if (!response.ok) throw new Error(t("Could not refresh images"));
1021
1030
  const result = await response.json();
1022
- announce(result.inProgress ? "Image refresh is already in progress." : "Images refreshed.");
1031
+ announce(result.inProgress ? t("Image refresh is already in progress.") : t("Images refreshed."));
1023
1032
  await loadFacets();
1024
1033
  if (shell.classList.contains("drawer-open") && activeImage) {
1025
1034
  const detailResponse = await fetch(`/api/images/${activeImage.id}`);
@@ -1053,16 +1062,16 @@ promptCopyButton.addEventListener("click", async () => {
1053
1062
  const prompt = activeImage?.prompt;
1054
1063
  if (!prompt) return;
1055
1064
  if (!(await copyText(prompt))) {
1056
- announce("Prompt could not be copied.");
1065
+ announce(t("Prompt could not be copied."));
1057
1066
  return;
1058
1067
  }
1059
1068
  resetCopyFeedback();
1060
1069
  promptCopyIcon.setAttribute("hidden", "");
1061
1070
  promptCheckIcon.removeAttribute("hidden");
1062
1071
  promptCopyButton.classList.add("is-copied");
1063
- promptCopyButton.setAttribute("aria-label", "Prompt copied");
1064
- promptCopyButton.title = "Copied";
1065
- announce("Prompt copied.");
1072
+ promptCopyButton.setAttribute("aria-label", t("Prompt copied"));
1073
+ promptCopyButton.title = t("Copied");
1074
+ announce(t("Prompt copied."));
1066
1075
  copyFeedbackTimer = setTimeout(resetCopyFeedback, 2000);
1067
1076
  });
1068
1077
 
@@ -1070,14 +1079,14 @@ saveButton.addEventListener("click", async () => {
1070
1079
  if (!activeImage) return;
1071
1080
  const imageId = activeImage.id;
1072
1081
  saveButton.disabled = true;
1073
- saveButton.textContent = "Saving…";
1082
+ saveButton.textContent = t("Saving…");
1074
1083
  try {
1075
- const response = await fetch(`/api/images/${imageId}/save-as`, { method: "POST" });
1076
- if (!response.ok) throw new Error("Could not save image");
1084
+ const response = await fetch(`/api/images/${imageId}/save-as?locale=${getLocale()}`, { method: "POST" });
1085
+ if (!response.ok) throw new Error(t("Could not save image"));
1077
1086
  const result = await response.json();
1078
1087
  if (result.saved) {
1079
- saveButton.textContent = "Saved";
1080
- announce("Image saved.");
1088
+ saveButton.textContent = t("Saved");
1089
+ announce(t("Image saved."));
1081
1090
  await new Promise((resolve) => setTimeout(resolve, 1200));
1082
1091
  }
1083
1092
  } catch (error) {
@@ -1085,7 +1094,7 @@ saveButton.addEventListener("click", async () => {
1085
1094
  console.error(error);
1086
1095
  } finally {
1087
1096
  saveButton.disabled = false;
1088
- saveButton.textContent = "Save as";
1097
+ saveButton.textContent = t("Save as");
1089
1098
  }
1090
1099
  });
1091
1100
 
@@ -1170,19 +1179,19 @@ function resetZoom() {
1170
1179
  }
1171
1180
 
1172
1181
  function requestHostAction(action, payload) {
1173
- if (window.parent === window) return Promise.reject(new Error("Image editing is available inside Codex only."));
1182
+ if (window.parent === window) return Promise.reject(new Error(t("Image editing is available inside Codex only.")));
1174
1183
  const requestId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
1175
1184
  return new Promise((resolve, reject) => {
1176
1185
  const timeout = setTimeout(() => {
1177
1186
  window.removeEventListener("message", onResult);
1178
- reject(new Error("Codex did not respond to the image edit request."));
1187
+ reject(new Error(t("Codex did not respond to the image edit request.")));
1179
1188
  }, 30_000);
1180
1189
  function onResult(event) {
1181
1190
  if (event.source !== window.parent || event.data?.type !== "codex-personal:host-result" || event.data?.requestId !== requestId) return;
1182
1191
  clearTimeout(timeout);
1183
1192
  window.removeEventListener("message", onResult);
1184
1193
  if (event.data.ok) resolve(event.data.result);
1185
- else reject(new Error(event.data.error?.message || "Codex could not start the image edit."));
1194
+ else reject(new Error(event.data.error?.message || t("Codex could not start the image edit.")));
1186
1195
  }
1187
1196
  window.addEventListener("message", onResult);
1188
1197
  window.parent.postMessage({ type: "codex-personal:host-action", requestId, action, payload }, "*");
@@ -1237,14 +1246,14 @@ function removeMaskPng() {
1237
1246
  }
1238
1247
 
1239
1248
  async function sendImageEdit(kind, detail) {
1240
- if (!activeImage || !drawerImage.naturalWidth) throw new Error("The original image is not ready.");
1249
+ if (!activeImage || !drawerImage.naturalWidth) throw new Error(t("The original image is not ready."));
1241
1250
  let auxiliaryPngBase64 = null;
1242
1251
  let prompt;
1243
1252
  if (kind === "comment") {
1244
1253
  const comments = editComments
1245
1254
  .map((comment) => ({ ...comment, text: committedCommentText(comment) }))
1246
1255
  .filter((comment) => comment.text?.trim());
1247
- if (!comments.length) throw new Error("Add at least one comment before sending.");
1256
+ if (!comments.length) throw new Error(t("Add at least one comment before sending."));
1248
1257
  auxiliaryPngBase64 = annotationPng(comments);
1249
1258
  prompt = `Edit the first attached image according to the numbered comments. The second attachment is a transparent position overlay aligned exactly to the first image.\n${comments.map((comment, index) => `${index + 1}. ${comment.text} (at ${Math.round(comment.x * 100)}% from left, ${Math.round(comment.y * 100)}% from top)`).join("\n")}`;
1250
1259
  } else if (kind === "remove") {
@@ -1258,7 +1267,7 @@ async function sendImageEdit(kind, detail) {
1258
1267
  headers: { "Content-Type": "application/json" },
1259
1268
  body: JSON.stringify({ imageId: activeImage.id, auxiliaryPngBase64 }),
1260
1269
  });
1261
- if (!draft.ok) throw new Error((await draft.json().catch(() => null))?.error || "Could not prepare the image edit.");
1270
+ if (!draft.ok) throw new Error((await draft.json().catch(() => null))?.error || t("Could not prepare the image edit."));
1262
1271
  const { draftId } = await draft.json();
1263
1272
  await requestHostAction("create-image-edit-thread", {
1264
1273
  draftId,
@@ -1302,11 +1311,11 @@ function renderEditMode() {
1302
1311
  const committedComments = editComments.filter((comment) => committedCommentText(comment)?.trim());
1303
1312
  const helper = document.createElement("span");
1304
1313
  helper.textContent = editMode === "comment"
1305
- ? (committedComments.length ? `${committedComments.length} comment${committedComments.length === 1 ? "" : "s"}` : "Click on the image to add comments")
1306
- : "Brush over what you want to remove";
1314
+ ? (committedComments.length ? t(committedComments.length === 1 ? "{count} comment" : "{count} comments", { count: committedComments.length }) : t("Click on the image to add comments"))
1315
+ : t("Brush over what you want to remove");
1307
1316
  const send = document.createElement("button");
1308
1317
  send.dataset.editSend = "";
1309
- send.textContent = "Send";
1318
+ send.textContent = t("Send");
1310
1319
  send.disabled = editMode === "comment" ? committedComments.length === 0 : editStrokes.length === 0;
1311
1320
  send.addEventListener("click", async () => {
1312
1321
  send.disabled = true;
@@ -1320,18 +1329,18 @@ function renderEditMode() {
1320
1329
  const cancel = document.createElement("button");
1321
1330
  cancel.type = "button";
1322
1331
  cancel.dataset.editCancel = "";
1323
- cancel.setAttribute("aria-label", "Cancel");
1332
+ cancel.setAttribute("aria-label", t("Cancel"));
1324
1333
  cancel.innerHTML = '<svg viewBox="0 0 21 21" aria-hidden="true"><path d="M14.6549 5.57307C14.9283 5.2997 15.3718 5.2997 15.6451 5.57307C15.9185 5.84643 15.9185 6.28993 15.6451 6.5633L11.3903 10.8182L15.6451 15.0731L15.735 15.1834C15.9141 15.4551 15.8842 15.8242 15.6451 16.0633C15.4061 16.3024 15.0369 16.3322 14.7653 16.1531L14.6549 16.0633L10.4 11.8084L6.14515 16.0633C5.87178 16.3367 5.42828 16.3367 5.15492 16.0633C4.88155 15.7899 4.88155 15.3464 5.15492 15.0731L9.4098 10.8182L5.15492 6.5633L5.06507 6.45295C4.88597 6.18128 4.91584 5.81214 5.15492 5.57307C5.39399 5.33399 5.76313 5.30413 6.0348 5.48322L6.14515 5.57307L10.4 9.82795L14.6549 5.57307Z" /></svg>';
1325
1334
  cancel.addEventListener("click", () => setEditMode(null));
1326
1335
  editModeRoot.append(helper);
1327
1336
  if (editMode === "remove") {
1328
1337
  const historyVisible = editUndo.length > 0 || editRedo.length > 0;
1329
1338
  const historyIcon = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15.998 10.833C15.9978 8.439 14.0571 6.49805 11.663 6.49805H4.9355L7.13374 8.69629L7.2187 8.80078C7.38911 9.05884 7.36084 9.40947 7.13374 9.63672C6.90652 9.86394 6.55592 9.89207 6.2978 9.72168L6.19331 9.63672L2.85932 6.30371C2.5999 6.04411 2.60001 5.62295 2.85932 5.36328L6.19331 2.0293C6.45298 1.76998 6.87414 1.76987 7.13374 2.0293C7.39344 2.289 7.39344 2.711 7.13374 2.9707L4.93647 5.16797H11.663C14.7916 5.16797 17.3279 7.70446 17.3281 10.833C17.3281 13.9617 14.7917 16.498 11.663 16.498H8.33003C7.96276 16.498 7.66499 16.2003 7.66499 15.833C7.66516 15.4659 7.96287 15.168 8.33003 15.168H11.663C14.0572 15.168 15.998 13.2272 15.998 10.833Z" /></svg>';
1330
- const undo = document.createElement("button"); undo.type = "button"; undo.setAttribute("aria-label", "Undo"); undo.innerHTML = historyIcon;
1339
+ const undo = document.createElement("button"); undo.type = "button"; undo.setAttribute("aria-label", t("Undo")); undo.innerHTML = historyIcon;
1331
1340
  undo.disabled = editUndo.length === 0;
1332
1341
  undo.hidden = !historyVisible;
1333
1342
  undo.addEventListener("click", () => { const previous = editUndo.pop(); if (!previous) return; editRedo.push(editStrokes); editStrokes = previous; repaintMask(); renderEditMode(); });
1334
- const redo = document.createElement("button"); redo.type = "button"; redo.setAttribute("aria-label", "Redo"); redo.innerHTML = historyIcon;
1343
+ const redo = document.createElement("button"); redo.type = "button"; redo.setAttribute("aria-label", t("Redo")); redo.innerHTML = historyIcon;
1335
1344
  redo.disabled = editRedo.length === 0;
1336
1345
  redo.hidden = !historyVisible;
1337
1346
  redo.addEventListener("click", () => { const next = editRedo.pop(); if (!next) return; editUndo.push(editStrokes); editStrokes = next; repaintMask(); renderEditMode(); });
@@ -1384,7 +1393,7 @@ function renderComments() {
1384
1393
  marker.className = "image-comment-marker";
1385
1394
  if (!comment.draft) {
1386
1395
  marker.type = "button";
1387
- marker.setAttribute("aria-label", `Edit comment ${index + 1}`);
1396
+ marker.setAttribute("aria-label", t("Edit comment {number}", { number: index + 1 }));
1388
1397
  marker.addEventListener("click", () => openCommentEditor(comment));
1389
1398
  }
1390
1399
  marker.style.left = `${comment.x * 100}%`; marker.style.top = `${comment.y * 100}%`;
@@ -1439,7 +1448,7 @@ for (const button of editActions.querySelectorAll("[data-edit-action]")) {
1439
1448
  for (const [label, ratio] of RESIZE_OPTIONS) {
1440
1449
  const button = document.createElement("button");
1441
1450
  button.type = "button";
1442
- button.innerHTML = `${ratioIcon(ratio)}<span class="image-ratio-label"><span>${label}</span><small>${ratio}</small></span>`;
1451
+ button.innerHTML = `${ratioIcon(ratio)}<span class="image-ratio-label"><span data-i18n="${label}">${t(label)}</span><small>${ratio}</small></span>`;
1443
1452
  button.setAttribute("role", "menuitem");
1444
1453
  button.addEventListener("click", async () => {
1445
1454
  closeEditorMenus();
@@ -1469,7 +1478,7 @@ function renderZoomMenu() {
1469
1478
  const fit = document.createElement("button");
1470
1479
  fit.type = "button";
1471
1480
  fit.setAttribute("role", "menuitem");
1472
- fit.innerHTML = `<span class="image-ratio-label"><span>Zoom to fit</span>${menuCheck(zoomScale == null)}</span>`;
1481
+ fit.innerHTML = `<span class="image-ratio-label"><span>${t("Zoom to fit")}</span>${menuCheck(zoomScale == null)}</span>`;
1473
1482
  fit.addEventListener("click", () => {
1474
1483
  resetZoom();
1475
1484
  closeEditorMenus();
@@ -1527,26 +1536,26 @@ function openCommentEditor(comment) {
1527
1536
  if (!isNew) {
1528
1537
  const textarea = document.createElement("textarea");
1529
1538
  textarea.name = "image-comment-instruction";
1530
- textarea.setAttribute("aria-label", "Edit comment");
1539
+ textarea.setAttribute("aria-label", t("Edit comment"));
1531
1540
  textarea.value = comment.text;
1532
1541
  const footer = document.createElement("div");
1533
1542
  footer.className = "image-comment-editor-footer";
1534
1543
  const remove = document.createElement("button");
1535
1544
  remove.type = "button";
1536
1545
  remove.className = "image-comment-delete";
1537
- remove.setAttribute("aria-label", "Delete comment");
1546
+ remove.setAttribute("aria-label", t("Delete comment"));
1538
1547
  remove.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10.6299 1.33496C12.0335 1.33496 13.2695 2.25996 13.666 3.60645L13.8809 4.33496H17L17.1338 4.34863C17.4369 4.41057 17.665 4.67858 17.665 5C17.665 5.32142 17.4369 5.58943 17.1338 5.65137L17 5.66504H16.6543L15.8574 14.9912C15.7177 16.629 14.3478 17.8877 12.7041 17.8877H7.2959C5.75502 17.8877 4.45439 16.7815 4.18262 15.2939L4.14258 14.9912L3.34668 5.66504H3C2.63273 5.66504 2.33496 5.36727 2.33496 5C2.33496 4.63273 2.63273 4.33496 3 4.33496H6.11914L6.33398 3.60645L6.41797 3.3584C6.88565 2.14747 8.05427 1.33496 9.37012 1.33496H10.6299ZM5.46777 14.8779L5.49121 15.0537C5.64881 15.9161 6.40256 16.5576 7.2959 16.5576H12.7041C13.6571 16.5576 14.4512 15.8275 14.5322 14.8779L15.3193 5.66504H4.68164L5.46777 14.8779ZM7.66797 12.8271V8.66016C7.66797 8.29299 7.96588 7.99528 8.33301 7.99512C8.70028 7.99512 8.99805 8.29289 8.99805 8.66016V12.8271C8.99779 13.1942 8.70012 13.4912 8.33301 13.4912C7.96604 13.491 7.66823 13.1941 7.66797 12.8271ZM11.002 12.8271V8.66016C11.002 8.29289 11.2997 7.99512 11.667 7.99512C12.0341 7.9953 12.332 8.293 12.332 8.66016V12.8271C12.3318 13.1941 12.0339 13.491 11.667 13.4912C11.2999 13.4912 11.0022 13.1942 11.002 12.8271ZM9.37012 2.66504C8.60726 2.66504 7.92938 3.13589 7.6582 3.83789L7.60938 3.98145L7.50586 4.33496H12.4941L12.3906 3.98145C12.1607 3.20084 11.4437 2.66504 10.6299 2.66504H9.37012Z" /></svg>';
1539
1548
  remove.addEventListener("click", () => finishExistingEdit({ remove: true }));
1540
1549
  const actions = document.createElement("span");
1541
1550
  actions.className = "image-comment-editor-actions";
1542
1551
  const cancel = document.createElement("button");
1543
1552
  cancel.type = "button";
1544
- cancel.textContent = "Cancel";
1553
+ cancel.textContent = t("Cancel");
1545
1554
  cancel.addEventListener("click", () => finishExistingEdit({ restore: true }));
1546
1555
  const save = document.createElement("button");
1547
1556
  save.type = "button";
1548
1557
  save.className = "image-comment-save";
1549
- save.textContent = "Save";
1558
+ save.textContent = t("Save");
1550
1559
  save.disabled = !textarea.value.trim();
1551
1560
  save.addEventListener("click", () => { if (!save.disabled) finishExistingEdit(); });
1552
1561
  textarea.addEventListener("input", () => {
@@ -1566,9 +1575,9 @@ function openCommentEditor(comment) {
1566
1575
  return;
1567
1576
  }
1568
1577
  const input = document.createElement("input");
1569
- input.setAttribute("aria-label", "Add comment");
1578
+ input.setAttribute("aria-label", t("Add comment"));
1570
1579
  input.name = "image-comment-instruction";
1571
- input.placeholder = "Add a comment…";
1580
+ input.placeholder = t("Add a comment…");
1572
1581
  input.value = comment.text;
1573
1582
  input.addEventListener("input", () => { comment.text = input.value; });
1574
1583
  input.addEventListener("keydown", (keyEvent) => {
@@ -1629,6 +1638,8 @@ new ResizeObserver(() => {
1629
1638
 
1630
1639
  drawer.inert = true;
1631
1640
  applyDensity();
1641
+ setLocale(navigator.language);
1642
+ translateDocument(document);
1632
1643
  await Promise.all([loadFacets(), loadPage({ reset: true })]);
1633
1644
  await new Promise((resolve) => setTimeout(resolve, 0));
1634
1645