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 +2 -2
- package/features/images/src/http-server.mjs +2 -1
- package/features/images/src/save-as.mjs +37 -8
- package/features/images/web/app.js +91 -80
- package/features/images/web/i18n.mjs +127 -0
- package/features/images/web/index.html +33 -33
- package/features/notes/src/http-server.mjs +28 -0
- package/features/notes/web/app.js +4 -3
- package/package.json +4 -2
- package/packages/cli/src/commands.mjs +3 -3
- package/packages/cli/src/npm-adapter.mjs +12 -4
- package/packages/cli/src/paths.mjs +36 -1
- package/packages/cli/src/platform.mjs +517 -0
- package/packages/cli/src/runtime-manager.mjs +9 -2
- package/packages/runtime-host/src/codex-injection.mjs +26 -7
- package/packages/runtime-host/src/main.mjs +17 -9
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ MineCodex 给 Codex Desktop 增加一组轻量、本地优先的扩展功能。
|
|
|
17
17
|
|
|
18
18
|
## 安装
|
|
19
19
|
|
|
20
|
-
MineCodex
|
|
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 签名的官方
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
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 =
|
|
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",
|
|
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",
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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",
|
|
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
|
|
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
|
|