diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index 6816656c7..22247abc3 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -5461,6 +5461,7 @@ export type LocaleData = { toast_title: string error: { load_failed: string + unlock_failed: string } achievements: { open_achievements_page: { @@ -5762,6 +5763,7 @@ export type LocaleKey = Paths */ export type LocaleKeyParams = { 'achievements.error.load_failed': { message: string | number } + 'achievements.error.unlock_failed': { error: string | number } 'achievements.unlocked_on': { date: string | number } 'auth.error.accountLockedRetry': { timeLeft: string | number } 'badges_maker.copy_error': { error: string | number } diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 9e6eab2f2..7ed369f11 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -5480,7 +5480,8 @@ "locked": "غير مقفلة", "toast_title": "الإنجازات مقفلة!", "error": { - "load_failed": "فشل تحميل الإنجازات: ${message}" + "load_failed": "فشل تحميل الإنجازات: ${message}", + "unlock_failed": "فشل فتح الإنجاز: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index cee1c46c5..200257c4e 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -5480,7 +5480,8 @@ "locked": "Nicht entsperrt", "toast_title": "Erfolge freigeschaltet!", "error": { - "load_failed": "Erfolge konnten nicht geladen werden: ${message}" + "load_failed": "Erfolge konnten nicht geladen werden: ${message}", + "unlock_failed": "Erfolg konnte nicht freigeschaltet werden: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 401678e42..daeb8ed8e 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -5480,7 +5480,8 @@ "locked": "🔒", "toast_title": "🎉🏆❗", "error": { - "load_failed": "🏆❌: ${message}" + "load_failed": "🏆❌: ${message}", + "unlock_failed": "🔓❌: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index 70c910c60..4593ed780 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -5477,7 +5477,8 @@ "locked": "Not unlocked", "toast_title": "Achievements unlocked!", "error": { - "load_failed": "Failed to load achievements: ${message}" + "load_failed": "Failed to load achievements: ${message}", + "unlock_failed": "Failed to unlock achievement: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 236b1c13c..e76a3664e 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -5480,7 +5480,8 @@ "locked": "No desbloqueado", "toast_title": "¡Logros desbloqueados!", "error": { - "load_failed": "No se pudieron cargar los logros: ${message}" + "load_failed": "No se pudieron cargar los logros: ${message}", + "unlock_failed": "No se pudo desbloquear el logro: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index d8b6cf151..2f23a5c13 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -5480,7 +5480,8 @@ "locked": "Non débloqué", "toast_title": "Succès débloqués !", "error": { - "load_failed": "Échec du chargement des succès : ${message}" + "load_failed": "Échec du chargement des succès : ${message}", + "unlock_failed": "Échec du déblocage du succès : ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index ee11aaa0a..92dbffa56 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -5480,7 +5480,8 @@ "locked": "अनलॉक नहीं किया गया", "toast_title": "उपलब्धियाँ खुलीं!", "error": { - "load_failed": "उपलब्धियाँ लोड करने में विफल: ${message}" + "load_failed": "उपलब्धियाँ लोड करने में विफल: ${message}", + "unlock_failed": "उपलब्धि अनलॉक करने में विफल: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index 1b732ea40..750dc8b33 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -5480,7 +5480,8 @@ "locked": "Ekki ólæst", "toast_title": "Afrek opnuð!", "error": { - "load_failed": "Mistókst að hlaða afrekum: ${message}" + "load_failed": "Mistókst að hlaða afrekum: ${message}", + "unlock_failed": "Mistókst að opna afrek: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 0374224f8..315b7ae8a 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -5480,7 +5480,8 @@ "locked": "Non sbloccato", "toast_title": "Obiettivi sbloccati!", "error": { - "load_failed": "Impossibile caricare gli obiettivi: ${message}" + "load_failed": "Impossibile caricare gli obiettivi: ${message}", + "unlock_failed": "Impossibile sbloccare l'obiettivo: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index e95dc4224..25f7e5f08 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -5480,7 +5480,8 @@ "locked": "ロックが解除されていません", "toast_title": "実績のロックが解除されました!", "error": { - "load_failed": "実績の読み込みに失敗しました: ${message}" + "load_failed": "実績の読み込みに失敗しました: ${message}", + "unlock_failed": "実績の解除に失敗しました: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index 5cff162a6..ffc4338e8 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -5480,7 +5480,8 @@ "locked": "잠금 해제되지 않음", "toast_title": "업적 잠금 해제!", "error": { - "load_failed": "업적을 로드하지 못했습니다: ${message}" + "load_failed": "업적을 로드하지 못했습니다: ${message}", + "unlock_failed": "업적을 잠금 해제하지 못했습니다: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index 650fa5d57..8600e67ee 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -5480,7 +5480,8 @@ "locked": "尚未功成", "toast_title": "功成!", "error": { - "load_failed": "誊录功绩不成:${message}" + "load_failed": "誊录功绩不成:${message}", + "unlock_failed": "启功勋不成:${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index 753fbf76a..957c06455 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -5480,7 +5480,8 @@ "locked": "Niet ontgrendeld", "toast_title": "Prestaties ontgrendeld!", "error": { - "load_failed": "Kan prestaties niet laden: ${message}" + "load_failed": "Kan prestaties niet laden: ${message}", + "unlock_failed": "Kan prestatie niet ontgrendelen: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index 6237fa355..8d8d54db3 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -5480,7 +5480,8 @@ "locked": "Não desbloqueado", "toast_title": "Conquistas desbloqueadas!", "error": { - "load_failed": "Falha ao carregar conquistas: ${message}" + "load_failed": "Falha ao carregar conquistas: ${message}", + "unlock_failed": "Falha ao desbloquear a conquista: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index be344f283..f44b5f8e9 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -5480,7 +5480,8 @@ "locked": "Не разблокировано", "toast_title": "Достижения разблокированы!", "error": { - "load_failed": "Не удалось загрузить достижения: `${message}" + "load_failed": "Не удалось загрузить достижения: ${message}", + "unlock_failed": "Не удалось разблокировать достижение: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index 7e67d3d1a..6221e6219 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -5480,7 +5480,8 @@ "locked": "Не розблоковано", "toast_title": "Досягнення розблоковано!", "error": { - "load_failed": "Не вдалося завантажити досягнення: ${message}" + "load_failed": "Не вдалося завантажити досягнення: ${message}", + "unlock_failed": "Не вдалося розблокувати досягнення: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index b45aff963..3c78159ab 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -5480,7 +5480,8 @@ "locked": "Chưa mở khóa", "toast_title": "Thành tích đã được mở khóa!", "error": { - "load_failed": "Không tải được thành tích: ${message}" + "load_failed": "Không tải được thành tích: ${message}", + "unlock_failed": "Không mở khóa được thành tích: ${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index cd2d224b2..704eb1bb6 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -5492,7 +5492,8 @@ "locked": "未解锁", "toast_title": "成就已解锁!", "error": { - "load_failed": "加载成就失败:${message}" + "load_failed": "加载成就失败:${message}", + "unlock_failed": "解锁成就失败:${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index ce09f149d..ae491d8c3 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -5479,7 +5479,8 @@ "locked": "未解鎖", "toast_title": "成就已解鎖!", "error": { - "load_failed": "載入成就失敗:${message}" + "load_failed": "載入成就失敗:${message}", + "unlock_failed": "解鎖成就失敗:${error}" }, "achievements": { "open_achievements_page": { diff --git a/src/public/pages/AGENTS.md b/src/public/pages/AGENTS.md index 0765a9cab..3ac3c6498 100644 --- a/src/public/pages/AGENTS.md +++ b/src/public/pages/AGENTS.md @@ -13,10 +13,15 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes ## API & Communication -- **`endpoints.mjs`**: Core auth/system APIs (`login`, `register`, `whoami`, `getUserSetting`, etc.). +- **Global HTTP lives in `scripts/endpoints/`** (`base.mjs`, `parts.mjs`, `registries.mjs`, `server_events.mjs`, `p2p/evfsMedia.mjs`). Import via `/scripts/endpoints/…`. **Named functions only** — no path-string clients. Shell-local REST belongs in that shell’s `public/src/endpoints.mjs` / `endpoints/*` (see shells AGENTS). +- **No client-side timeouts on backend links.** Do not wrap local `/api/*` (or same-origin backend) `fetch` / WS with `AbortSignal.timeout`, artificial deadlines, or “give up if slow” logic. If the backend hangs or times out, that is a backend bug — fix the server, do not add frontend complexity to paper over it. UI supersession abort (e.g. user navigated away / started a newer enter) is fine; do not pass that signal into backend fetch just to simulate a timeout. +- **`endpoints/base.mjs`**: Core auth/system APIs (`login`, `register`, `whoami`, `getUserSetting`, etc.). +- **`endpoints/parts.mjs`**: `runPart`, `loadPart`, `getPartList`, `getPartDetails`, `setDefaultPart`. +- **`endpoints/server_events.mjs`**: `onServerEvent` — server-sent event bus. +- **`endpoints/registries.mjs`**: `GET /api/registries/:name` + dynamic `import()`. +- **`endpoints/p2p/evfsMedia.mjs`**: EVFS GET/PUT (`fetchEvfsFile`, `fetchMediaRef`, `uploadEvfsFile`, `uploadEvfsAttachment`). Pure URL helpers stay Deno-pure in chat `shared/evfsMedia.mjs` (`entityFileUrl`, `mediaRefUrl`). - **`debug_log.mjs`**: `debugLog(name, data)` → `debug_logs/`. -- **`parts.mjs`**: `runPart`, `loadPart`, `getPartList`, `setDefaultPart`. -- **`server_events.mjs`**: `onServerEvent` — server-sent event bus. +- **HTML templates**: use `renderTemplate` / `mountTemplate` / `withTemplates` — never `fetch(…html)`. ## UI & Theming @@ -32,6 +37,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`contentReveal.mjs`**: `wrapSensitiveMediaHtml`, `wrapContentWarningHtml`, `bindContentReveal`. - **`translate.mjs`**: `mountTranslationBlock`, `requestTranslation`, `resolveTargetLang` (-> `primaryLocale()`). - **`toast.mjs`**: `showToast`, `showToastI18n`. +- **`errorHandlers.mjs`**: `handleError(i18nKey, toastParams?)` returns a `.catch` closure (toast + console + Sentry). Immediate form: `handleError(i18nKey, toastParams, error)`. **Only for fount faults** — user mistakes use `showToastI18n` directly. Backend twin: `fount/scripts/errorHandlers.mjs` (`handleError(error, ...extras)`). ## Rendering & Content @@ -40,7 +46,6 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`markdown/standaloneDocument.mjs`**: `renderMarkdownAsStandaloneDocument` / `wrapStandaloneMarkdownDocument` — offline full HTML for Chat/Social download/share/drag. Filenames from document `` via `fileNameFromHtmlTitle` / `downloadHtmlDocument`. - **`sanitizeHtml.mjs`**: `sanitizePermissiveHtml` — rich displayName HTML minus script / `style` / `on*` / dangerous URLs. `scrubHtmlActivePayload(string|root)` — string → `<template>` scrub → `DocumentFragment`; DOM root → in-place; strips `on*` / all `srcset` / unsafe URLs (keeps `style`). Prefer the string path over live-`innerHTML` then scrub. `isSafeHtmlUrl` rejects `javascript:` / `data:` / protocol-relative `//…` and `/\…`. - **`embedCard.mjs`**: `ALL /api/no-cors?url=` + OG parse; `MutationObserver` hydration; session LRU. Proxy details: [markdown-notes.md](markdown-notes.md#no-cors-proxy). -- **`registries.mjs`**: `GET /api/registries/:name` + dynamic `import()`. - **`emojiPicker.mjs`**: Shared emoji picker (click inserts token; Hub long-press/right-click sends sticker). Section headers / Alt·right-click on the rail open `emojiPackPreview`. Floating placement in `components/floatingPanel.mjs`. Hub mounts via `mountDockedEmojiPicker`. - **`emojiPackPreview.mjs`**: Pack preview card (info + join/follow/favorite); `showEmojiPackPreview(anchor, { pack, provider, available })`. - **`i18n.mjs`**: Sole public entry. Call `initTranslations()` early. Switch UI language with **`setLanguage(string[])`** (writes preferredLangs + reloads via platform `i18n/base.mjs`). Raw bundle without applying: **`loadLocaleData(string[])`** (fount → `/api/getlocaledata`; Pages → static `locales/*.json`). Prefer these over ad-hoc fetch. `data-i18n`, `geti18n`, `setElementI18n`, `primaryLocale()` (preferredLangs[0] → `main_locale`, default `en-UK`). Use for content locale / translation target — do not hardcode `zh-CN` or bare `navigator.language`. Missing keys → `console.warn('[i18n:missing] …')`; Playwright fixtures hard-fail on that prefix. Locale map slices: `matchLocale` / `getBestLocale` / `pickLocalizedSlice` (`i18n/locale_match.mjs`, same as backend). Params / placeholders: [i18n-notes.md](i18n-notes.md). @@ -59,4 +64,4 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes ## P2P (Browser) -Import via `esm.sh`. Shared primitives live in `shells/chat/public/shared/` (`/parts/shells:chat/shared/…`). Entity HTTP: `/api/parts/shells:chat/{viewer,entities…}`; network: `/api/p2p/{network,denylist,mailbox,federation}`. +Import via `esm.sh`. Shared primitives live in `shells/chat/public/shared/` (`/parts/shells:chat/shared/…`). EVFS URL helpers (`entityFileUrl` / `mediaRefUrl`) stay Deno-pure in chat `shared/evfsMedia.mjs`; browser fetch/upload is `/scripts/endpoints/p2p/evfsMedia.mjs`. Entity HTTP: `/api/parts/shells:chat/{viewer,entities…}`; network: `/api/p2p/{network,denylist,mailbox,federation}`. diff --git a/src/public/pages/base.mjs b/src/public/pages/base.mjs index 584fd30e1..e4c2db758 100644 --- a/src/public/pages/base.mjs +++ b/src/public/pages/base.mjs @@ -6,7 +6,7 @@ */ import * as Sentry from 'https://esm.sh/@sentry/browser' -import { onServerEvent } from './scripts/api/server_events.mjs' +import { onServerEvent } from './scripts/endpoints/server_events.mjs' let skipBreadcrumb = false if (!globalThis.fount?.test?.enabled) try { diff --git a/src/public/pages/index.html b/src/public/pages/index.html index 5ca45cb03..2ee993b24 100644 --- a/src/public/pages/index.html +++ b/src/public/pages/index.html @@ -20,7 +20,7 @@ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" crossorigin="anonymous"></script> <script type="module" src="/base.mjs"></script> <script type="module"> - import { getAnyDefaultPart } from './scripts/api/parts.mjs' + import { getAnyDefaultPart } from './scripts/endpoints/parts.mjs' import { redirectToLoginInfo } from './scripts/host/credentialManager.mjs' import { initTranslations } from './scripts/i18n/index.mjs' import { setTheme } from './scripts/theme/index.mjs' diff --git a/src/public/pages/log_viewer/index.mjs b/src/public/pages/log_viewer/index.mjs index 269879372..5028e5753 100644 --- a/src/public/pages/log_viewer/index.mjs +++ b/src/public/pages/log_viewer/index.mjs @@ -4,7 +4,7 @@ import { geti18n, initTranslations } from '/scripts/i18n/index.mjs' import { createVirtualList } from '/scripts/lib/virtualList.mjs' import { attachLogWire } from 'https://esm.sh/@steve02081504/virtual-console/wire/client' -import { ping } from '/scripts/api/base.mjs' +import { ping } from '/scripts/endpoints/base.mjs' import { createLogsWs, openSource } from './endpoints.mjs' import { renderLogItem, createLogToolbar, entryMatchesFilter } from './log.mjs' import { initRepl, mountReplPanel } from './repl/index.mjs' diff --git a/src/public/pages/login/index.mjs b/src/public/pages/login/index.mjs index a3c049275..94775656e 100644 --- a/src/public/pages/login/index.mjs +++ b/src/public/pages/login/index.mjs @@ -1,3 +1,5 @@ +import { initPasswordStrengthMeter } from '../scripts/components/passwordStrength.mjs' +import { createPOWCaptcha } from '../scripts/components/POWcaptcha.mjs' import { ping, generateVerificationCode, @@ -5,10 +7,8 @@ import { register, webauthnLoginBegin, webauthnLoginComplete, -} from '../scripts/api/base.mjs' -import { getAnyDefaultPart } from '../scripts/api/parts.mjs' -import { initPasswordStrengthMeter } from '../scripts/components/passwordStrength.mjs' -import { createPOWCaptcha } from '../scripts/components/POWcaptcha.mjs' +} from '../scripts/endpoints/base.mjs' +import { getAnyDefaultPart } from '../scripts/endpoints/parts.mjs' import { showToast } from '../scripts/features/toast.mjs' import { retrieveAndDecryptCredentials, redirectToLoginInfo } from '../scripts/host/credentialManager.mjs' import { initTranslations, console, savePreferredLangs, onLanguageChange } from '../scripts/i18n/index.mjs' diff --git a/src/public/pages/protocolhandler/index.mjs b/src/public/pages/protocolhandler/index.mjs index 0e6e4da41..3f8059365 100644 --- a/src/public/pages/protocolhandler/index.mjs +++ b/src/public/pages/protocolhandler/index.mjs @@ -1,5 +1,5 @@ -import { authenticate } from '../scripts/api/base.mjs' -import { runPart } from '../scripts/api/parts.mjs' +import { authenticate } from '../scripts/endpoints/base.mjs' +import { runPart } from '../scripts/endpoints/parts.mjs' import { initTranslations, console } from '../scripts/i18n/index.mjs' import { applyTheme } from '../scripts/theme/index.mjs' diff --git a/src/public/pages/scripts/api/p2p/evfsMedia.mjs b/src/public/pages/scripts/api/p2p/evfsMedia.mjs deleted file mode 100644 index c08ea4e8a..000000000 --- a/src/public/pages/scripts/api/p2p/evfsMedia.mjs +++ /dev/null @@ -1,9 +0,0 @@ -/** 重导出 chat 共用 EVFS 媒体上传与解析 API。 */ -export { - entityFileUrl, - fetchEvfsFile, - getViewerEntityHash, - mediaRefUrl, - uploadEvfsAttachment, - uploadEvfsFile, -} from '/parts/shells:chat/shared/evfsMedia.mjs' diff --git a/src/public/pages/scripts/components/partpath_picker.mjs b/src/public/pages/scripts/components/partpath_picker.mjs index 494400f0c..526314aa9 100644 --- a/src/public/pages/scripts/components/partpath_picker.mjs +++ b/src/public/pages/scripts/components/partpath_picker.mjs @@ -1,5 +1,5 @@ -import { getPartBranches, getPartDetails } from '../api/parts.mjs' -import { onServerEvent } from '../api/server_events.mjs' +import { getPartBranches, getPartDetails } from '../endpoints/parts.mjs' +import { onServerEvent } from '../endpoints/server_events.mjs' import { geti18n } from '../i18n/index.mjs' /** diff --git a/src/public/pages/scripts/api/base.mjs b/src/public/pages/scripts/endpoints/base.mjs similarity index 76% rename from src/public/pages/scripts/api/base.mjs rename to src/public/pages/scripts/endpoints/base.mjs index 8bb1b9374..0f71782cf 100644 --- a/src/public/pages/scripts/api/base.mjs +++ b/src/public/pages/scripts/endpoints/base.mjs @@ -1,12 +1,14 @@ /** - * 解析 JSON:`response.ok` 为 false 则 reject 并附带响应体。 + * 解析 JSON:`response.ok` 为 false 则抛错并附带响应体。 * @param {Response} response - fetch 响应。 * @returns {Promise<object>} 解析后的 JSON 对象。 */ -async function finishAuthenticatedJsonMutation(response) { +async function parseJsonResponse(response) { if (!response.ok) { - const data = await response.json().catch(() => ({})) - return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), data, { response })) + let data = {} + try { data = await response.json() } + catch { /* non-JSON body */ } + throw Object.assign(new Error(`API request failed with status ${response.status}`), data, { response }) } return response.json() } @@ -21,8 +23,7 @@ export async function ping(with_cache = false) { credentials: 'omit', cache: with_cache ? 'default' : 'no-cache', }) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -30,7 +31,12 @@ export async function ping(with_cache = false) { * @returns {Promise<string>} - 本地 IP 中的主机 URL。 */ export async function hosturl_in_local_ip() { - return ping(true).then(data => data.hosturl_in_local_ip).catch(() => window.location.origin) + try { + return (await ping(true)).hosturl_in_local_ip + } + catch { + return window.location.origin + } } /** @@ -39,8 +45,7 @@ export async function hosturl_in_local_ip() { */ export async function getPoWChallenge() { const response = await fetch('/api/pow/challenge', { method: 'POST' }) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -55,8 +60,7 @@ export async function redeemPoWToken(token, solutions) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, solutions }), }) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -68,8 +72,7 @@ export async function getLocaleData(preferred) { const url = new URL(window.location.origin + '/api/getlocaledata') if (preferred) url.searchParams.set('preferred', preferred.join(',')) const response = await fetch(url) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -78,8 +81,7 @@ export async function getLocaleData(preferred) { */ export async function getAvailableLocales() { const response = await fetch('/api/getavailablelocales') - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -103,8 +105,7 @@ export async function whoami() { const response = await fetch('/api/whoami', { headers: { Accept: 'application/json' }, }) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - return response.json() + return parseJsonResponse(response) } /** @@ -197,9 +198,7 @@ export async function authenticate() { */ export async function getUserSetting(key) { const response = await fetch(`/api/getusersetting?key=${encodeURIComponent(key)}`) - if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) - const { value } = await response.json() - return value + return (await parseJsonResponse(response)).value } /** @@ -227,7 +226,7 @@ export async function logout() { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) - return finishAuthenticatedJsonMutation(response) + return parseJsonResponse(response) } /** @@ -236,7 +235,7 @@ export async function logout() { */ export async function getApiKeys() { const response = await fetch('/api/apikey/list') - return finishAuthenticatedJsonMutation(response) + return parseJsonResponse(response) } /** @@ -250,7 +249,7 @@ export async function createApiKey(description) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description }), }) - return finishAuthenticatedJsonMutation(response) + return parseJsonResponse(response) } /** @@ -265,7 +264,7 @@ export async function revokeApiKey(jti, password) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jti, password }), }) - return finishAuthenticatedJsonMutation(response) + return parseJsonResponse(response) } /** @@ -280,4 +279,3 @@ export async function verifyApiKey(apiKey) { body: JSON.stringify({ apiKey }), }) } - diff --git a/src/public/pages/scripts/endpoints/desktop.ini b/src/public/pages/scripts/endpoints/desktop.ini new file mode 100644 index 000000000..8c602c72e --- /dev/null +++ b/src/public/pages/scripts/endpoints/desktop.ini @@ -0,0 +1,6 @@ +[.ShellClassInfo] +IconResource=%SystemRoot%\System32\SHELL32.dll,293 +[ViewState] +Mode= +Vid= +FolderType=Generic diff --git a/src/public/pages/scripts/endpoints/p2p/desktop.ini b/src/public/pages/scripts/endpoints/p2p/desktop.ini new file mode 100644 index 000000000..b4c3de45e --- /dev/null +++ b/src/public/pages/scripts/endpoints/p2p/desktop.ini @@ -0,0 +1,6 @@ +[.ShellClassInfo] +IconResource=%SystemRoot%\System32\SHELL32.dll,18 +[ViewState] +Mode= +Vid= +FolderType=Generic diff --git a/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs b/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs new file mode 100644 index 000000000..0d3f3d8e4 --- /dev/null +++ b/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs @@ -0,0 +1,80 @@ +/** + * 浏览器端 EVFS 媒体 HTTP(Chat / Social 共用)。 + * URL 纯函数见 `/parts/shells:chat/shared/evfsMedia.mjs`。 + */ +import { entityFileUrl, mediaRefUrl } from '/parts/shells:chat/shared/evfsMedia.mjs' + +/** 再导出 EVFS 文件 URL / mediaRef URL 纯函数(实现见 chat shared)。 */ +export { entityFileUrl, mediaRefUrl } + +const CHAT_SHELL_API_PREFIX = '/api/parts/shells:chat' + +/** + * @returns {Promise<string | null>} viewer entityHash + */ +export async function getViewerEntityHash() { + const resp = await fetch(`${CHAT_SHELL_API_PREFIX}/viewer`, { credentials: 'include' }) + if (!resp.ok) throw new Error(`viewer ${resp.status}`) + const data = await resp.json() + return data.viewerEntityHash +} + +/** + * @param {string} entityHash owner + * @param {string} logicalPath 路径 + * @param {File | Blob} file 文件 + * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 + */ +export async function uploadEvfsFile(entityHash, logicalPath, file) { + const url = entityFileUrl(entityHash, logicalPath) + const res = await fetch(url, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/octet-stream' }, + body: file, + }) + if (!res.ok) throw new Error((await res.json()).error || `evfs upload failed: ${res.status}`) + const { url: resolvedUrl } = await res.json() + return { entityHash, path: logicalPath, url: resolvedUrl } +} + +/** + * @param {string} entityHash owner + * @param {string} logicalPath 路径 + * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} 文件字节与 Content-Type + */ +export async function fetchEvfsFile(entityHash, logicalPath) { + const res = await fetch(entityFileUrl(entityHash, logicalPath), { credentials: 'include' }) + if (!res.ok) throw new Error(`evfs fetch failed: ${res.status}`) + return { + buffer: await res.arrayBuffer(), + mimeType: res.headers.get('Content-Type') || 'application/octet-stream', + } +} + +/** + * 按 mediaRef(url 或 entityHash+path)下载字节。 + * @param {{ entityHash?: string, path?: string, url?: string, mimeType?: string }} ref 媒体引用 + * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} 文件字节与 Content-Type + */ +export async function fetchMediaRef(ref) { + if (ref?.entityHash && ref?.path && !ref.url) + return fetchEvfsFile(ref.entityHash, ref.path) + const res = await fetch(mediaRefUrl(ref), { credentials: 'include' }) + if (!res.ok) throw new Error(`mediaRef fetch failed: ${res.status}`) + return { + buffer: await res.arrayBuffer(), + mimeType: String(ref?.mimeType || res.headers.get('Content-Type') || 'application/octet-stream'), + } +} + +/** + * @param {File | Blob} file 文件 + * @param {string} logicalPathPrefix 逻辑路径前缀(如 shells/chat/attachments) + * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 + */ +export async function uploadEvfsAttachment(file, logicalPathPrefix) { + const entityHash = await getViewerEntityHash() + if (!entityHash) throw new Error('identity required for attachments') + return uploadEvfsFile(entityHash, `${logicalPathPrefix}/${crypto.randomUUID()}`, file) +} diff --git a/src/public/pages/scripts/api/parts.mjs b/src/public/pages/scripts/endpoints/parts.mjs similarity index 92% rename from src/public/pages/scripts/api/parts.mjs rename to src/public/pages/scripts/endpoints/parts.mjs index 91d593ade..28fe79b7d 100644 --- a/src/public/pages/scripts/api/parts.mjs +++ b/src/public/pages/scripts/endpoints/parts.mjs @@ -8,6 +8,7 @@ * - 不要在 `runPart` / `loadPart` 里偷偷 `replace(/:/g, '/')`:会掩盖调用方 bug,且与 * 「规整是调用者职责」不一致。 */ +import { handleError } from '../features/errorHandlers.mjs' /** * 运行部件。 @@ -195,12 +196,23 @@ export async function getPartBranches(nocache = false) { * 解锁成就。 * @param {string} partpath - 部件路径。 * @param {string} achievementId - 成就 ID。 - * @returns {Promise<Response>} - 服务器响应。 - */ -export function unlockAchievement(partpath, achievementId) { - return fetch('/api/parts/shells:achievements/unlock', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ partpath, id: achievementId }), - }).catch(() => { /* Fail silently */ }) + * @returns {Promise<void>} + */ +export async function unlockAchievement(partpath, achievementId) { + try { + const response = await fetch('/api/parts/shells:achievements/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partpath, id: achievementId }), + }) + if (!response.ok) { + let data = {} + try { data = await response.json() } + catch { /* non-JSON body */ } + throw Object.assign(new Error(`API request failed with status ${response.status}`), data, { response }) + } + } + catch (error) { + handleError('achievements.error.unlock_failed', {}, error) + } } diff --git a/src/public/pages/scripts/api/registries.mjs b/src/public/pages/scripts/endpoints/registries.mjs similarity index 87% rename from src/public/pages/scripts/api/registries.mjs rename to src/public/pages/scripts/endpoints/registries.mjs index 8ca389469..5fadbdcc1 100644 --- a/src/public/pages/scripts/api/registries.mjs +++ b/src/public/pages/scripts/endpoints/registries.mjs @@ -23,7 +23,15 @@ export async function getRegistry(name, { nocache = false } = {}) { return res.json() })() - if (!nocache) registryFetchCache.set(name, fetchPromise) + if (!nocache) { + registryFetchCache.set(name, fetchPromise) + try { await fetchPromise } + catch (error) { + if (registryFetchCache.get(name) === fetchPromise) + registryFetchCache.delete(name) + throw error + } + } return fetchPromise } diff --git a/src/public/pages/scripts/api/server_events.mjs b/src/public/pages/scripts/endpoints/server_events.mjs similarity index 100% rename from src/public/pages/scripts/api/server_events.mjs rename to src/public/pages/scripts/endpoints/server_events.mjs diff --git a/src/public/pages/scripts/features/emoji/providers.mjs b/src/public/pages/scripts/features/emoji/providers.mjs index 43bd33d62..a80345d62 100644 --- a/src/public/pages/scripts/features/emoji/providers.mjs +++ b/src/public/pages/scripts/features/emoji/providers.mjs @@ -1,7 +1,7 @@ /** * 聚合 registries.emoji 全部 provider。 */ -import { importRegistryModules } from '../../api/registries.mjs' +import { importRegistryModules } from '../../endpoints/registries.mjs' /** * @returns {Promise<object[]>} 全部可用 emoji provider diff --git a/src/public/pages/scripts/features/errorHandlers.mjs b/src/public/pages/scripts/features/errorHandlers.mjs new file mode 100644 index 000000000..f7d6eb8be --- /dev/null +++ b/src/public/pages/scripts/features/errorHandlers.mjs @@ -0,0 +1,42 @@ +/** + * 前端错误处理:只报 fount 自身故障(toast + console + Sentry)。 + * 用户输入/操作过错请直接 `showToastI18n`,不要走这里。 + * 工厂形式便于 `.catch(handleError('key'))`。 + */ +import * as Sentry from 'https://esm.sh/@sentry/browser' + +import { showToastI18n } from './toast.mjs' + +/** + * @param {unknown} error 异常或字符串 + * @returns {Error} 规范化 Error + */ +function toError(error) { + if (error instanceof Error) return error + return new Error(String(error?.message ?? error)) +} + +/** + * 用户可见的 fount 故障:toast + console + Sentry。 + * @param {string} i18nKey toast 文案键 + * @param {Record<string, unknown>} [toastParams] 额外 i18n 插值与 console 上下文(`error` 由本函数注入) + * @param {unknown} [error] 若传入则立即处理,否则返回 `.catch` 闭包 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error | ((error: unknown, ...extras: unknown) => Error)} 传入 error 时立即处理并返回 Error,否则返回 `.catch` 闭包 + */ +export function handleError(i18nKey, toastParams = {}, error, ...extras) { + /** + * @param {unknown} error 异常或字符串 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error} 规范化 Error + */ + const handler = (error, ...extras) => { + const err = toError(error) + console.error(`[fount-ui] ${i18nKey}`, err, ...extras) + showToastI18n('error', i18nKey, { ...toastParams, error: err.message }) + Sentry.captureException(err) + return err + } + if (error) return handler(error, ...extras) + return handler +} diff --git a/src/public/pages/scripts/features/markdown/extensions.mjs b/src/public/pages/scripts/features/markdown/extensions.mjs index d56880435..98b317e07 100644 --- a/src/public/pages/scripts/features/markdown/extensions.mjs +++ b/src/public/pages/scripts/features/markdown/extensions.mjs @@ -1,4 +1,4 @@ -import { importRegistryModules } from '../../api/registries.mjs' +import { importRegistryModules } from '../../endpoints/registries.mjs' /** @type {Promise<{ remarkPlugins: unknown[], rehypePlugins: unknown[], css: string, inits: Array<() => void>, version: string }> | null} */ let loadPromise = null diff --git a/src/public/pages/scripts/host/credentialManager.mjs b/src/public/pages/scripts/host/credentialManager.mjs index 0bb1dac8c..4670727a4 100644 --- a/src/public/pages/scripts/host/credentialManager.mjs +++ b/src/public/pages/scripts/host/credentialManager.mjs @@ -5,7 +5,7 @@ */ import CryptoJS from 'https://esm.sh/crypto-js' -import { ping } from '../api/base.mjs' +import { ping } from '../endpoints/base.mjs' import { downloadFromCatbox, uploadToCatbox } from './catbox.mjs' diff --git a/src/public/pages/scripts/i18n/base.mjs b/src/public/pages/scripts/i18n/base.mjs index 093e4165e..b08c325a5 100644 --- a/src/public/pages/scripts/i18n/base.mjs +++ b/src/public/pages/scripts/i18n/base.mjs @@ -1,4 +1,4 @@ -import { onServerEvent } from '../api/server_events.mjs' +import { onServerEvent } from '../endpoints/server_events.mjs' import { loadPreferredLangs, diff --git a/src/public/parts/shells/AGENTS.md b/src/public/parts/shells/AGENTS.md index efb44ea2a..7d3da727c 100644 --- a/src/public/parts/shells/AGENTS.md +++ b/src/public/parts/shells/AGENTS.md @@ -16,7 +16,8 @@ alwaysApply: false - `main.mjs`: Backend entry. Default export must include `Load({ router })`. - `public/`: Frontend assets. `public/llms.txt`: AI-readable API docs. -- `src/endpoints.mjs`: Routes via `router.get/post/ws`. Path: `/api/parts/shells\:<name>/...`. +- `src/endpoints.mjs`: **Backend** Express routes via `router.get/post/ws`. Path: `/api/parts/shells:<name>/...`. +- **Frontend HTTP**: `public/src/endpoints.mjs` or `public/src/endpoints/*.mjs` — **named exports only**. No path-string clients (`socialApi('/…')`, `api(method, path)`, UI-facing `groupFetch(path)`). UI / shared / providers must not `fetch` shell REST; use endpoints. Global `/api/whoami`, `/api/getdetails…`, EVFS → `@src/public/pages/scripts/endpoints/`. HTML templates → `renderTemplate` / `mountTemplate` / `withTemplates`, never `fetch(…html)`. - **HTTP API**: Success = 2xx JSON (no `success` wrapper); failures = `throw httpError(code, message, { json?, skip_report? })` from `@src/scripts/http_error.mjs`. - **`fount.json` → `registries`**: `[{ id, level, path }]` for `markdown_extensions`, `emoji`, `locales`, `home_*`, `achievements`. - **`home_function_buttons.info`**: locale **object** with `title` (e.g. `achievements.home_function_buttons.main`), not a page-level string. Home reads `geti18n(info).title`. diff --git a/src/public/parts/shells/access/public/index.mjs b/src/public/parts/shells/access/public/index.mjs index b2091da4a..31ade2f3c 100644 --- a/src/public/parts/shells/access/public/index.mjs +++ b/src/public/parts/shells/access/public/index.mjs @@ -3,7 +3,7 @@ */ import qrcode from 'https://esm.sh/qrcode-generator' -import { hosturl_in_local_ip, ping } from '../../scripts/api/base.mjs' +import { hosturl_in_local_ip, ping } from '../../scripts/endpoints/base.mjs' import { showToast, showToastI18n } from '../../scripts/features/toast.mjs' import { redirectToLoginInfo, diff --git a/src/public/parts/shells/achievements/public/index.mjs b/src/public/parts/shells/achievements/public/index.mjs index f77ec9b09..1b8429315 100644 --- a/src/public/parts/shells/achievements/public/index.mjs +++ b/src/public/parts/shells/achievements/public/index.mjs @@ -1,8 +1,8 @@ /** * 成就页面的主要客户端逻辑。 */ -import { unlockAchievement, loadPart } from '../../../scripts/api/parts.mjs' -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { unlockAchievement, loadPart } from '../../../scripts/endpoints/parts.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { renderTemplate, usingTemplates } from '../../../scripts/features/template.mjs' import { geti18n, geti18n_nowarn, initTranslations } from '../../../scripts/i18n/index.mjs' import { applyTheme } from '../../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/browserIntegration/src/endpoints.mjs b/src/public/parts/shells/browserIntegration/src/endpoints.mjs index 592e99032..e6845da2e 100644 --- a/src/public/parts/shells/browserIntegration/src/endpoints.mjs +++ b/src/public/parts/shells/browserIntegration/src/endpoints.mjs @@ -40,7 +40,7 @@ export function setEndpoints(router) { } }) - router.get('/virtual_files/parts/shells\\:browserIntegration/script.meta.js', async (_req, res) => { + router.get('/virtual_files/parts/shells\\:browserIntegration/script.meta.js', async (req, res) => { const scriptPublicPath = path.join(import.meta.dirname, '..', 'public') const publicScriptPath = path.join(scriptPublicPath, 'script.user.js') const content = await fs.promises.readFile(publicScriptPath, 'utf-8') diff --git a/src/public/parts/shells/cabinet/AGENTS.md b/src/public/parts/shells/cabinet/AGENTS.md index 8547e3e9b..2a72c7f64 100644 --- a/src/public/parts/shells/cabinet/AGENTS.md +++ b/src/public/parts/shells/cabinet/AGENTS.md @@ -14,5 +14,5 @@ alwaysApply: false - **Recoverable delete**: `DELETE …/entries` + `recoverable:true` → `recovery_token`; restore / finalize-delete endpoints. UI undo must finalize discarded tokens. History factories capture unlock at push time — do not re-resolve `currentUnlockToken()` in undo/redo. - **Clipboard / shortcuts**: app-level (`sessionStorage` + `BroadcastChannel`), not OS. Keymap: `public/shared/keyboard`. - **Entity profile**: `#user:{entityHash}` — `/parts/shells:chat/shared/entityProfilePopup.mjs`; stamps via `formatEntityAtId` / `formatHashShort`. -- **UI**: Bootstrap `public/index.mjs` (`applyTheme` + `initTranslations`); state `cabinetStore`; DOM under `public/src/`; Deno-pure helpers under `public/shared/` (`keyboard`, `commandHistory`). Prefer DaisyUI + shared `promptDialog` / `positionContextMenu`. Entry grid: `role="listbox"` + `aria-multiselectable`, cards `role="option"` + `aria-selected`. Context menu: `#contextMenu` = `ul.menu[role=menu]`. SPA hashes: `` `${location.pathname}#…` `` (not bare `href="#…"`). No explanatory `data-i18n` on invisible controls; omit unavailable context-menu items. Custom CSS only for thumb/safe-area/remote chrome. +- **UI**: Bootstrap `public/index.mjs` (`applyTheme` + `initTranslations`); state `cabinetStore`; frontend HTTP named endpoints in `public/src/endpoints.mjs`; DOM under `public/src/`; Deno-pure helpers under `public/shared/` (`keyboard`, `commandHistory`). Prefer DaisyUI + shared `promptDialog` / `positionContextMenu`. Entry grid: `role="listbox"` + `aria-multiselectable`, cards `role="option"` + `aria-selected`. Context menu: `#contextMenu` = `ul.menu[role=menu]`. SPA hashes: `` `${location.pathname}#…` `` (not bare `href="#…"`). No explanatory `data-i18n` on invisible controls; omit unavailable context-menu items. Custom CSS only for thumb/safe-area/remote chrome. - **Tests**: `fount test shells/cabinet --no-parallel` (frontend subtests: `shortcuts`, `daisyui`). Pure suites import `public/shared/` only — not `public/src/`. Keep Social `visibilitySpec` behind dynamic import in `remote.mjs` fetch paths so pure suites do not statically pull Social. diff --git a/src/public/parts/shells/cabinet/public/src/api.mjs b/src/public/parts/shells/cabinet/public/src/api.mjs deleted file mode 100644 index 2139c0196..000000000 --- a/src/public/parts/shells/cabinet/public/src/api.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import { cabinetStore, currentUnlockToken } from './state.mjs' - -const API = '/api/parts/shells:cabinet' - -/** - * @param {string} method HTTP 方法 - * @param {string} path 路径 - * @param {object} [body] body - * @param {Record<string, string>} [headers] 额外头 - * @returns {Promise<any>} JSON - */ -export async function api(method, path, body, headers = {}) { - const res = await fetch(`${API}${path}`, { - method, - credentials: 'include', - headers: { - ...body ? { 'Content-Type': 'application/json' } : {}, - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || err.message || `${method} ${path} ${res.status}`) - } - if (res.headers.get('Content-Type')?.includes('application/zip')) - return res.blob() - return res.json() -} - -/** - * @param {string} [unlockToken] token - * @returns {Record<string, string>} headers - */ -export function unlockHeaders(unlockToken) { - return unlockToken ? { 'X-Cabinet-Unlock': unlockToken } : {} -} - -/** - * 当前柜路径请求(自动带 unlock)。 - * @param {string} method HTTP - * @param {string} subpath 相对 `/cabinets/:id` 的路径 - * @param {object} [body] body - * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 - * @returns {Promise<any>} JSON / blob - */ -export function cabinetApi(method, subpath, body, opts = {}) { - const id = opts.cabinetId ?? cabinetStore.currentCabinetId - return api( - method, - `/cabinets/${encodeURIComponent(id)}${subpath}`, - body, - unlockHeaders(opts.unlock !== undefined ? opts.unlock : currentUnlockToken()), - ) -} - -/** - * @param {string} href 链接 - * @param {string} [filename] 下载名 - * @returns {void} - */ -export function triggerDownload(href, filename) { - const a = document.createElement('a') - a.href = href - if (filename) a.download = filename - a.click() -} diff --git a/src/public/parts/shells/cabinet/public/src/endpoints.mjs b/src/public/parts/shells/cabinet/public/src/endpoints.mjs new file mode 100644 index 000000000..007419ae0 --- /dev/null +++ b/src/public/parts/shells/cabinet/public/src/endpoints.mjs @@ -0,0 +1,254 @@ +/** + * Cabinet shell 前端 named HTTP 端点。 + */ +import { cabinetStore, currentUnlockToken } from './state.mjs' + +const API = '/api/parts/shells:cabinet' + +/** + * @param {string} method HTTP 方法 + * @param {string} path 路径 + * @param {object} [body] body + * @param {Record<string, string>} [headers] 额外头 + * @returns {Promise<any>} JSON / blob + */ +async function request(method, path, body, headers = {}) { + const res = await fetch(`${API}${path}`, { + method, + credentials: 'include', + headers: { + ...body ? { 'Content-Type': 'application/json' } : {}, + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || err.message || `${method} ${path} ${res.status}`) + } + if (res.headers.get('Content-Type')?.includes('application/zip')) + return res.blob() + return res.json() +} + +/** + * @param {string} [unlockToken] token + * @returns {Record<string, string>} headers + */ +export function unlockHeaders(unlockToken) { + return unlockToken ? { 'X-Cabinet-Unlock': unlockToken } : {} +} + +/** + * @param {string} href 链接 + * @param {string} [filename] 下载名 + * @returns {void} + */ +export function triggerDownload(href, filename) { + const a = document.createElement('a') + a.href = href + if (filename) a.download = filename + a.click() +} + +/** + * 当前柜路径请求(自动带 unlock)。 + * @param {string} method HTTP + * @param {string} subpath 相对 `/cabinets/:id` 的路径 + * @param {object} [body] body + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} JSON / blob + */ +function cabinetRequest(method, subpath, body, opts = {}) { + const id = opts.cabinetId ?? cabinetStore.currentCabinetId + return request( + method, + `/cabinets/${encodeURIComponent(id)}${subpath}`, + body, + unlockHeaders(Object.hasOwn(opts, 'unlock') ? opts.unlock : currentUnlockToken()), + ) +} + +/** + * 列出本地柜。 + * @returns {Promise<{ cabinets: object[] }>} 柜列表 + */ +export function listCabinets() { + return request('GET', '/cabinets') +} + +/** + * 创建个人柜。 + * @param {object} body 创建参数 + * @returns {Promise<{ cabinet: object }>} 新建柜 + */ +export function createCabinet(body) { + return request('POST', '/cabinets', body) +} + +/** + * 更新柜元数据。 + * @param {string} cabinetId 柜 + * @param {object} patch 补丁 + * @returns {Promise<any>} 更新结果 + */ +export function patchCabinet(cabinetId, patch) { + return request('PATCH', `/cabinets/${encodeURIComponent(cabinetId)}`, patch) +} + +/** + * 删除柜。 + * @param {string} cabinetId 柜 + * @returns {Promise<any>} 删除结果 + */ +export function deleteCabinet(cabinetId) { + return request('DELETE', `/cabinets/${encodeURIComponent(cabinetId)}`) +} + +/** + * 列出柜内条目(index)。 + * @param {string} cabinetId 柜 + * @param {URLSearchParams | string} query index 查询 + * @param {Record<string, string>} [headers] 额外头(如 unlock) + * @returns {Promise<any>} index 响应 + */ +export function listEntries(cabinetId, query, headers) { + const q = String(query) + return request('GET', `/cabinets/${encodeURIComponent(cabinetId)}/index${q ? `?${q}` : ''}`, null, headers) +} + +/** + * 列出远端实体可见柜。 + * @param {string} entityHash 远端实体 + * @returns {Promise<{ cabinets: object[] }>} 柜列表 + */ +export function listRemoteCabinets(entityHash) { + return request('GET', `/remote/${encodeURIComponent(entityHash)}/cabinets`) +} + +/** + * 列出远端柜内条目。 + * @param {string} entityHash 远端实体 + * @param {string} cabinetId 柜 + * @param {URLSearchParams | string} query index 查询 + * @returns {Promise<any>} index 响应 + */ +export function listRemoteEntries(entityHash, cabinetId, query) { + const q = String(query) + return request( + 'GET', + `/remote/${encodeURIComponent(entityHash)}/cabinets/${encodeURIComponent(cabinetId)}/index${q ? `?${q}` : ''}`, + ) +} + +/** + * 当前 viewer 实体信息。 + * @returns {Promise<{ viewer_entity_hash: string }>} viewer + */ +export function getViewer() { + return request('GET', '/viewer') +} + +/** + * 解锁密码文件夹。 + * @param {{ folder_id: string, password: string }} body 解锁参数 + * @returns {Promise<{ unlock_token: string }>} unlock token + */ +export function unlockCabinet(body) { + return cabinetRequest('POST', '/unlock', body, { unlock: undefined }) +} + +/** + * 解析链接条目目标。 + * @param {string} entryId 条目 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} resolve 结果 + */ +export function resolveEntry(entryId, opts) { + return cabinetRequest('GET', `/entries/${encodeURIComponent(entryId)}/resolve`, null, opts) +} + +/** + * 上传条目预览图。 + * @param {object} body 预览上传 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ url: string }>} 预览 URL + */ +export function uploadPreview(body, opts) { + return cabinetRequest('POST', '/preview', body, opts) +} + +/** + * 创建条目(文件 / 文件夹 / 链接等)。 + * @param {object} body 创建参数 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ entry: object }>} 新建条目 + */ +export function createEntry(body, opts) { + return cabinetRequest('POST', '/entries', body, opts) +} + +/** + * 更新条目。 + * @param {string} entryId 条目 + * @param {object} patch 补丁 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} 更新结果 + */ +export function patchEntry(entryId, patch, opts) { + return cabinetRequest('PATCH', `/entries/${encodeURIComponent(entryId)}`, patch, opts) +} + +/** + * 复制条目到目标柜/目录。 + * @param {string} sourceCabinetId 源柜 + * @param {object} body 复制参数 + * @param {Record<string, string>} [headers] 额外头(目标 unlock) + * @returns {Promise<{ entries: object[] }>} 新建条目 + */ +export function copyEntries(sourceCabinetId, body, headers) { + return request('POST', `/cabinets/${encodeURIComponent(sourceCabinetId)}/entries/copy`, body, headers) +} + +/** + * 下载柜/文件夹 zip。 + * @param {string} [query] zip 查询串(可含 folder_id=…) + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<Blob>} zip blob + */ +export function downloadZip(query = '', opts) { + return cabinetRequest('GET', `/zip${query ? `?${query}` : ''}`, null, opts) +} + +/** + * 可恢复删除条目。 + * @param {string[]} entryIds 条目 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ deleted: string[], recovery_token?: string }>} 删除结果 + */ +export function deleteEntries(entryIds, opts) { + return cabinetRequest('DELETE', '/entries', { entry_ids: entryIds, recoverable: true }, opts) +} + +/** + * 按 recovery token 恢复条目。 + * @param {string} recoveryToken token + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} 恢复结果 + */ +export function restoreEntries(recoveryToken, opts) { + return cabinetRequest('POST', '/entries/restore', { recovery_token: recoveryToken }, opts) +} + +/** + * 永久确认可恢复删除(丢弃 recovery token)。 + * @param {string} recoveryToken token + * @param {{ cabinetId?: string }} [opts] 覆盖(不带 unlock) + * @returns {Promise<any>} 确认结果 + */ +export function finalizeDelete(recoveryToken, opts = {}) { + return cabinetRequest('POST', '/entries/finalize-delete', { recovery_token: recoveryToken }, { + ...opts, + unlock: undefined, + }) +} diff --git a/src/public/parts/shells/cabinet/public/src/entryActions.mjs b/src/public/parts/shells/cabinet/public/src/entryActions.mjs index 34ce2bb28..7b59534fc 100644 --- a/src/public/parts/shells/cabinet/public/src/entryActions.mjs +++ b/src/public/parts/shells/cabinet/public/src/entryActions.mjs @@ -5,8 +5,19 @@ import { confirmAction, promptText } from '/scripts/features/promptDialog.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { arrayBufferToBase64, blobToBase64 } from '/scripts/lib/base64.mjs' -import { api, cabinetApi, triggerDownload, unlockHeaders } from './api.mjs' import { writeClipboard } from './clipboard.mjs' +import { + copyEntries, + createEntry, + downloadZip, + getViewer, + patchEntry, + resolveEntry, + triggerDownload, + unlockCabinet, + unlockHeaders, + uploadPreview, +} from './endpoints.mjs' import { renderEntries, selectedEntries } from './entryGrid.mjs' import { openCabinet, refreshEntries } from './navigation.mjs' import { @@ -44,7 +55,7 @@ export async function promptUnlock(folderId) { submit.onclick = async () => { try { const password = document.getElementById('unlockPassword').value - const result = await cabinetApi('POST', '/unlock', { folder_id: folderId, password }, { unlock: undefined }) + const result = await unlockCabinet({ folder_id: folderId, password }) cabinetStore.unlockTokens.set(folderId, result.unlock_token) dialog.close() await refreshEntries() @@ -72,7 +83,7 @@ export async function onEntryOpen(entry) { } if (entry.kind === 'link') { if (remoteEntityHash) return - const resolved = await cabinetApi('GET', `/entries/${encodeURIComponent(entry.id)}/resolve`) + const resolved = await resolveEntry(entry.id) if (!resolved.ok) { showToastI18n('warning', 'cabinet.brokenLink', { reason: resolved.reason }) entry._broken = true @@ -116,7 +127,7 @@ export async function downloadEntry(entry, cabinetId = cabinetStore.currentCabin ) return } - const entity = ownerEntityHash || (await api('GET', '/viewer')).viewer_entity_hash + const entity = ownerEntityHash || (await getViewer()).viewer_entity_hash triggerDownload( `/api/parts/shells:chat/entities/${encodeURIComponent(entity)}/files/${entry.evfs_path.split('/').map(encodeURIComponent).join('/')}`, entry.name, @@ -137,7 +148,7 @@ export async function uploadFiles(files) { try { const previewBlob = await generateUploadPreview(file) if (previewBlob) { - const uploaded = await cabinetApi('POST', '/preview', { + const uploaded = await uploadPreview({ plaintext_base64: await blobToBase64(previewBlob), name: `preview.${previewBlob.type.includes('avif') ? 'avif' : 'webp'}`, mime_type: previewBlob.type, @@ -146,7 +157,7 @@ export async function uploadFiles(files) { } } catch { /* 预览失败不阻断上传 */ } - const { entry } = await cabinetApi('POST', '/entries', { + const { entry } = await createEntry({ plaintext_base64: arrayBufferToBase64(await file.arrayBuffer()), name: file.name, mime_type: file.type || 'application/octet-stream', @@ -167,7 +178,7 @@ export async function createFolder() { if (!canWrite()) return const name = await promptText('cabinet.new.folderPrompt') if (!name) return - const { entry } = await cabinetApi('POST', '/entries', { + const { entry } = await createEntry({ kind: 'folder', name, parent_id: cabinetStore.currentParentId, @@ -210,7 +221,7 @@ export async function pasteClipboard(asLinks = false) { const sourceParent = clip.source_parent_id ?? null const movedIds = [...clip.entry_ids] for (const id of movedIds) - await cabinetApi('PATCH', `/entries/${encodeURIComponent(id)}`, { parent_id: targetParent }) + await patchEntry(id, { parent_id: targetParent }) writeClipboard(null) cabinetStore.clipboard = null await refreshEntries() @@ -224,7 +235,7 @@ export async function pasteClipboard(asLinks = false) { } const sourceUnlock = clip.source_parent_id ? cabinetStore.unlockTokens.get(clip.source_parent_id) : undefined - const created = await api('POST', `/cabinets/${encodeURIComponent(clip.cabinet_id)}/entries/copy`, { + const created = await copyEntries(clip.cabinet_id, { entry_ids: clip.entry_ids, target_parent_id: cabinetStore.currentParentId, target_cabinet_id: cabinetStore.currentCabinetId, @@ -287,7 +298,7 @@ export async function renameSelection() { if (!entry) return const name = await promptText('cabinet.renamePrompt', entry.name) if (!name || name === entry.name) return - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entry.id)}`, { name }) + await patchEntry(entry.id, { name }) await refreshEntries() await cabinetStore.history.push(makePatchHistory({ entryId: entry.id, @@ -322,7 +333,7 @@ export async function deleteSelection() { */ export async function downloadFolder(folderId, name) { const query = folderId ? `folder_id=${encodeURIComponent(folderId)}` : '' - const blob = await cabinetApi('GET', `/zip?${query}`, null, folderId + const blob = await downloadZip(query, folderId ? { unlock: cabinetStore.unlockTokens.get(folderId) } : {}) const url = URL.createObjectURL(blob) diff --git a/src/public/parts/shells/cabinet/public/src/navigation.mjs b/src/public/parts/shells/cabinet/public/src/navigation.mjs index e88144c72..6ea3a1d4e 100644 --- a/src/public/parts/shells/cabinet/public/src/navigation.mjs +++ b/src/public/parts/shells/cabinet/public/src/navigation.mjs @@ -3,7 +3,7 @@ */ import { confirmAction, promptText } from '/scripts/features/promptDialog.mjs' -import { api, unlockHeaders } from './api.mjs' +import { listCabinets, listEntries, listRemoteCabinets, listRemoteEntries, deleteCabinet, patchCabinet, unlockHeaders } from './endpoints.mjs' import { promptUnlock } from './entryActions.mjs' import { renderEntries, renderStatus } from './entryGrid.mjs' import { renderRemoteEntityBar } from './remoteBrowse.mjs' @@ -40,7 +40,7 @@ export function locationHashFor(cabinetId, parentId = null) { * @returns {Promise<void>} */ export async function refreshCabinets() { - const data = await api('GET', '/cabinets') + const data = await listCabinets() cabinetStore.cabinets = data.cabinets || [] cabinetStore.cabinets.sort((a, b) => { if (a.type !== b.type) return a.type === 'personal' ? -1 : 1 @@ -95,13 +95,13 @@ async function cabinetContext(cabinet) { if (action === 'rename') { const name = await promptText('cabinet.renamePrompt', cabinet.name) if (!name) return - await api('PATCH', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`, { name }) + await patchCabinet(cabinet.cabinet_id, { name }) await refreshCabinets() } else if (action === 'delete') { if (!await confirmAction('cabinet.confirmDeleteCabinet')) return const wasCurrent = cabinetStore.currentCabinetId === cabinet.cabinet_id - await api('DELETE', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`) + await deleteCabinet(cabinet.cabinet_id) await refreshCabinets() if (wasCurrent) { const next = cabinetStore.cabinets[0]?.cabinet_id @@ -115,7 +115,7 @@ async function cabinetContext(cabinet) { cabinet.visibility?.visibility || 'private', ) if (!visibility) return - await api('PATCH', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`, { visibility: { visibility } }) + await patchCabinet(cabinet.cabinet_id, { visibility: { visibility } }) await refreshCabinets() } } @@ -146,16 +146,8 @@ export async function refreshEntries() { if (currentParentId) query.set('parent_id', currentParentId) if (showHidden) query.set('show_hidden', '1') const data = remoteEntityHash - ? await api( - 'GET', - `/remote/${encodeURIComponent(remoteEntityHash)}/cabinets/${encodeURIComponent(currentCabinetId)}/index?${query}`, - ) - : await api( - 'GET', - `/cabinets/${encodeURIComponent(currentCabinetId)}/index?${query}`, - null, - unlockHeaders(currentUnlockToken()), - ) + ? await listRemoteEntries(remoteEntityHash, currentCabinetId, query) + : await listEntries(currentCabinetId, query, unlockHeaders(currentUnlockToken())) cabinetStore.currentCabinet = data.cabinet cabinetStore.folderTrail = data.folder_trail || [] await renderBreadcrumb() @@ -281,7 +273,7 @@ export async function bootFromHash() { const parts = hash.slice(5).split('/') const entityHash = parts[0].toLowerCase() setBrowseMode(entityHash) - const data = await api('GET', `/remote/${encodeURIComponent(entityHash)}/cabinets`) + const data = await listRemoteCabinets(entityHash) cabinetStore.cabinets = data.cabinets || [] renderCabinetList() const cabinetId = parts[1] || cabinetStore.cabinets[0]?.cabinet_id diff --git a/src/public/parts/shells/cabinet/public/src/properties.mjs b/src/public/parts/shells/cabinet/public/src/properties.mjs index a22996aca..3b5231751 100644 --- a/src/public/parts/shells/cabinet/public/src/properties.mjs +++ b/src/public/parts/shells/cabinet/public/src/properties.mjs @@ -3,7 +3,7 @@ */ import { setElementI18n } from '/scripts/i18n/index.mjs' -import { cabinetApi } from './api.mjs' +import { patchEntry } from './endpoints.mjs' import { formatStamp, selectedEntries } from './entryGrid.mjs' import { refreshEntries } from './navigation.mjs' import { makePatchHistory } from './recoveryHistory.mjs' @@ -78,7 +78,7 @@ export async function saveProps() { const password = document.getElementById('propFolderPassword').value if (entry.kind === 'folder' && password) patch.set_password = password - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entry.id)}`, patch) + await patchEntry(entry.id, patch) document.getElementById('propsDialog').close() await refreshEntries() if (!password) diff --git a/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs b/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs index 8ff742c47..863e5a45a 100644 --- a/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs +++ b/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs @@ -1,7 +1,9 @@ /** * 可恢复删除 / 创建 / 补丁 的撤销历史工厂。 */ -import { cabinetApi } from './api.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' + +import { deleteEntries, finalizeDelete, patchEntry, restoreEntries } from './endpoints.mjs' import { currentUnlockToken } from './state.mjs' /** @returns {Promise<void>} */ @@ -17,9 +19,12 @@ async function refreshEntries() { */ export async function finalizeRecovery(cabinetId, recoveryToken) { if (!recoveryToken) return - await cabinetApi('POST', '/entries/finalize-delete', { recovery_token: recoveryToken }, { - cabinetId, unlock: undefined, - }).catch(() => { }) + try { + await finalizeDelete(recoveryToken, { cabinetId }) + } + catch (error) { + handleError('cabinet.bootstrapFailed', {}, error) + } } /** @@ -29,7 +34,7 @@ export async function finalizeRecovery(cabinetId, recoveryToken) { * @returns {Promise<{ deleted: string[], recovery_token?: string }>} 删除结果 */ export async function recoverableDelete(cabinetId, entryIds, unlock) { - return cabinetApi('DELETE', '/entries', { entry_ids: entryIds, recoverable: true }, { cabinetId, unlock }) + return deleteEntries(entryIds, { cabinetId, unlock }) } /** @@ -39,7 +44,7 @@ export async function recoverableDelete(cabinetId, entryIds, unlock) { * @returns {Promise<void>} */ export async function restoreRecovery(cabinetId, recoveryToken, unlock) { - await cabinetApi('POST', '/entries/restore', { recovery_token: recoveryToken }, { cabinetId, unlock }) + await restoreEntries(recoveryToken, { cabinetId, unlock }) } /** @@ -101,38 +106,39 @@ export function makeDeleteHistory(ids, initialToken, cabinetId, unlock = current } /** - * @param {{ entryId: string, before: object, after: object, label?: string, cabinetId: string }} opts 选项 + * @param {{ entryId: string, before: object, after: object, label?: string, cabinetId: string, unlock?: string }} opts 选项 * @returns {import('./commandHistory.mjs').HistoryEntry} 历史 */ -export function makePatchHistory({ entryId, before, after, label = 'patch', cabinetId }) { - const path = `/entries/${encodeURIComponent(entryId)}` +export function makePatchHistory({ entryId, before, after, label = 'patch', cabinetId, unlock = currentUnlockToken() }) { + const unlockToken = unlock return { label, /** 撤销 PATCH:写回修改前快照。 */ async undo() { - await cabinetApi('PATCH', path, before, { cabinetId }) + await patchEntry(entryId, before, { cabinetId, unlock: unlockToken }) await refreshEntries() }, /** 重做 PATCH:应用修改后快照。 */ async redo() { - await cabinetApi('PATCH', path, after, { cabinetId }) + await patchEntry(entryId, after, { cabinetId, unlock: unlockToken }) await refreshEntries() }, } } /** - * @param {{ entryIds: string[], fromParent: string | null, toParent: string | null, label?: string, cabinetId: string }} opts 选项 + * @param {{ entryIds: string[], fromParent: string | null, toParent: string | null, label?: string, cabinetId: string, unlock?: string }} opts 选项 * @returns {import('./commandHistory.mjs').HistoryEntry} 历史 */ -export function makeMoveHistory({ entryIds, fromParent, toParent, label = 'cut', cabinetId }) { +export function makeMoveHistory({ entryIds, fromParent, toParent, label = 'cut', cabinetId, unlock = currentUnlockToken() }) { + const unlockToken = unlock /** * @param {string | null} parentId 父 * @returns {Promise<void>} */ async function moveAll(parentId) { for (const entryId of entryIds) - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entryId)}`, { parent_id: parentId }, { cabinetId }) + await patchEntry(entryId, { parent_id: parentId }, { cabinetId, unlock: unlockToken }) await refreshEntries() } return { diff --git a/src/public/parts/shells/cabinet/public/src/wiring.mjs b/src/public/parts/shells/cabinet/public/src/wiring.mjs index f1c71604b..ed9b15a30 100644 --- a/src/public/parts/shells/cabinet/public/src/wiring.mjs +++ b/src/public/parts/shells/cabinet/public/src/wiring.mjs @@ -5,9 +5,9 @@ import { promptText } from '/scripts/features/promptDialog.mjs' import { matchCabinetShortcut } from '../shared/keyboard.mjs' -import { api } from './api.mjs' import { runCommand } from './commands.mjs' import { hideContextMenu, showContextMenu } from './contextMenu.mjs' +import { createCabinet } from './endpoints.mjs' import { uploadFiles } from './entryActions.mjs' import { refreshCabinets, openCabinet, refreshEntries } from './navigation.mjs' import { saveProps } from './properties.mjs' @@ -20,16 +20,16 @@ export function wireBootstrap() { /** * @returns {Promise<void>} 创建个人柜并打开 */ - const createCabinet = async () => { + const onCreateCabinet = async () => { const name = await promptText('cabinet.new.cabinetPrompt') if (!name) return const visibility = await promptText('cabinet.visibilityPrompt', 'private') || 'private' - const { cabinet } = await api('POST', '/cabinets', { name, visibility: { visibility }, type: 'personal' }) + const { cabinet } = await createCabinet({ name, visibility: { visibility }, type: 'personal' }) await refreshCabinets() if (cabinet?.cabinet_id) await openCabinet(cabinet.cabinet_id) } for (const el of document.querySelectorAll('[data-action="new-cabinet"]')) - el.onclick = createCabinet + el.onclick = onCreateCabinet /** * @param {Event} event 文件/文件夹选择变更 * @returns {Promise<void>} diff --git a/src/public/parts/shells/cabinet/src/cabinets.mjs b/src/public/parts/shells/cabinet/src/cabinets.mjs index 8b577c72f..0bd77d308 100644 --- a/src/public/parts/shells/cabinet/src/cabinets.mjs +++ b/src/public/parts/shells/cabinet/src/cabinets.mjs @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto' +import { handleError } from 'fount/scripts/errorHandlers.mjs' + import { normalizeVisibilitySpec } from '../../social/src/lib/visibilitySpec.mjs' import { normalizeIndex } from './entryModel.mjs' @@ -25,7 +27,7 @@ export async function loadCabinets(username, entityHash) { */ export async function saveCabinets(username, entityHash, cabinets) { await writeJsonFile(cabinetsListPath(username, entityHash), { cabinets }) - await publishCabinetLists(username, entityHash, cabinets).catch(() => { }) + await publishCabinetLists(username, entityHash, cabinets).catch(handleError) } /** @@ -162,5 +164,5 @@ export async function savePersonalIndex(username, entityHash, cabinetId, index) await writeJsonFile(cabinetIndexPath(username, entityHash, cabinetId), normalized) const cabinet = await getCabinet(username, entityHash, cabinetId) if (cabinet?.type === 'personal') - await publishCabinetIndex(username, entityHash, cabinet, normalized).catch(() => { }) + await publishCabinetIndex(username, entityHash, cabinet, normalized).catch(handleError) } diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 1626c2c61..2e3ebdbf3 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -5,7 +5,7 @@ * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 * 【关联】../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/api/groupCore、groupClient、groupChannel、core/domUtils、core/overlayModal、core/state。 */ -import { getPartList } from '../../../../scripts/api/parts.mjs' +import { getPartList } from '../../../../scripts/endpoints/parts.mjs' import { mountTemplate, renderTemplateAsHtmlString } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { triggerChannelReply } from '../src/api/groupChannel.mjs' diff --git a/src/public/parts/shells/chat/public/hub/friendsList.mjs b/src/public/parts/shells/chat/public/hub/friendsList.mjs index f96ca928d..bc7e93344 100644 --- a/src/public/parts/shells/chat/public/hub/friendsList.mjs +++ b/src/public/parts/shells/chat/public/hub/friendsList.mjs @@ -7,7 +7,7 @@ */ import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' -import { getAllCachedPartDetails, getPartList } from '../../../../scripts/api/parts.mjs' +import { getAllCachedPartDetails, getPartList } from '../../../../scripts/endpoints/parts.mjs' import { mountTemplate, renderTemplate } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n, geti18n } from '../../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs b/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs index bb95d5daf..0f9418c22 100644 --- a/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs +++ b/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs @@ -5,7 +5,7 @@ * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 * 【关联】../../../../scripts/i18n、../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/api/groupCore、groupClient、../src/inviteQr、chat、core/domUtils。 */ -import { getPartList } from '../../../../scripts/api/parts.mjs' +import { getPartList } from '../../../../scripts/endpoints/parts.mjs' import { openDialogFromTemplate } from '../../../../scripts/features/dialog.mjs' import { renderTemplate, diff --git a/src/public/parts/shells/chat/public/hub/profileEdit.mjs b/src/public/parts/shells/chat/public/hub/profileEdit.mjs index 432c02178..2ab99b826 100644 --- a/src/public/parts/shells/chat/public/hub/profileEdit.mjs +++ b/src/public/parts/shells/chat/public/hub/profileEdit.mjs @@ -5,7 +5,7 @@ * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 * 【关联】../../../../scripts/i18n、../../../../scripts/template、../../../../scripts/toast、../profile/src/endpoints、../src/entityProfileApi、../src/profileLocaleEditor、core/state、presence。 */ -import { getUserSetting } from '/scripts/api/base.mjs' +import { getUserSetting } from '/scripts/endpoints/base.mjs' import { renderTemplate, usingTemplates } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n, primaryLocale } from '../../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs b/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs index 71e7a95ed..cf954e373 100644 --- a/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs +++ b/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs @@ -3,7 +3,7 @@ * 【职责】Hub 轻量事件绑定:不依赖 messages/init 重模块图,保证建群、composer、hash 导航等壳层交互尽快可用。 * 【关联】groupModals、dialog、wireEvents(其余绑定延后加载) */ -import { onServerEvent } from '../../../../../scripts/api/server_events.mjs' +import { onServerEvent } from '../../../../../scripts/endpoints/server_events.mjs' import { openDialogFromTemplate } from '../../../../../scripts/features/dialog.mjs' import { withTemplates } from '../../../../../scripts/features/template.mjs' import { iconifyImg } from '../../src/lib/emojiSvg.mjs' diff --git a/src/public/parts/shells/chat/public/profile/index.mjs b/src/public/parts/shells/chat/public/profile/index.mjs index ba727ae57..ff441a793 100644 --- a/src/public/parts/shells/chat/public/profile/index.mjs +++ b/src/public/parts/shells/chat/public/profile/index.mjs @@ -5,7 +5,7 @@ * 【数据结构】currentEntityHash、currentProfile;localized 各 locale 字段。 * 【关联】profile/src/endpoints.mjs、ownerSettingsPanel.mjs;hub/entityProfile.mjs、profileEdit.mjs。 */ -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { renderTemplate, usingTemplates, diff --git a/src/public/parts/shells/chat/public/src/achievements.mjs b/src/public/parts/shells/chat/public/src/achievements.mjs index 2d1ba4a36..fdc7b4f72 100644 --- a/src/public/parts/shells/chat/public/src/achievements.mjs +++ b/src/public/parts/shells/chat/public/src/achievements.mjs @@ -3,9 +3,9 @@ * 【职责】chat shell 前端成就钩子:监听 Markdown 代码块执行等并解锁对应成就。 * 【原理】initializeAchievements 注册 window 事件监听,满足条件时 unlockAchievement(parts API)。 * 【数据结构】无持久模块状态;事件 detail 由 markdown 渲染器发出。 - * 【关联】@pages/scripts/api/parts.mjs;chatMarkdown 代码块执行。 + * 【关联】@pages/scripts/endpoints/parts.mjs;chatMarkdown 代码块执行。 */ -import { unlockAchievement } from '../../../scripts/api/parts.mjs' +import { unlockAchievement } from '../../../scripts/endpoints/parts.mjs' /** 初始化聊天 shell 的成就系统,注册相关事件监听。 */ export async function initializeAchievements() { diff --git a/src/public/parts/shells/chat/test/manifest.json b/src/public/parts/shells/chat/test/manifest.json index ef92de5aa..f7d0284f0 100644 --- a/src/public/parts/shells/chat/test/manifest.json +++ b/src/public/parts/shells/chat/test/manifest.json @@ -12,7 +12,7 @@ ], "shellLiveCommon": [ "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs", "src/public/pages/scripts/components/emojiPicker.mjs" ], @@ -21,7 +21,7 @@ "deno.json", "src/server/p2p_server/**", "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/**", "src/public/pages/scripts/lib/sanitizeHtml.mjs", "src/public/parts/shells/chat/public/src/trustedAuthors.mjs", @@ -32,7 +32,7 @@ "src/scripts/search/**", "src/server/p2p_server/**", "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs", "src/public/pages/scripts/components/emojiPicker.mjs" ], diff --git a/src/public/parts/shells/config/public/index.mjs b/src/public/parts/shells/config/public/index.mjs index 5096c6306..68334b523 100644 --- a/src/public/parts/shells/config/public/index.mjs +++ b/src/public/parts/shells/config/public/index.mjs @@ -5,7 +5,7 @@ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' import { initTranslations, i18nElement, geti18n, confirmI18n, console } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartDetails } from '/scripts/api/parts.mjs' +import { getPartDetails } from '/scripts/endpoints/parts.mjs' import { svgInliner } from '/scripts/lib/svgInliner.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' diff --git a/src/public/parts/shells/debug_info/public/index.mjs b/src/public/parts/shells/debug_info/public/index.mjs index 7910e2c39..1358b012e 100644 --- a/src/public/parts/shells/debug_info/public/index.mjs +++ b/src/public/parts/shells/debug_info/public/index.mjs @@ -2,9 +2,9 @@ import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { initTranslations } from '/scripts/i18n/index.mjs' import { mountTemplate, renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' -import { ping } from '/scripts/api/base.mjs' +import { ping } from '/scripts/endpoints/base.mjs' import { getAutoUpdateEnabled, getSystemInfo, postRestart } from './src/endpoints.mjs' applyTheme() diff --git a/src/public/parts/shells/deskpet/public/index.mjs b/src/public/parts/shells/deskpet/public/index.mjs index 94fae4960..097768134 100644 --- a/src/public/parts/shells/deskpet/public/index.mjs +++ b/src/public/parts/shells/deskpet/public/index.mjs @@ -6,8 +6,8 @@ import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' import { renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { getRunningPetList, diff --git a/src/public/parts/shells/discordbot/public/index.mjs b/src/public/parts/shells/discordbot/public/index.mjs index f743f5d9d..cfb7894cd 100644 --- a/src/public/parts/shells/discordbot/public/index.mjs +++ b/src/public/parts/shells/discordbot/public/index.mjs @@ -3,7 +3,7 @@ */ import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/public/parts/shells/home/public/src/data.mjs b/src/public/parts/shells/home/public/src/data.mjs index 11736dcf0..fc0565f52 100644 --- a/src/public/parts/shells/home/public/src/data.mjs +++ b/src/public/parts/shells/home/public/src/data.mjs @@ -1,4 +1,4 @@ -import { getAllCachedPartDetails, getPartDetails } from '../../../../../scripts/api/parts.mjs' +import { getAllCachedPartDetails, getPartDetails } from '../../../../../scripts/endpoints/parts.mjs' /** * 部件详细信息缓存,以便其他模块可以访问。 diff --git a/src/public/parts/shells/home/public/src/events.mjs b/src/public/parts/shells/home/public/src/events.mjs index d02cb8888..76c98c830 100644 --- a/src/public/parts/shells/home/public/src/events.mjs +++ b/src/public/parts/shells/home/public/src/events.mjs @@ -1,6 +1,6 @@ -import { setUserSetting } from '../../../scripts/api/base.mjs' -import { unlockAchievement } from '../../../scripts/api/parts.mjs' -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { setUserSetting } from '../../../scripts/endpoints/base.mjs' +import { unlockAchievement } from '../../../scripts/endpoints/parts.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { showToastI18n } from '../../../scripts/features/toast.mjs' import { confirmI18n, onLanguageChange } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/home/public/src/home.mjs b/src/public/parts/shells/home/public/src/home.mjs index a70d6fd56..c2dcb6ac3 100644 --- a/src/public/parts/shells/home/public/src/home.mjs +++ b/src/public/parts/shells/home/public/src/home.mjs @@ -2,8 +2,8 @@ * 主页 shell 的客户端逻辑。 */ -import { getUserSetting } from '../../../scripts/api/base.mjs' -import { unlockAchievement, getAllDefaultParts, getPartBranches } from '../../../scripts/api/parts.mjs' +import { getUserSetting } from '../../../scripts/endpoints/base.mjs' +import { unlockAchievement, getAllDefaultParts, getPartBranches } from '../../../scripts/endpoints/parts.mjs' import { showToast } from '../../../scripts/features/toast.mjs' import { applyUrlParamsTransferStrategy } from '../../../scripts/host/urlDataTransfer.mjs' import { initTranslations, console } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/home/public/src/ui.mjs b/src/public/parts/shells/home/public/src/ui.mjs index 614f7e9fc..8c80bdc76 100644 --- a/src/public/parts/shells/home/public/src/ui.mjs +++ b/src/public/parts/shells/home/public/src/ui.mjs @@ -1,7 +1,7 @@ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' -import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAllDefaultParts } from '../../../scripts/api/parts.mjs' import { getFiltersFromString, compileFilter, makeSearchable } from '../../../scripts/components/search.mjs' +import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAllDefaultParts } from '../../../scripts/endpoints/parts.mjs' import { renderMarkdown } from '../../../scripts/features/markdown/index.mjs' import { mountTemplate, renderTemplate, usingTemplates } from '../../../scripts/features/template.mjs' import { geti18n, console } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/ideIntegration/public/index.mjs b/src/public/parts/shells/ideIntegration/public/index.mjs index 69c41b6c8..0d0588894 100644 --- a/src/public/parts/shells/ideIntegration/public/index.mjs +++ b/src/public/parts/shells/ideIntegration/public/index.mjs @@ -1,8 +1,8 @@ /** * IDE 集成配置页:API 密钥、角色选择、一站式 Agent 脚本 URL(Zed 用 deno 跑远端脚本)。 */ -import { verifyApiKey, createApiKey } from '/scripts/api/base.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { verifyApiKey, createApiKey } from '/scripts/endpoints/base.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { initTranslations } from '/scripts/i18n/index.mjs' import { renderMarkdown } from '/scripts/features/markdown/index.mjs' diff --git a/src/public/parts/shells/install/public/uninstall/index.mjs b/src/public/parts/shells/install/public/uninstall/index.mjs index 79e443a5c..c3a1a8fb9 100644 --- a/src/public/parts/shells/install/public/uninstall/index.mjs +++ b/src/public/parts/shells/install/public/uninstall/index.mjs @@ -2,7 +2,7 @@ * 卸载 shell 的客户端逻辑。 */ import { initTranslations, geti18n } from '/scripts/i18n/index.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n, setToastContainer, getToastContainer, setDefaultToastDuration } from '/scripts/features/toast.mjs' import { uninstallPart as uninstallPartEndpoint } from '../src/endpoints.mjs' diff --git a/src/public/parts/shells/proxy/public/index.mjs b/src/public/parts/shells/proxy/public/index.mjs index c52688274..87db3e7e6 100644 --- a/src/public/parts/shells/proxy/public/index.mjs +++ b/src/public/parts/shells/proxy/public/index.mjs @@ -1,7 +1,7 @@ /** * 代理 shell 的客户端逻辑。 */ -import { verifyApiKey, createApiKey } from '/scripts/api/base.mjs' +import { verifyApiKey, createApiKey } from '/scripts/endpoints/base.mjs' import { initTranslations, console } from '/scripts/i18n/index.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { mountTemplate, usingTemplates } from '/scripts/features/template.mjs' diff --git a/src/public/parts/shells/serviceSourceManage/public/index.mjs b/src/public/parts/shells/serviceSourceManage/public/index.mjs index 72e7171a2..527dda9da 100644 --- a/src/public/parts/shells/serviceSourceManage/public/index.mjs +++ b/src/public/parts/shells/serviceSourceManage/public/index.mjs @@ -3,8 +3,8 @@ */ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' -import { unlockAchievement, getPartList, getPartBranches, getAllDefaultParts, getAnyPreferredDefaultPart, setDefaultPart, unsetDefaultPart } from '../../scripts/api/parts.mjs' import { createJsonEditor } from '../../scripts/components/jsonEditor.mjs' +import { unlockAchievement, getPartList, getPartBranches, getAllDefaultParts, getAnyPreferredDefaultPart, setDefaultPart, unsetDefaultPart } from '../../scripts/endpoints/parts.mjs' import { renderTemplate, usingTemplates } from '../../scripts/features/template.mjs' import { showToast, showToastI18n } from '../../scripts/features/toast.mjs' import { initTranslations, i18nElement, console, geti18n, confirmI18n, promptI18n } from '../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/social/public/src/init.mjs b/src/public/parts/shells/social/public/src/init.mjs index c0d7f41cc..e38a137a4 100644 --- a/src/public/parts/shells/social/public/src/init.mjs +++ b/src/public/parts/shells/social/public/src/init.mjs @@ -1,5 +1,5 @@ -import { onServerEvent } from '../../../../scripts/api/server_events.mjs' import { wireEmojiPickerButton } from '../../../../scripts/components/emojiPicker.mjs' +import { onServerEvent } from '../../../../scripts/endpoints/server_events.mjs' import { createReadyGate } from '/scripts/test/ready_gate.mjs' import { loadAliases } from '/parts/shells:chat/shared/aliases.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' diff --git a/src/public/parts/shells/social/test/manifest.json b/src/public/parts/shells/social/test/manifest.json index cb0020e2d..da58d2b3c 100644 --- a/src/public/parts/shells/social/test/manifest.json +++ b/src/public/parts/shells/social/test/manifest.json @@ -15,7 +15,7 @@ "src/server/p2p_server/**", "src/scripts/search/**", "src/server/registries.mjs", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/components/emojiPicker.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs" ], @@ -26,7 +26,7 @@ "src/server/p2p_server/**", "src/scripts/search/**", "src/server/registries.mjs", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/components/emojiPicker.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs" ], @@ -52,7 +52,7 @@ "src/public/parts/shells/chat/public/markdown_extensions/**", "src/public/parts/shells/chat/src/group/{groupPreview,emojiContentResolve}.mjs", "src/server/registries.mjs", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/components/emojiPicker.mjs" ], "liveCrossShellEmoji": [ @@ -60,7 +60,7 @@ ], "wsExtra": [ "src/server/registries.mjs", - "src/public/pages/scripts/api/registries.mjs" + "src/public/pages/scripts/endpoints/registries.mjs" ], "frontendShared": [ "src/public/parts/shells/social/test/frontend/{fixtures.mjs,run.mjs,phases.mjs,playwright.config.mjs}", diff --git a/src/public/parts/shells/subfounts/public/subfount.mjs b/src/public/parts/shells/subfounts/public/subfount.mjs index 3161348a6..d1b439418 100644 --- a/src/public/parts/shells/subfounts/public/subfount.mjs +++ b/src/public/parts/shells/subfounts/public/subfount.mjs @@ -512,9 +512,11 @@ async function connectViaP2P() { const { getLink, ensureLinkToNode } = await import('npm:@steve02081504/fount-p2p/transport/link_registry') for (let attempt = 0; attempt < 30; attempt++) { if (getLink(hostNodeHashHint)) break - void ensureLinkToNode(hostNodeHashHint).catch(() => null) + void ensureLinkToNode(hostNodeHashHint).catch(() => { }) await new Promise(resolve => setTimeout(resolve, 1000)) } + if (!getLink(hostNodeHashHint)) + console.warn('subfount: no link to host after warmup', hostNodeHashHint) } room = p2p.createGroupLinkSet({ diff --git a/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs b/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs index b7c3679b6..0a7403b50 100644 --- a/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs +++ b/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs @@ -69,9 +69,11 @@ if (hostNodeHashHint) { await markStage('link-warmup') for (let attempt = 0; attempt < 90; attempt++) { if (getLink(hostNodeHashHint)) break - void ensureLinkToNode(hostNodeHashHint).catch(() => null) + void ensureLinkToNode(hostNodeHashHint).catch(() => { }) await sleep(1000) } + if (!getLink(hostNodeHashHint)) + console.warn('subfount worker: no link to host after warmup', hostNodeHashHint) } await markStage('link-ready') diff --git a/src/public/parts/shells/telegrambot/public/index.mjs b/src/public/parts/shells/telegrambot/public/index.mjs index 76255f88f..b6532335b 100644 --- a/src/public/parts/shells/telegrambot/public/index.mjs +++ b/src/public/parts/shells/telegrambot/public/index.mjs @@ -3,7 +3,7 @@ */ import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/public/parts/shells/themeManage/public/index.mjs b/src/public/parts/shells/themeManage/public/index.mjs index 2f6a0d5cb..85f254bfa 100644 --- a/src/public/parts/shells/themeManage/public/index.mjs +++ b/src/public/parts/shells/themeManage/public/index.mjs @@ -2,7 +2,7 @@ import { confirmI18n, initTranslations, promptI18n } from '/scripts/i18n/index.m import { showToastI18n } from '/scripts/features/toast.mjs' import { makeSearchable } from '/scripts/components/search.mjs' import { renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { unlockAchievement } from '/scripts/api/parts.mjs' +import { unlockAchievement } from '/scripts/endpoints/parts.mjs' import { applyTheme, builtin_themes, diff --git a/src/public/parts/shells/tutorial/public/index.mjs b/src/public/parts/shells/tutorial/public/index.mjs index d4a633029..803dd110e 100644 --- a/src/public/parts/shells/tutorial/public/index.mjs +++ b/src/public/parts/shells/tutorial/public/index.mjs @@ -4,7 +4,7 @@ import * as Sentry from 'https://esm.sh/@sentry/browser' /* global confetti */ -import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAnyDefaultPart } from '../../scripts/api/parts.mjs' +import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAnyDefaultPart } from '../../scripts/endpoints/parts.mjs' import { initTranslations, geti18n } from '../../scripts/i18n/index.mjs' import { svgInliner } from '../../scripts/lib/svgInliner.mjs' import { applyTheme } from '../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/userSettings/public/index.mjs b/src/public/parts/shells/userSettings/public/index.mjs index 02f152923..0e71822a6 100644 --- a/src/public/parts/shells/userSettings/public/index.mjs +++ b/src/public/parts/shells/userSettings/public/index.mjs @@ -1,7 +1,7 @@ /** * 用户设置 shell 的客户端逻辑。 */ -import { getApiKeys, createApiKey, revokeApiKey, logout } from '../../scripts/api/base.mjs' +import { getApiKeys, createApiKey, revokeApiKey, logout } from '../../scripts/endpoints/base.mjs' import { showToastI18n } from '../../scripts/features/toast.mjs' import { initTranslations, geti18n, promptI18n, confirmI18n, console } from '../../scripts/i18n/index.mjs' import { applyTheme } from '../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/wechatbot/public/index.mjs b/src/public/parts/shells/wechatbot/public/index.mjs index 3669a0272..acb1969c3 100644 --- a/src/public/parts/shells/wechatbot/public/index.mjs +++ b/src/public/parts/shells/wechatbot/public/index.mjs @@ -3,7 +3,7 @@ import qrcode from 'https://esm.sh/qrcode-generator' import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/scripts/checks/AGENTS.md b/src/scripts/checks/AGENTS.md index 338c1c05f..8ca607b0d 100644 --- a/src/scripts/checks/AGENTS.md +++ b/src/scripts/checks/AGENTS.md @@ -36,7 +36,7 @@ Playwright and `[i18n:missing]` miss these cases: Rules: - Element binding (`data-i18n`, `setElementI18n`): the key must exist; objects need ≥1 applicator (`placeholder`, `title`, `label`, `value`, `alt`, `aria-label`, `textContent`, `innerHTML`, `dataset`). Prefer `.main` for “string plus sibling messages” clusters. -- String binding (`showToastI18n`, `confirmI18n`, `handleUIError`, …) and `path/fount.{ps1,sh}` `Get-I18n` / `get_i18n`: must resolve to a string (or tip array). Raw `geti18n` may return objects (e.g. `util.zxcvbn`); only missing keys fail. +- String binding (`showToastI18n`, `confirmI18n`, …) and `path/fount.{ps1,sh}` `Get-I18n` / `get_i18n`: must resolve to a string (or tip array). Raw `geti18n` may return objects (e.g. `util.zxcvbn`); only missing keys fail. `handleError('key')` is scanned only when imported from frontend `features/errorHandlers.mjs` (factory; first arg is the i18n key) — not backend `scripts/errorHandlers.mjs` where the first arg is the error. - Static keys only (`a.b.c`); skip template interpolations. Rewrite suffixes include `.sh` (shared `walk.mjs` / `reshape_i18n_keys.py`). ## Agent docs language diff --git a/src/scripts/checks/i18n_refs.mjs b/src/scripts/checks/i18n_refs.mjs index 278015b19..417a3e2d9 100644 --- a/src/scripts/checks/i18n_refs.mjs +++ b/src/scripts/checks/i18n_refs.mjs @@ -168,8 +168,11 @@ export function extractI18nRefsFromSource(text) { promptText: 'string', promptTextArea: 'string', confirmAction: 'string', - handleUIError: 'string', } + // 仅前端 features/errorHandlers 的工厂形式:首参是 i18n key;后端 scripts/errorHandlers 首参是 error。 + if (/\bimport\s*\{[^}]*\bhandleError\b[^}]*\}\s*from\s*['"][^'"]*features\/errorHandlers\.mjs['"]/.test(text)) + apis.handleError = 'string' + for (const [name, binding] of Object.entries(apis)) { const re = new RegExp(`\\b${name}\\s*\\(\\s*(["'\`])([^"'\`]+)\\1`, 'g') for (const match of text.matchAll(re)) { diff --git a/src/scripts/checks/test/i18n_refs.test.mjs b/src/scripts/checks/test/i18n_refs.test.mjs index 90ff1a8db..cfaa9e366 100644 --- a/src/scripts/checks/test/i18n_refs.test.mjs +++ b/src/scripts/checks/test/i18n_refs.test.mjs @@ -98,6 +98,28 @@ Deno.test('extractI18nRefsFromSource skips data-i18n literals', () => { assertEquals(refs.map(r => r.key), ['chat.hub.send.main']) }) +Deno.test('handleError first-arg key only when imported from frontend features/errorHandlers', () => { + const frontend = ` +import { handleError } from '/scripts/features/errorHandlers.mjs' +handleError('cabinet.bootstrapFailed') +.catch(handleError('chat.hub.send.failed')) +` + assertEquals( + extractI18nRefsFromSource(frontend).filter(r => r.binding === 'string').map(r => r.key), + ['cabinet.bootstrapFailed', 'chat.hub.send.failed'], + ) + + const backend = ` +import { handleError } from 'fount/scripts/errorHandlers.mjs' +handleError('not.an.i18n.key') +await work().catch(handleError) +` + assertEquals( + extractI18nRefsFromSource(backend).filter(r => r.key === 'not.an.i18n.key'), + [], + ) +}) + Deno.test('I18N_REWRITE_SUFFIXES includes shell scripts', () => { assert(I18N_REWRITE_SUFFIXES.includes('.sh')) assert(I18N_REWRITE_SUFFIXES.includes('.ps1')) diff --git a/src/scripts/errorHandlers.mjs b/src/scripts/errorHandlers.mjs new file mode 100644 index 000000000..869328339 --- /dev/null +++ b/src/scripts/errorHandlers.mjs @@ -0,0 +1,30 @@ +/** + * 后端错误处理:只报 fount 自身故障(console + Sentry)。 + * 用户输入/操作过错应返回 4xx / 业务错误给调用方,不要走这里。 + */ +import * as Sentry from 'npm:@sentry/deno' + +import { sentry_enabled } from './sentry_state.mjs' + +/** + * @param {unknown} error 异常或字符串 + * @returns {Error} 规范化 Error + */ +function toError(error) { + if (error instanceof Error) return error + return new Error(String(error?.message ?? error)) +} + +/** + * fount 故障:console.error;Sentry 启用时再上报。 + * 可直接 `.catch(handleError)`。 + * @param {unknown} error 异常 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error} 规范化 Error + */ +export function handleError(error, ...extras) { + const err = toError(error) + console.error(err, ...extras) + if (sentry_enabled) Sentry.captureException(err) + return err +} diff --git a/src/scripts/sentry_state.mjs b/src/scripts/sentry_state.mjs index 89792c737..7561edc70 100644 --- a/src/scripts/sentry_state.mjs +++ b/src/scripts/sentry_state.mjs @@ -1,5 +1,6 @@ /** * Sentry 启用状态与初始化的独立模块。 + * 错误汇报见 `fount/scripts/errorHandlers.mjs`。 */ import * as Sentry from 'npm:@sentry/deno' diff --git a/src/scripts/test/hub/apis/health.mjs b/src/scripts/test/hub/apis/health.mjs index fcb08fc5b..950f99359 100644 --- a/src/scripts/test/hub/apis/health.mjs +++ b/src/scripts/test/hub/apis/health.mjs @@ -8,7 +8,7 @@ import { Router } from 'npm:express' */ export function createHealthRouter() { const router = Router() - router.get('/health', (_req, res) => { + router.get('/health', (req, res) => { res.json({ ok: true }) }) return router diff --git a/src/scripts/test/playwright/pages_server.mjs b/src/scripts/test/playwright/pages_server.mjs index 31f7c24e6..491f3081d 100644 --- a/src/scripts/test/playwright/pages_server.mjs +++ b/src/scripts/test/playwright/pages_server.mjs @@ -73,7 +73,7 @@ export function createPagesApp(projectRoot = REPO_ROOT) { // 测试框架浏览器侧脚本(pages/scripts 未覆盖的路径) app.use('/fount/scripts/test', express.static(path.join(projectRoot, 'src', 'scripts', 'test'))) - app.get('/fount/data/comments.json', async (_req, res) => { + app.get('/fount/data/comments.json', async (req, res) => { try { const response = await fetch(GITHUB_PAGES_COMMENTS_URL, { signal: AbortSignal.timeout(8000), @@ -104,7 +104,7 @@ export function createPagesApp(projectRoot = REPO_ROOT) { }) for (const filePath of hooked_version_files) - app.get(`/fount/${filePath}`, async (_req, res) => { + app.get(`/fount/${filePath}`, async (req, res) => { const body = await getHookedVersionFileContent(filePath) res.type('application/javascript').send(body) }) diff --git a/src/server/test/manifest.json b/src/server/test/manifest.json index 740e10910..bd0ddca09 100644 --- a/src/server/test/manifest.json +++ b/src/server/test/manifest.json @@ -18,7 +18,7 @@ "src/server/registries.mjs", "src/server/no_cors.mjs", "src/scripts/net_listen.mjs", - "src/public/pages/scripts/api/registries.mjs" + "src/public/pages/scripts/endpoints/registries.mjs" ] }, { diff --git a/src/server/web_server/endpoints.mjs b/src/server/web_server/endpoints.mjs index e2a3ca33e..5562147a8 100644 --- a/src/server/web_server/endpoints.mjs +++ b/src/server/web_server/endpoints.mjs @@ -143,7 +143,7 @@ export function registerEndpoints(router) { /** 已认证用户通用 no-CORS 中转:双向流式;见 src/server/no_cors.mjs */ router.all('/api/no-cors', authenticate, handleNoCors) - router.get('/api/notify/vapid-public-key', cors(), async (_req, res) => { + router.get('/api/notify/vapid-public-key', cors(), async (req, res) => { res.status(200).json({ publicKey: await getVapidPublicKey() }) }) router.post('/api/notify/push-subscribe', authenticate, async (req, res) => { diff --git a/src/server/web_server/p2p_endpoints.mjs b/src/server/web_server/p2p_endpoints.mjs index 827371e3d..d1774f403 100644 --- a/src/server/web_server/p2p_endpoints.mjs +++ b/src/server/web_server/p2p_endpoints.mjs @@ -44,17 +44,14 @@ export function registerP2pEndpoints(router) { }) router.get('/api/p2p/network', authenticate, async (req, res) => { - void getUserByReq(req) res.status(200).json(loadNetwork()) }) router.get('/api/p2p/denylist', authenticate, async (req, res) => { - void getUserByReq(req) res.status(200).json(loadDenylist()) }) router.post('/api/p2p/denylist', authenticate, async (req, res) => { - void getUserByReq(req) const body = req.body || {} const scope = String(body.scope || '').trim().toLowerCase() const value = String(body.value || '').trim() @@ -69,7 +66,6 @@ export function registerP2pEndpoints(router) { }) router.get('/api/p2p/mailbox/summary', authenticate, async (req, res) => { - void getUserByReq(req) const { countMailboxPending } = await import('npm:@steve02081504/fount-p2p/mailbox/store') const pendingCount = await countMailboxPending() res.status(200).json({ pendingCount })