From 4e4676ed5041353b0caaf7f9815ea7c1b164d4fd Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Fri, 7 Aug 2026 16:29:51 +0800 Subject: [PATCH] split deps: foundation + path/i18n for stacked PRs --- path/AGENTS.md | 8 +- path/src/cmd/version.ps1 | 4 + path/src/cmd/version.sh | 5 + path/src/env.sh | 12 +- path/src/git.ps1 | 130 +++++++- path/src/git.sh | 116 ++++++- path/src/i18n.ps1 | 1 + path/src/i18n.sh | 22 +- path/src/unix/termux.sh | 90 +++++- path/src/update.sh | 2 +- path/test/git.test.mjs | 163 ++++++++++ src/decl/locale_data.ts | 32 ++ src/public/locales/ar-SA.json | 24 ++ src/public/locales/de-DE.json | 24 ++ src/public/locales/emoji.json | 24 ++ src/public/locales/en-UK.json | 24 ++ src/public/locales/es-ES.json | 24 ++ src/public/locales/fr-FR.json | 24 ++ src/public/locales/hi-IN.json | 24 ++ src/public/locales/is-IS.json | 24 ++ src/public/locales/it-IT.json | 24 ++ src/public/locales/ja-JP.json | 24 ++ src/public/locales/ko-KR.json | 24 ++ src/public/locales/lzh.json | 24 ++ src/public/locales/nl-NL.json | 24 ++ src/public/locales/pt-PT.json | 24 ++ src/public/locales/ru-RU.json | 24 ++ src/public/locales/uk-UA.json | 24 ++ src/public/locales/vi-VN.json | 24 ++ src/public/locales/zh-CN.json | 24 ++ src/public/locales/zh-TW.json | 24 ++ src/public/pages/AGENTS.md | 15 +- src/public/pages/base.mjs | 2 +- src/public/pages/index.html | 2 +- src/public/pages/log_viewer/index.mjs | 2 +- src/public/pages/login/index.mjs | 8 +- src/public/pages/protocolhandler/index.mjs | 4 +- .../pages/scripts/api/p2p/evfsMedia.mjs | 9 - .../scripts/components/partpath_picker.mjs | 4 +- src/public/pages/scripts/endpoints/base.mjs | 283 ++++++++++++++++++ .../pages/scripts/endpoints/desktop.ini | 6 + .../pages/scripts/endpoints/p2p/desktop.ini | 6 + .../pages/scripts/endpoints/p2p/evfsMedia.mjs | 82 +++++ src/public/pages/scripts/endpoints/parts.mjs | 206 +++++++++++++ .../pages/scripts/endpoints/registries.mjs | 48 +++ .../pages/scripts/endpoints/server_events.mjs | 52 ++++ .../scripts/features/emoji/providers.mjs | 2 +- .../pages/scripts/features/errorHandlers.mjs | 43 +++ .../scripts/features/markdown/extensions.mjs | 2 +- .../pages/scripts/host/credentialManager.mjs | 2 +- src/public/pages/scripts/i18n/base.mjs | 2 +- src/public/parts/shells/AGENTS.md | 3 +- .../parts/shells/access/public/index.mjs | 2 +- .../shells/achievements/public/index.mjs | 4 +- .../browserIntegration/src/endpoints.mjs | 2 +- src/public/parts/shells/cabinet/AGENTS.md | 2 +- .../parts/shells/cabinet/public/src/api.mjs | 67 ----- .../shells/cabinet/public/src/endpoints.mjs | 254 ++++++++++++++++ .../cabinet/public/src/entryActions.mjs | 33 +- .../shells/cabinet/public/src/navigation.mjs | 24 +- .../shells/cabinet/public/src/properties.mjs | 4 +- .../cabinet/public/src/recoveryHistory.mjs | 19 +- .../shells/cabinet/public/src/wiring.mjs | 8 +- .../parts/shells/cabinet/src/cabinets.mjs | 6 +- .../parts/shells/config/public/index.mjs | 2 +- .../parts/shells/debug_info/public/index.mjs | 4 +- .../parts/shells/deskpet/public/index.mjs | 4 +- .../parts/shells/discordbot/public/index.mjs | 2 +- .../parts/shells/home/public/src/data.mjs | 2 +- .../parts/shells/home/public/src/events.mjs | 6 +- .../parts/shells/home/public/src/home.mjs | 4 +- .../parts/shells/home/public/src/ui.mjs | 2 +- .../shells/ideIntegration/public/index.mjs | 4 +- .../shells/install/public/uninstall/index.mjs | 2 +- .../parts/shells/proxy/public/index.mjs | 2 +- .../serviceSourceManage/public/index.mjs | 2 +- .../shells/subfounts/public/subfount.mjs | 4 +- .../helpers/subfount_client_worker.mjs | 4 +- .../parts/shells/telegrambot/public/index.mjs | 2 +- .../parts/shells/themeManage/public/index.mjs | 2 +- .../parts/shells/tutorial/public/index.mjs | 2 +- .../shells/userSettings/public/index.mjs | 2 +- .../parts/shells/wechatbot/public/index.mjs | 2 +- src/scripts/checks/AGENTS.md | 2 +- src/scripts/checks/i18n_refs.mjs | 2 +- src/scripts/errorHandlers.mjs | 31 ++ src/scripts/sentry_state.mjs | 1 + src/scripts/test/hub/apis/health.mjs | 2 +- src/scripts/test/playwright/pages_server.mjs | 4 +- src/server/test/manifest.json | 2 +- src/server/web_server/endpoints.mjs | 2 +- src/server/web_server/p2p_endpoints.mjs | 4 - 92 files changed, 2148 insertions(+), 205 deletions(-) create mode 100755 path/src/cmd/version.ps1 create mode 100755 path/src/cmd/version.sh delete mode 100644 src/public/pages/scripts/api/p2p/evfsMedia.mjs create mode 100644 src/public/pages/scripts/endpoints/base.mjs create mode 100644 src/public/pages/scripts/endpoints/desktop.ini create mode 100644 src/public/pages/scripts/endpoints/p2p/desktop.ini create mode 100644 src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs create mode 100644 src/public/pages/scripts/endpoints/parts.mjs create mode 100644 src/public/pages/scripts/endpoints/registries.mjs create mode 100644 src/public/pages/scripts/endpoints/server_events.mjs create mode 100644 src/public/pages/scripts/features/errorHandlers.mjs delete mode 100644 src/public/parts/shells/cabinet/public/src/api.mjs create mode 100644 src/public/parts/shells/cabinet/public/src/endpoints.mjs create mode 100644 src/scripts/errorHandlers.mjs diff --git a/path/AGENTS.md b/path/AGENTS.md index 3aae912ff..2c169a7de 100644 --- a/path/AGENTS.md +++ b/path/AGENTS.md @@ -21,19 +21,21 @@ Same logic is isomorphic across `foo.{ps1,sh}`; platform-only code under `path/s ## Updates - Sync: `update_fount_and_deno`. Background: `update_fount_and_deno_background` (after `data/installer/deno_upgraded`). `bootstrap_server` uses background; keepalive retries and exit-131 call sync before restart. +- `fount version` prints branch (or detached HEAD), HEAD sha, optional remote tip, and up-to-date / behind / ahead / diverged via one-shot `git_fetch_remote_branch` (same fetch shape as plain update; no widen). Detached HEAD skips remote compare; `.noupdate` is noted when present. - `fount update` is the sync CLI (also how PS Start-Job re-enters). -- Plain update refreshes only the current branch via one-shot `git_fetch_remote_branch` (does **not** widen `remote.origin.fetch`). +- Plain update refreshes only the current branch via one-shot `git_fetch_remote_branch` (does **not** expand `remote.origin.fetch` to `refs/heads/*`). - `fount update ` checks out that branch and removes `.noupdate`; unknown names `ls-remote` once then the same one-shot fetch. - `fount update ` detaches at that commit and creates `.noupdate`. - `fount update pr/` (also `pull/`, `#`, or a `https://github.com///pull/` URL) fetches `refs/pull//head` into `origin/pr/`, detaches there, and creates `.noupdate`. Re-run the same command to refresh the tip. - If the current upstream is confirmed gone on origin (not a network error), fall back to tracking `master`. +- Upstream for one-shot `origin/` refs: `git_track_origin_branch` adds that single head to `remote.origin.fetch` (so `@{u}` works under single-branch clones) and sets `branch..remote` / `merge`. Never uses `git branch --set-upstream-to` alone — that fatals when the refspec is outside configured fetch. - `git_valid_branch_name` gates one-shot fetch/ls-remote. In bash `case` patterns, escape glob chars (`*\?*`, `*\**`) — do **not** quote them as `*'?'*` / `/'*|*'/'`; a dangling `'` makes `bash -n path/src/git.sh` fail and every sourced function vanish (`git_remote_branch_status: command not found`). Keep `@{` out of `case` arms (`[[ "$branch" == *'@{'* ]]`) — shellcheck SC1083 treats `{` in `*@{*` as literal. Regression: `fount test path:git --no-parallel`. - `path/**/*.sh` is linted by ShellCheck in the same suite (`shellcheck.test.mjs`). Resolve via `@steve02081504/exec` `where_command` / `execFile`: PATH first; if missing or older than GitHub `releases/latest`, download into `data/test/shellcheck/v*` (zip / `.tar.gz` via system `tar`, same asset layout as [vscode-shellcheck](https://github.com/vscode-shellcheck/vscode-shellcheck)) and try to overwrite a writable PATH binary. Latest tag is cached in `data/test/shellcheck/latest.json` (24h). Non-ASCII entry names (`⛲.sh`, …) are copied to a temp ASCII path before lint — Windows ShellCheck crashes when printing those filenames. ## Termux -- `env.sh` sets `LANG` from `getprop persist.sys.locale` at CLI start (before i18n). -- `termux_ensure_sensor_api` (`unix/termux.sh`) installs `termux-api` for `termux-sensor` when missing on `fount logo` / `log` / `server`; tracked in `auto_installed_system_packages` for uninstall. Logo gravity details: [imgs/icon_anime/AGENTS.md](../imgs/icon_anime/AGENTS.md). +- `unix/termux.sh`: locale + sensor. `env.sh` on Termux `require unix/termux` then `termux_apply_android_lang` (before i18n) — Android locale chain (`persist.sys.locale` → language/country → `ro.product.locale` → `settings`) via `getprop` / `/system/bin/getprop`, BCP 47 → `zh_CN.UTF-8`, sets `LANG`, unsets `LC_ALL`. +- `termux_ensure_sensor_api` installs `termux-api` for `termux-sensor` when missing on `fount logo` / `log` / `server`; tracked in `auto_installed_system_packages` for uninstall. Logo gravity details: [imgs/icon_anime/AGENTS.md](../imgs/icon_anime/AGENTS.md). ## CI smoke diff --git a/path/src/cmd/version.ps1 b/path/src/cmd/version.ps1 new file mode 100755 index 000000000..66bfaab7e --- /dev/null +++ b/path/src/cmd/version.ps1 @@ -0,0 +1,4 @@ +function script:cmd_version { + require git + fount_show_version +} diff --git a/path/src/cmd/version.sh b/path/src/cmd/version.sh new file mode 100755 index 000000000..cd1541e8a --- /dev/null +++ b/path/src/cmd/version.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +cmd_version() { + require git + fount_show_version +} diff --git a/path/src/env.sh b/path/src/env.sh index 17b4a84af..3c96b3f30 100755 --- a/path/src/env.sh +++ b/path/src/env.sh @@ -26,15 +26,10 @@ in_docker() { [ "$IN_DOCKER" -eq 1 ]; } in_termux() { [ "$IN_TERMUX" -eq 1 ]; } in_container() { in_docker || in_termux; } -# Termux ships a weak default LANG; use the Android system locale for the whole CLI -# (i18n + git/bash messages), not only inside run(). +# Termux: Android system locale → LANG (before i18n). if [ "$IN_TERMUX" -eq 1 ]; then - termux_lang=$(getprop persist.sys.locale 2>/dev/null || true) - if [ -n "$termux_lang" ]; then - LANG="$termux_lang.UTF-8" - export LANG - fi - unset termux_lang + require unix/termux + termux_apply_android_lang fi # Installer data paths (exported for packages.sh / deno.sh / uninstall hooks) @@ -60,4 +55,3 @@ if echo "${LANG:-}" | grep -iqE "_(CN|KP|RU)|(^|-)(zh|ko|ru)(-|$)"; then done ) >/dev/null 2>&1 & fi - diff --git a/path/src/git.ps1 b/path/src/git.ps1 index 7d1613a75..50efce307 100755 --- a/path/src/git.ps1 +++ b/path/src/git.ps1 @@ -129,6 +129,41 @@ function script:git_sync_to_ref($Ref) { invoke_repo_git reset --hard $Ref } +# Ensure remote.origin.fetch maps refs/heads/ → origin/. +# Adds a single-branch refspec only — never expands to refs/heads/*. +function script:git_ensure_origin_fetch_branch($RemoteBranch) { + if (-not (git_valid_branch_name $RemoteBranch)) { + $global:LastExitCode = 1 + return + } + $specs = @(invoke_repo_git config --get-all remote.origin.fetch 2>$null) + if ($LastExitCode -ne 0) { $specs = @() } + foreach ($spec in $specs) { + if ($spec -match '^\+?refs/heads/\*:refs/remotes/origin/\*$') { return } + if ($spec -eq "+refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}") { return } + if ($spec -eq "refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}") { return } + } + invoke_repo_git config --add remote.origin.fetch "+refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}" +} + +# Point local branch at origin/ without requiring a prior wildcard fetch refspec. +# `git branch --set-upstream-to` rejects one-shot remote-tracking refs under single-branch clones; +# add the one head to remote.origin.fetch (not *) then set branch.*.remote / merge. +function script:git_track_origin_branch($Branch, $OriginRef = $null) { + if (-not $OriginRef) { $OriginRef = "origin/$Branch" } + if ($OriginRef -notlike 'origin/*') { + Write-Warning (Get-I18n -key 'git.remoteRefUnavailable' -params @{ ref = $OriginRef }) + $global:LastExitCode = 1 + return + } + $remoteBranch = $OriginRef.Substring('origin/'.Length) + git_ensure_origin_fetch_branch $remoteBranch + if ($LastExitCode -ne 0) { return } + invoke_repo_git config "branch.$Branch.remote" origin + if ($LastExitCode -ne 0) { return } + invoke_repo_git config "branch.$Branch.merge" "refs/heads/$remoteBranch" +} + # Switch/create local branch at StartPoint (default origin/). Does not move other branches. function script:git_checkout_branch($Branch, $StartPoint = $null) { if (-not $StartPoint) { $StartPoint = "origin/$Branch" } @@ -143,7 +178,7 @@ function script:git_checkout_branch($Branch, $StartPoint = $null) { invoke_repo_git checkout -B $Branch $StartPoint if ($LastExitCode -ne 0) { return } if ($StartPoint -like 'origin/*') { - invoke_repo_git branch --set-upstream-to $StartPoint $Branch + git_track_origin_branch $Branch $StartPoint } } @@ -170,7 +205,8 @@ function script:fount_resolve_upstream($Branch) { if (-not $hadUpstream) { Write-Warning (Get-I18n -key 'git.noUpstreamBranch' -params @{ branch = $Branch; remote = $script:remoteBranch }) } - invoke_repo_git branch --set-upstream-to $script:remoteBranch $Branch | Out-Null + git_track_origin_branch $Branch $script:remoteBranch + if ($LastExitCode -ne 0) { return } $script:currentBranch = $Branch $global:LastExitCode = 0 return @@ -305,3 +341,93 @@ function script:fount_upgrade { if ($status) { Write-Warning (Get-I18n -key 'git.dirtyWorkingDirectory') } } } + +# $Kind = version.status.* suffix; $Warn = Write-Warning instead of Write-Host. +function script:fount_print_version_status($Kind, [switch]$Warn) { + $statusText = Get-I18n -key "version.status.$Kind" + $line = Get-I18n -key 'version.status.title' -params @{ status = $statusText } + if ($Warn) { Write-Warning $line } + else { Write-Host $line } +} + +# $Branch = branch name, or HEAD for detached. +function script:fount_print_version_branch($Branch) { + $text = $Branch + if ($text -eq 'HEAD') { + $text = Get-I18n -key 'version.branch.detached' + } + Write-Host (Get-I18n -key 'version.branch.title' -params @{ branch = $text }) +} + +# Print branch, HEAD sha, and whether the current branch tip matches origin. +function script:fount_show_version { + $global:LastExitCode = 0 + if (!(Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning (Get-I18n -key 'version.noGit') + $global:LastExitCode = 1 + return + } + if (!(Test-Path -LiteralPath "$FOUNT_DIR/.git")) { + Write-Warning (Get-I18n -key 'version.noRepo') + $global:LastExitCode = 1 + return + } + + $branch = invoke_repo_git rev-parse --abbrev-ref HEAD 2>$null + if ($LastExitCode -ne 0 -or -not $branch) { $branch = 'HEAD' } + $sha = invoke_repo_git rev-parse HEAD 2>$null + if ($LastExitCode -ne 0 -or -not $sha) { + Write-Warning (Get-I18n -key 'version.noRepo') + $global:LastExitCode = 1 + return + } + + fount_print_version_branch $branch + Write-Host (Get-I18n -key 'version.commit' -params @{ ref = $sha }) + + if (Test-Path -LiteralPath "$FOUNT_DIR/.noupdate") { + Write-Host (Get-I18n -key 'version.autoUpdatePaused') + } + + if ($branch -eq 'HEAD') { + fount_print_version_status detachedNoCompare + $global:LastExitCode = 0 + return + } + + git_fetch_remote_branch $branch + if ($LastExitCode -ne 0) { + fount_print_version_status fetchFailed -Warn + $global:LastExitCode = 1 + return + } + $remoteSha = invoke_repo_git rev-parse "origin/$branch" 2>$null + if ($LastExitCode -ne 0 -or -not $remoteSha) { + fount_print_version_status fetchFailed -Warn + $global:LastExitCode = 1 + return + } + Write-Host (Get-I18n -key 'version.remote' -params @{ ref = $remoteSha }) + + if ($sha -eq $remoteSha) { + fount_print_version_status upToDate + $global:LastExitCode = 0 + return + } + $mergeBase = invoke_repo_git merge-base HEAD "origin/$branch" 2>$null + if ($LastExitCode -ne 0 -or -not $mergeBase) { + fount_print_version_status diverged -Warn + $global:LastExitCode = 0 + return + } + if ($mergeBase -eq $sha) { + fount_print_version_status behind -Warn + } + elseif ($mergeBase -eq $remoteSha) { + fount_print_version_status ahead + } + else { + fount_print_version_status diverged -Warn + } + $global:LastExitCode = 0 +} diff --git a/path/src/git.sh b/path/src/git.sh index 6e5d75ffb..4f9a38a8e 100755 --- a/path/src/git.sh +++ b/path/src/git.sh @@ -113,6 +113,43 @@ git_sync_to_ref() { invoke_repo_git reset --hard "$ref" } +# Ensure remote.origin.fetch maps refs/heads/ → origin/. +# Adds a single-branch refspec only — never expands to refs/heads/*. +git_ensure_origin_fetch_branch() { + local remote_branch="$1" specs + git_valid_branch_name "$remote_branch" || return 1 + specs=$(invoke_repo_git config --get-all remote.origin.fetch 2>/dev/null) || specs= + if printf '%s\n' "$specs" | grep -qE '^(\+)?refs/heads/\*:refs/remotes/origin/\*$'; then + return 0 + fi + if printf '%s\n' "$specs" | grep -qxF "+refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}"; then + return 0 + fi + if printf '%s\n' "$specs" | grep -qxF "refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}"; then + return 0 + fi + invoke_repo_git config --add remote.origin.fetch "+refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}" +} + +# Point local branch at origin/ without requiring a prior wildcard fetch refspec. +# `git branch --set-upstream-to` rejects one-shot remote-tracking refs under single-branch clones; +# add the one head to remote.origin.fetch (not *) then set branch.*.remote / merge. +git_track_origin_branch() { + local branch="$1" + local origin_ref="${2:-origin/$branch}" + local remote_branch + case "$origin_ref" in + origin/*) remote_branch="${origin_ref#origin/}" ;; + *) + print_i18n_yellow 'git.remoteRefUnavailable' 'ref' "$origin_ref" >&2 + return 1 + ;; + esac + git_ensure_origin_fetch_branch "$remote_branch" || return 1 + invoke_repo_git config "branch.${branch}.remote" origin || return 1 + invoke_repo_git config "branch.${branch}.merge" "refs/heads/${remote_branch}" +} + # Switch/create local branch at start_point (default origin/). Does not move other branches. git_checkout_branch() { local branch="$1" @@ -125,7 +162,7 @@ git_checkout_branch() { invoke_repo_git clean -fd || return 1 invoke_repo_git checkout -B "$branch" "$start_point" || return 1 case "$start_point" in - origin/*) invoke_repo_git branch --set-upstream-to "$start_point" "$branch" >/dev/null ;; + origin/*) git_track_origin_branch "$branch" "$start_point" || return 1 ;; esac } @@ -169,3 +206,80 @@ git_reset_and_clean() { fi } +# $1 = version.status.* suffix; $2 = green|yellow| (default plain stdout). +fount_print_version_status() { + local text color="${2:-}" + text=$(get_i18n "version.status.$1") + case "$color" in + green) print_i18n_green 'version.status.title' 'status' "$text" ;; + yellow) print_i18n_yellow 'version.status.title' 'status' "$text" >&2 ;; + *) get_i18n 'version.status.title' 'status' "$text" ;; + esac +} + +# $1 = branch name, or HEAD for detached. +fount_print_version_branch() { + local text="$1" + if [ "$text" = "HEAD" ]; then + text=$(get_i18n 'version.branch.detached') + fi + get_i18n 'version.branch.title' 'branch' "$text" +} + +# Print branch, HEAD sha, and whether the current branch tip matches origin. +fount_show_version() { + local branch sha remote_sha merge_base + if ! command -v git &>/dev/null; then + print_i18n_yellow 'version.noGit' >&2 + return 1 + fi + if [ ! -d "$FOUNT_DIR/.git" ]; then + print_i18n_yellow 'version.noRepo' >&2 + return 1 + fi + + branch=$(invoke_repo_git rev-parse --abbrev-ref HEAD 2>/dev/null) || branch=HEAD + sha=$(invoke_repo_git rev-parse HEAD 2>/dev/null) || { + print_i18n_yellow 'version.noRepo' >&2 + return 1 + } + + fount_print_version_branch "$branch" + get_i18n 'version.commit' 'ref' "$sha" + + if [ -f "$FOUNT_DIR/.noupdate" ]; then + get_i18n 'version.autoUpdatePaused' + fi + + if [ "$branch" = "HEAD" ]; then + fount_print_version_status detachedNoCompare + return 0 + fi + + if ! git_fetch_remote_branch "$branch"; then + fount_print_version_status fetchFailed yellow + return 1 + fi + remote_sha=$(invoke_repo_git rev-parse "origin/$branch" 2>/dev/null) || { + fount_print_version_status fetchFailed yellow + return 1 + } + get_i18n 'version.remote' 'ref' "$remote_sha" + + if [ "$sha" = "$remote_sha" ]; then + fount_print_version_status upToDate green + return 0 + fi + merge_base=$(invoke_repo_git merge-base HEAD "origin/$branch" 2>/dev/null) || { + fount_print_version_status diverged yellow + return 0 + } + if [ "$merge_base" = "$sha" ]; then + fount_print_version_status behind yellow + elif [ "$merge_base" = "$remote_sha" ]; then + fount_print_version_status ahead + else + fount_print_version_status diverged yellow + fi +} + diff --git a/path/src/i18n.ps1 b/path/src/i18n.ps1 index b35832348..1d27df8e5 100755 --- a/path/src/i18n.ps1 +++ b/path/src/i18n.ps1 @@ -71,6 +71,7 @@ $Script:I18nParamAnsiColors = @{ path = 36 ref = 34 branch = 33 + status = 33 target = 34 } diff --git a/path/src/i18n.sh b/path/src/i18n.sh index 0face303a..0e3263451 100755 --- a/path/src/i18n.sh +++ b/path/src/i18n.sh @@ -2,17 +2,25 @@ # --- Internationalization --- get_system_locales() { - local locales=() - if [ -n "$LANG" ]; then locales+=("$(echo "$LANG" | cut -d. -f1 | sed 's/_/-/')"); fi + local locales=() entry + if [ -n "$LANG" ]; then + entry=$(echo "$LANG" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") + fi if [ -n "$LANGUAGE" ]; then IFS=':' read -r -a lang_array <<<"$LANGUAGE" for lang in "${lang_array[@]}"; do - locales+=("$(echo "$lang" | cut -d. -f1 | sed 's/_/-/')") + entry=$(echo "$lang" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") done fi - if [ -n "$LC_ALL" ]; then locales+=("$(echo "$LC_ALL" | cut -d. -f1 | sed 's/_/-/')"); fi + if [ -n "$LC_ALL" ]; then + entry=$(echo "$LC_ALL" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") + fi if command -v locale >/dev/null; then - locales+=("$(locale -uU 2>/dev/null | cut -d. -f1 | sed 's/_/-/')") + entry=$(locale -uU 2>/dev/null | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") fi locales+=("en-UK") # shellcheck disable=SC2207 @@ -38,6 +46,7 @@ get_best_locale() { local available_locales=($available_locales_str) for preferred in "${preferred_locales[@]}"; do + [ -n "$preferred" ] || continue for available in "${available_locales[@]}"; do if [ "$preferred" = "$available" ]; then echo "$preferred" @@ -47,8 +56,10 @@ get_best_locale() { done for preferred in "${preferred_locales[@]}"; do + [ -n "$preferred" ] || continue local prefix prefix=$(echo "$preferred" | cut -d- -f1) + [ -n "$prefix" ] || continue for available in "${available_locales[@]}"; do if [[ "$available" == "$prefix"* ]]; then echo "$available" @@ -99,6 +110,7 @@ i18n_format_param_value() { path) printf '\033[36m%s\033[0m' "$param_value" ; return ;; ref) printf '\033[34m%s\033[0m' "$param_value" ; return ;; branch) printf '\033[33m%s\033[0m' "$param_value" ; return ;; + status) printf '\033[33m%s\033[0m' "$param_value" ; return ;; target) printf '\033[34m%s\033[0m' "$param_value" ; return ;; esac fi diff --git a/path/src/unix/termux.sh b/path/src/unix/termux.sh index c944a556a..655bb0155 100755 --- a/path/src/unix/termux.sh +++ b/path/src/unix/termux.sh @@ -1,5 +1,93 @@ #!/usr/bin/env bash -# Termux-specific helpers (sensor API for logo/log/server) +# Termux-specific helpers (locale + sensor API) + +# Resolve getprop even when /system/bin is absent from PATH (some Termux setups). +android_getprop() { + local key="$1" path + path=$(command -v getprop 2>/dev/null) || path= + if [ -n "$path" ]; then + "$path" "$key" 2>/dev/null + return 0 + fi + for path in /system/bin/getprop /system/xbin/getprop; do + if [ -x "$path" ]; then + "$path" "$key" 2>/dev/null + return 0 + fi + done + return 1 +} + +# Android system locale tag (BCP 47), first of a comma-separated list. Echoes nothing on failure. +android_system_locale_tag() { + local tag lang country variant + tag=$(android_getprop persist.sys.locale || true) + if [ -z "$tag" ]; then + lang=$(android_getprop persist.sys.language || true) + if [ -n "$lang" ]; then + country=$(android_getprop persist.sys.country || true) + variant=$(android_getprop persist.sys.localevar || true) + tag="$lang" + [ -n "$country" ] && tag="$tag-$country" + [ -n "$variant" ] && tag="$tag-$variant" + fi + fi + if [ -z "$tag" ]; then + tag=$(android_getprop ro.product.locale || true) + fi + if [ -z "$tag" ]; then + lang=$(android_getprop ro.product.locale.language || true) + country=$(android_getprop ro.product.locale.region || true) + if [ -n "$lang" ]; then + tag="$lang" + [ -n "$country" ] && tag="$tag-$country" + fi + fi + if [ -z "$tag" ] && command -v settings >/dev/null 2>&1; then + tag=$(settings get system system_locales 2>/dev/null || true) + [ "$tag" = "null" ] && tag= + fi + tag="${tag%%,*}" + tag=$(printf '%s' "$tag" | tr -d '[:space:]') + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + +# BCP 47 → POSIX LANG (zh-Hans-CN → zh_CN.UTF-8; en → en.UTF-8). +android_locale_to_lang() { + local tag="${1//_/-}" language="" region="" part + local IFS='-' + # shellcheck disable=SC2086 # intentional IFS split on - + set -- $tag + language="${1:-}" + [ -n "$language" ] || return 1 + shift || true + for part in "$@"; do + case "$part" in + [A-Za-z][A-Za-z][A-Za-z][A-Za-z]) ;; # script (Hans/Hant/Latn) — skip + [A-Za-z][A-Za-z] | [0-9][0-9][0-9]) region="$part" ;; + esac + done + if [ -n "$region" ]; then + printf '%s_%s.UTF-8\n' "$language" "$region" + else + printf '%s.UTF-8\n' "$language" + fi +} + +# Termux default LANG is weak; apply Android system locale for CLI i18n / gettext. +termux_apply_android_lang() { + local tag lang + [[ ${IN_TERMUX:-0} -eq 1 ]] || return 0 + tag=$(android_system_locale_tag || true) + [ -n "$tag" ] || return 0 + lang=$(android_locale_to_lang "$tag") || return 0 + [ -n "$lang" ] || return 0 + LANG="$lang" + export LANG + # Drop LC_ALL so LANG wins for gettext; fount i18n reads LANG first either way. + unset LC_ALL +} # Ensure termux-sensor CLI (pkg termux-api); tracked for uninstall. Soft-fail if missing. termux_ensure_sensor_api() { diff --git a/path/src/update.sh b/path/src/update.sh index 371ec8fc8..f11b3fcbb 100755 --- a/path/src/update.sh +++ b/path/src/update.sh @@ -11,7 +11,7 @@ fount_resolve_upstream() { if [ -z "$had_upstream" ]; then print_i18n_yellow 'git.noUpstreamBranch' 'branch' "$branch" 'remote' "$remoteBranch" >&2 fi - invoke_repo_git branch --set-upstream-to "$remoteBranch" "$branch" >/dev/null + git_track_origin_branch "$branch" "$remoteBranch" || return 1 currentBranch="$branch" return 0 fi diff --git a/path/test/git.test.mjs b/path/test/git.test.mjs index cb78b857b..f4cbf46e2 100644 --- a/path/test/git.test.mjs +++ b/path/test/git.test.mjs @@ -10,6 +10,7 @@ import { REPO_ROOT } from '../../src/scripts/test/core/repo_root.mjs' const gitShPath = join(REPO_ROOT, 'path', 'src', 'git.sh') const gitPs1Path = join(REPO_ROOT, 'path', 'src', 'git.ps1') +const termuxShPath = join(REPO_ROOT, 'path', 'src', 'unix', 'termux.sh') /** 应被拒绝的分支名(Git ref 规则 + apostrophe)。 */ const INVALID_BRANCH_NAMES = [ @@ -336,3 +337,165 @@ foreach ($base64Name in $encoded) { assertEquals(bashVerdicts.length, allTargets.length) assertEquals(psVerdicts, bashVerdicts) }) + +/** + * 单分支 clone + one-shot fetch 场景:`branch --set-upstream-to` 会 fatal; + * git_checkout_branch 必须补上该 head 的 fetch refspec(非 *)并建好 @{u}。 + */ +Deno.test('git_checkout_branch tracks one-shot origin ref under single-branch fetch', async () => { + const result = await runBash(` + set -euo pipefail + TMP=$(mktemp -d) + cleanup() { rm -rf "$TMP"; } + trap cleanup EXIT + + git init --bare -b master "$TMP/remote.git" >/dev/null + git clone "$TMP/remote.git" "$TMP/seed" >/dev/null 2>&1 + cd "$TMP/seed" + git config user.email t@t + git config user.name t + echo master > f.txt + git add f.txt && git commit -m master >/dev/null + git push origin master >/dev/null + git checkout -b lava >/dev/null 2>&1 + echo lava > f.txt + git add f.txt && git commit -m lava >/dev/null + git push origin lava >/dev/null + + git clone --single-branch --branch master "$TMP/remote.git" "$TMP/clone" >/dev/null 2>&1 + cd "$TMP/clone" + git config user.email t@t + git config user.name t + + FOUNT_DIR="$TMP/clone" + print_i18n_yellow() { :; } + print_i18n_green() { :; } + . ${JSON.stringify(gitShPath)} + + git_fetch_remote_branch lava + # Reproduce: raw --set-upstream-to must fail on this clone shape. + if git branch --set-upstream-to origin/lava lava >/dev/null 2>&1; then + echo 'expected set-upstream-to to fail' >&2 + exit 1 + fi + + git_checkout_branch lava origin/lava + upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}') + [ "$upstream" = 'origin/lava' ] || { echo "upstream=$upstream" >&2; exit 1; } + fetch_specs=$(git config --get-all remote.origin.fetch) + printf '%s\n' "$fetch_specs" | grep -qE '^(\\+)?refs/heads/\\*:refs/remotes/origin/\\*$' && { + echo "fetch widened to all heads:" >&2 + printf '%s\n' "$fetch_specs" >&2 + exit 1 + } + printf '%s\n' "$fetch_specs" | grep -qxF '+refs/heads/lava:refs/remotes/origin/lava' || { + echo "lava refspec missing:" >&2 + printf '%s\n' "$fetch_specs" >&2 + exit 1 + } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +/** + * fount_show_version:本地 tip / 落后 / 分离 HEAD 状态键。 + */ +Deno.test('fount_show_version reports branch sha and freshness', async () => { + const result = await runBash(` + set -euo pipefail + TMP=$(mktemp -d) + cleanup() { rm -rf "$TMP"; } + trap cleanup EXIT + + git init --bare -b master "$TMP/remote.git" >/dev/null + git clone "$TMP/remote.git" "$TMP/seed" >/dev/null 2>&1 + cd "$TMP/seed" + git config user.email t@t + git config user.name t + echo v1 > f.txt + git add f.txt && git commit -m v1 >/dev/null + git push origin master >/dev/null + + git clone "$TMP/remote.git" "$TMP/clone" >/dev/null 2>&1 + cd "$TMP/clone" + git config user.email t@t + git config user.name t + + FOUNT_DIR="$TMP/clone" + get_i18n() { printf '%s' "$1"; shift; while [ $# -gt 0 ]; do printf ' %s=%s' "$1" "$2"; shift 2; done; printf '\\n'; } + print_i18n_green() { get_i18n "$@"; } + print_i18n_yellow() { get_i18n "$@" >&2; } + . ${JSON.stringify(gitShPath)} + + sha=$(git rev-parse HEAD) + out=$(fount_show_version) + printf '%s\\n' "$out" | grep -qxF "version.branch.title branch=master" || { echo "branch line:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.commit ref=$sha" || { echo "commit line:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.upToDate" || { echo "expected upToDate:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + + cd "$TMP/seed" + echo v2 > f.txt + git add f.txt && git commit -m v2 >/dev/null + git push origin master >/dev/null + + cd "$TMP/clone" + out=$(fount_show_version 2>&1) + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.behind" || { echo "expected behind:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + + git checkout --detach HEAD >/dev/null 2>&1 + : >"$FOUNT_DIR/.noupdate" + out=$(fount_show_version) + printf '%s\\n' "$out" | grep -qxF "version.branch.title branch=version.branch.detached" || { echo "expected detached:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.autoUpdatePaused" || { echo "expected autoUpdatePaused:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.detachedNoCompare" || { echo "expected detachedNoCompare:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +/** BCP 47 → POSIX LANG fixtures (Android/Termux). */ +const ANDROID_LOCALE_TO_LANG = [ + ['zh-Hans-CN', 'zh_CN.UTF-8'], + ['zh-CN', 'zh_CN.UTF-8'], + ['zh_CN', 'zh_CN.UTF-8'], + ['en-US', 'en_US.UTF-8'], + ['ja-JP', 'ja_JP.UTF-8'], + ['en', 'en.UTF-8'], +] + +Deno.test('android_locale_to_lang normalizes BCP47 script tags for Termux LANG', async () => { + const result = await runBash(` + set -e + . ${JSON.stringify(termuxShPath)} + ${ANDROID_LOCALE_TO_LANG.map(([tag, want]) => ` + got=$(android_locale_to_lang ${JSON.stringify(tag)}) + [ "$got" = ${JSON.stringify(want)} ] || { echo "mismatch:${tag}:$got" >&2; exit 1; } + `).join('')} + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +Deno.test('android_locale_to_lang feeds get_system_locales toward zh-CN', async () => { + const i18nShPath = join(REPO_ROOT, 'path', 'src', 'i18n.sh') + const result = await runBash(` + set -e + FOUNT_DIR=${JSON.stringify(REPO_ROOT)} + . ${JSON.stringify(termuxShPath)} + . ${JSON.stringify(i18nShPath)} + LANG=$(android_locale_to_lang 'zh-Hans-CN') + export LANG + unset LC_ALL + system_locales=$(get_system_locales) + available_locales=$(get_available_locales) + best=$(get_best_locale "$system_locales" "$available_locales") + [ "$best" = 'zh-CN' ] || { echo "best=$best system=$system_locales" >&2; exit 1; } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index 6816656c7..57085641e 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -346,6 +346,26 @@ export type LocaleData = { createdNoUpdate: string unknownTarget: string } + version: { + branch: { + title: string + detached: string + } + commit: string + remote: string + status: { + title: string + upToDate: string + behind: string + ahead: string + diverged: string + detachedNoCompare: string + fetchFailed: string + } + autoUpdatePaused: string + noRepo: string + noGit: string + } shortcut: { desktopShortcutCreated: string startMenuShortcutCreated: string @@ -1916,6 +1936,7 @@ export type LocaleData = { 'aria-label': string } operationFailed: string + warmCharCacheFailed: string shareGroupFailed: string replyInline: { title: string @@ -2132,6 +2153,7 @@ export type LocaleData = { } federation: { loadFailed: string + rebindFailed: string subtitle: string title: string tooltip: { @@ -4013,6 +4035,8 @@ export type LocaleData = { title: string description: string bootstrapFailed: string + connectNodeFailed: string + dwellFailed: string home_function_buttons: { main: { title: string @@ -5906,6 +5930,7 @@ export type LocaleKeyParams = { 'chat.hub.fed.repairJoinSnapshotFailed': { error: string | number } 'chat.hub.fed.repairJoinSnapshotOk': { channels: string | number } 'chat.hub.federation.loadFailed': { error: string | number } + 'chat.hub.federation.rebindFailed': { error: string | number } 'chat.hub.files.loadFailed': { error: string | number } 'chat.hub.files.renameFolderPrompt': { name: string | number } 'chat.hub.folder.renamePrompt': { name: string | number } @@ -5976,6 +6001,7 @@ export type LocaleKeyParams = { 'chat.hub.vote.createFailed': { error: string | number } 'chat.hub.vote.deadline': { date: string | number } 'chat.hub.vote.total': { total: string | number } + 'chat.hub.warmCharCacheFailed': { error: string | number } 'chat.message.view.logprobsMetricsFooter': { speed: string | number; time: string | number; tokens: string | number; ttft: string | number } 'chat.message.view.logprobsTopLogprobsMeta': { token: string | number } 'chat.message.view.share.success': { provider: string | number; sponsorLink: string | number } @@ -6048,6 +6074,10 @@ export type LocaleKeyParams = { 'fountConsole.path.update.pinningToPullRequest': { pr: string | number } 'fountConsole.path.update.switchingToBranch': { branch: string | number } 'fountConsole.path.update.unknownTarget': { target: string | number } + 'fountConsole.path.version.branch.title': { branch: string | number } + 'fountConsole.path.version.commit': { ref: string | number } + 'fountConsole.path.version.remote': { ref: string | number } + 'fountConsole.path.version.status.title': { status: string | number } 'fountConsole.route.setLanguagePreference': { preferredLanguages: string | number; username: string | number } 'fountConsole.server.localUrl': { url: string | number } 'fountConsole.server.mdns.bonjourFailed': { error: string | number } @@ -6190,9 +6220,11 @@ export type LocaleKeyParams = { 'social.actions.repostFailed': { error: string | number } 'social.actions.saveFailed': { error: string | number } 'social.bootstrapFailed': { error: string | number } + 'social.connectNodeFailed': { error: string | number } 'social.drafts.deleteFailed': { error: string | number } 'social.drafts.loadFailed': { error: string | number } 'social.drafts.saveFailed': { error: string | number } + 'social.dwellFailed': { error: string | number } 'social.feed.repostedBy': { author: string | number } 'social.feed.trending.postCount.textContent': { n: string | number } 'social.feed.trending.postCount.title': { n: string | number } diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 9e6eab2f2..816c00e50 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -363,6 +363,26 @@ "createdNoUpdate": "تم إنشاء `.noupdate`؛ عُلِّقت التحديثات التلقائية.", "unknownTarget": "تعذّر التعرف على «${target}»: ليس فرعًا معروفًا، ولا التزامًا، ولا طلب سحب." }, + "version": { + "branch": { + "title": "الفرع: ${branch}", + "detached": "HEAD منفصل" + }, + "commit": "الالتزام: `${ref}`", + "remote": "البعيد: `${ref}`", + "status": { + "title": "الحالة: ${status}", + "upToDate": "محدّث", + "behind": "متأخر عن البعيد", + "ahead": "متقدّم على البعيد", + "diverged": "متباعد عن البعيد", + "detachedNoCompare": "HEAD منفصل، لا يمكن المقارنة مع البعيد", + "fetchFailed": "تعذّر جلب حالة البعيد" + }, + "autoUpdatePaused": "تم إيقاف التحديثات التلقائية (يوجد `.noupdate`)", + "noRepo": "لم يتم العثور على مستودع Git", + "noGit": "لم يتم تثبيت Git" + }, "shortcut": { "desktopShortcutCreated": "تم إنشاء اختصار سطح المكتب في `${path}`", "startMenuShortcutCreated": "تم إنشاء اختصار قائمة ابدأ في `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "المجموعات والتنقل" }, "operationFailed": "فشلت العملية: ${error}", + "warmCharCacheFailed": "فشل تسخين ذاكرة التخزين المؤقت للشخصيات: ${error}", "shareGroupFailed": "فشلت مشاركة رابط المجموعة: ${error}", "replyInline": { "title": "رد" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "فشل التحميل: ${error}", + "rebindFailed": "فشلت إعادة ربط غرفة الاتحاد: ${error}", "subtitle": "التحكم في الاتصالات عبر الأجهزة؛ ترك الإعدادات الافتراضية في معظم الحالات", "title": "إعدادات الاتحاد P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "الساحة", "description": "منشورات P2P مفتوحة ومتابعة ومشاركة ملفات", "bootstrapFailed": "فشل تهيئة واجهة التواصل: ${error}", + "connectNodeFailed": "فشل الاتصال بالعقدة: ${error}", + "dwellFailed": "فشل تحديث بيانات التفضيلات حسب مدة المشاهدة: ${error}", "home_function_buttons": { "main": { "title": "الساحة" diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index cee1c46c5..55dc6c01f 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` erstellt; automatische Updates pausiert.", "unknownTarget": "'${target}' kann nicht erkannt werden: kein bekannter Branch, Commit oder Pull Request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Abgetrennter HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Auf dem neuesten Stand", + "behind": "Hinter Remote zurück", + "ahead": "Remote voraus", + "diverged": "Von Remote abgewichen", + "detachedNoCompare": "Abgetrennter HEAD, kein Vergleich mit Remote", + "fetchFailed": "Remote-Status konnte nicht abgerufen werden" + }, + "autoUpdatePaused": "Automatische Updates pausiert (`.noupdate` vorhanden)", + "noRepo": "Git-Repository nicht gefunden", + "noGit": "Git ist nicht installiert" + }, "shortcut": { "desktopShortcutCreated": "Desktop-Verknüpfung erstellt unter `${path}`", "startMenuShortcutCreated": "Startmenü-Verknüpfung erstellt unter `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Gruppen und Navigation" }, "operationFailed": "Vorgang fehlgeschlagen: ${error}", + "warmCharCacheFailed": "Charakter-Cache konnte nicht vorgewärmt werden: ${error}", "shareGroupFailed": "Gruppenlink konnte nicht geteilt werden: ${error}", "replyInline": { "title": "Antwort" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Laden fehlgeschlagen: ${error}", + "rebindFailed": "Föderationsraum konnte nicht neu gebunden werden: ${error}", "subtitle": "Steuern Sie geräteübergreifende Verbindungen. Behalten Sie in den meisten Fällen die Standardeinstellungen bei", "title": "Föderierte P2P-Einstellungen", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plaza", "description": "P2P-Social: Feeds, Follows und Dateifreigabe", "bootstrapFailed": "Social-Oberfläche konnte nicht initialisiert werden: ${error}", + "connectNodeFailed": "Verbindung zum Knoten fehlgeschlagen: ${error}", + "dwellFailed": "Vorlieben konnten anhand der Wiedergabezeit nicht aktualisiert werden: ${error}", "home_function_buttons": { "main": { "title": "Plaza" diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 401678e42..19cc7d294 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -363,6 +363,26 @@ "createdNoUpdate": "✨ `.noupdate` ⏸️⏫", "unknownTarget": "❓ '${target}' 🚫🌿🚫📌🚫🔀" }, + "version": { + "branch": { + "title": "🌳 ${branch}", + "detached": "HEAD 🔓" + }, + "commit": "📌 `${ref}`", + "remote": "☁️ `${ref}`", + "status": { + "title": "📊 ${status}", + "upToDate": "👍💯", + "behind": "☁️⬆️🏠", + "ahead": "🏠⬆️☁️", + "diverged": "🔀", + "detachedNoCompare": "HEAD 🔓 ⏭️☁️", + "fetchFailed": "❌☁️📥" + }, + "autoUpdatePaused": "⏸️⏫ (`.noupdate`)", + "noRepo": "❓ 🐙🐱", + "noGit": "❌ 🐙🐱" + }, "shortcut": { "desktopShortcutCreated": "🖥️✨ `${path}`", "startMenuShortcutCreated": "🏁✨ `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "👥🧭" }, "operationFailed": "⚙️❌:${error}", + "warmCharCacheFailed": "🎭🗄️🔥❌:${error}", "shareGroupFailed": "📤👥🔗❌:${error}", "replyInline": { "title": "💬↩️" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "📥❌:${error}", + "rebindFailed": "🔄🌐🏠❌:${error}", "subtitle": "🖥️🔗🎛️ · 👌🔧⭐", "title": "🌐🔗⚙️", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "🏛️", "description": "🔗🌍 🏛️📰 ➕ 👀 ➕ 📁🔗", "bootstrapFailed": "🏛️🚀❌: ${error}", + "connectNodeFailed": "🔗🌐❌:${error}", + "dwellFailed": "👀⏱️⭐❤️❌:${error}", "home_function_buttons": { "main": { "title": "🏛️" diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index 70c910c60..ba9cb0777 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -358,6 +358,26 @@ "createdNoUpdate": "Created `.noupdate`; automatic updates paused.", "unknownTarget": "Unrecognised target '${target}': not a known branch, commit, or pull request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Detached HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Up to date", + "behind": "Behind remote", + "ahead": "Ahead of remote", + "diverged": "Diverged from remote", + "detachedNoCompare": "Detached HEAD; not comparing with remote", + "fetchFailed": "Unable to fetch remote status" + }, + "autoUpdatePaused": "Automatic updates paused (`.noupdate` exists)", + "noRepo": "Git repository not found", + "noGit": "Git is not installed" + }, "shortcut": { "desktopShortcutCreated": "Desktop shortcut created at `${path}`", "startMenuShortcutCreated": "Start menu shortcut created at `${path}`", @@ -1943,6 +1963,7 @@ "aria-label": "Groups and navigation" }, "operationFailed": "Operation failed: ${error}", + "warmCharCacheFailed": "Couldn't warm up the character cache: ${error}", "shareGroupFailed": "Failed to share group link: ${error}", "replyInline": { "title": "Reply" @@ -2155,6 +2176,7 @@ }, "federation": { "loadFailed": "Couldn't load: ${error}", + "rebindFailed": "Couldn't rebind the federation room: ${error}", "subtitle": "Control cross-device connections — defaults are fine for most people", "title": "Federated P2P settings", "tooltip": { @@ -4036,6 +4058,8 @@ "title": "Square", "description": "P2P open social posts, following and file sharing", "bootstrapFailed": "Social interface initialization failed: ${error}", + "connectNodeFailed": "Couldn't connect to node: ${error}", + "dwellFailed": "Couldn't update taste data from watch time: ${error}", "home_function_buttons": { "main": { "title": "Square" diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 236b1c13c..42e1aff0c 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Se creó `.noupdate`; actualizaciones automáticas suspendidas.", "unknownTarget": "No se puede identificar «${target}»: no es una rama conocida, un commit ni una pull request." }, + "version": { + "branch": { + "title": "Rama: ${branch}", + "detached": "HEAD separado" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Estado: ${status}", + "upToDate": "Actualizado", + "behind": "Por detrás del remoto", + "ahead": "Por delante del remoto", + "diverged": "Divergido del remoto", + "detachedNoCompare": "HEAD separado, sin comparación con el remoto", + "fetchFailed": "No se pudo obtener el estado del remoto" + }, + "autoUpdatePaused": "Actualizaciones automáticas en pausa (existe `.noupdate`)", + "noRepo": "Repositorio Git no encontrado", + "noGit": "Git no está instalado" + }, "shortcut": { "desktopShortcutCreated": "Acceso directo de escritorio creado en `${path}`", "startMenuShortcutCreated": "Acceso directo al menú Inicio creado en `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Grupos y navegación" }, "operationFailed": "Operación fallida: ${error}", + "warmCharCacheFailed": "No se pudo precalentar la caché de personajes: ${error}", "shareGroupFailed": "No se pudo compartir el enlace del grupo: ${error}", "replyInline": { "title": "responder" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Error al cargar: ${error}", + "rebindFailed": "No se pudo volver a vincular la sala de federación: ${error}", "subtitle": "Controlar conexiones entre dispositivos; Deje los valores predeterminados en la mayoría de los casos.", "title": "Configuración de P2P federado", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plaza", "description": "Social P2P abierto: publicaciones, seguimientos y archivos compartidos", "bootstrapFailed": "La inicialización de la interfaz social falló: ${error}", + "connectNodeFailed": "No se pudo conectar al nodo: ${error}", + "dwellFailed": "No se pudieron actualizar las preferencias según el tiempo de visualización: ${error}", "home_function_buttons": { "main": { "title": "Plaza" diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index d8b6cf151..12f73fff9 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` créé ; mises à jour automatiques suspendues.", "unknownTarget": "Impossible d'identifier « ${target} » : ni branche connue, ni commit, ni pull request." }, + "version": { + "branch": { + "title": "Branche : ${branch}", + "detached": "HEAD détaché" + }, + "commit": "Commit : `${ref}`", + "remote": "Distant : `${ref}`", + "status": { + "title": "État : ${status}", + "upToDate": "À jour", + "behind": "En retard sur le distant", + "ahead": "En avance sur le distant", + "diverged": "Divergé du distant", + "detachedNoCompare": "HEAD détaché, pas de comparaison avec le distant", + "fetchFailed": "Impossible de récupérer l'état du distant" + }, + "autoUpdatePaused": "Mises à jour automatiques suspendues (`.noupdate` présent)", + "noRepo": "Dépôt Git introuvable", + "noGit": "Git n'est pas installé" + }, "shortcut": { "desktopShortcutCreated": "Raccourci sur le bureau créé à `${path}`", "startMenuShortcutCreated": "Raccourci du menu Démarrer créé à `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Groupes et navigation" }, "operationFailed": "Échec de l'opération : ${error}", + "warmCharCacheFailed": "Échec du préchargement du cache des personnages : ${error}", "shareGroupFailed": "Échec du partage du lien du groupe : ${error}", "replyInline": { "title": "Répondre" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Échec du chargement : ${error}", + "rebindFailed": "Échec de la reconnexion de la salle de fédération : ${error}", "subtitle": "Contrôler les connexions entre appareils ; laissez les valeurs par défaut dans la plupart des cas", "title": "Paramètres P2P fédérés", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Social", "description": "Fil social P2P ouvert, abonnements et partage de fichiers", "bootstrapFailed": "Échec d'initialisation de l'interface sociale : ${error}", + "connectNodeFailed": "Échec de la connexion au nœud : ${error}", + "dwellFailed": "Échec de la mise à jour des préférences selon le temps de visionnage : ${error}", "home_function_buttons": { "main": { "title": "Social" diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index ee11aaa0a..b1e116937 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` बनाया गया; स्वचालित अपडेट रोक दिए गए।", "unknownTarget": "'${target}' को पहचाना नहीं जा सका: यह कोई ज्ञात शाखा, कमिट या पुल अनुरोध नहीं है।" }, + "version": { + "branch": { + "title": "शाखा: ${branch}", + "detached": "अलग HEAD" + }, + "commit": "कमिट: `${ref}`", + "remote": "रिमोट: `${ref}`", + "status": { + "title": "स्थिति: ${status}", + "upToDate": "नवीनतम", + "behind": "रिमोट से पीछे", + "ahead": "रिमोट से आगे", + "diverged": "रिमोट से विचलित", + "detachedNoCompare": "अलग HEAD, रिमोट से तुलना नहीं", + "fetchFailed": "रिमोट स्थिति प्राप्त नहीं कर सके" + }, + "autoUpdatePaused": "स्वचालित अपडेट रोक दिए गए (`.noupdate` मौजूद है)", + "noRepo": "Git रिपॉजिटरी नहीं मिली", + "noGit": "Git स्थापित नहीं है" + }, "shortcut": { "desktopShortcutCreated": "`${path}` पर डेस्कटॉप शॉर्टकट बनाया गया", "startMenuShortcutCreated": "`${path}` पर प्रारंभ मेनू शॉर्टकट बनाया गया", @@ -1948,6 +1968,7 @@ "aria-label": "समूह और नेविगेशन" }, "operationFailed": "ऑपरेशन विफल: ${error}", + "warmCharCacheFailed": "कैरेक्टर कैश वार्म-अप विफल: ${error}", "shareGroupFailed": "समूह लिंक साझा करने में विफल: ${error}", "replyInline": { "title": "उत्तर" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "लोडिंग विफल: ${error}", + "rebindFailed": "फ़ेडरेशन रूम रीबाइंड करने में विफल: ${error}", "subtitle": "क्रॉस-डिवाइस कनेक्शन नियंत्रित करें; अधिकांश मामलों में डिफ़ॉल्ट को छोड़ दें", "title": "फ़ेडरेटेड P2P सेटिंग्स", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "फ़ीड", "description": "P2P सोशल पोस्ट, फ़ॉलो और फ़ाइल शेयरिंग", "bootstrapFailed": "सामाजिक इंटरफ़ेस आरंभीकरण विफल: ${error}", + "connectNodeFailed": "नोड से कनेक्ट करने में विफल: ${error}", + "dwellFailed": "देखने के समय के आधार पर प्राथमिकता डेटा अपडेट विफल: ${error}", "home_function_buttons": { "main": { "title": "फ़ीड" diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index 1b732ea40..650ff227f 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` búið til; sjálfvirkar uppfærslur í bið.", "unknownTarget": "Get ekki borið kennsl á '${target}': ekki þekkt grein, commit né pull request." }, + "version": { + "branch": { + "title": "Grein: ${branch}", + "detached": "Aðskilið HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Fjarlægt: `${ref}`", + "status": { + "title": "Staða: ${status}", + "upToDate": "Uppfært", + "behind": "Á eftir fjarlægu", + "ahead": "Á undan fjarlægu", + "diverged": "Greinist frá fjarlægu", + "detachedNoCompare": "Aðskilið HEAD, ekki borið saman við fjarlægt", + "fetchFailed": "Gat ekki sótt fjarlægt ástand" + }, + "autoUpdatePaused": "Sjálfvirkar uppfærslur í bið (`.noupdate` er til)", + "noRepo": "Git geymsla fannst ekki", + "noGit": "Git er ekki uppsett" + }, "shortcut": { "desktopShortcutCreated": "Flýtileið á skjáborði búin til á `${path}`", "startMenuShortcutCreated": "Flýtileið fyrir upphafsvalmynd búin til á `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Hópar og leiðsögn" }, "operationFailed": "Aðgerð mistókst: ${error}", + "warmCharCacheFailed": "Forhleðsla skyndiminnis persónu mistókst: ${error}", "shareGroupFailed": "Mistókst að deila hóptengli: ${error}", "replyInline": { "title": "Svara" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Mistókst að hlaða: ${error}", + "rebindFailed": "Endurtenging sambandsherbergis mistókst: ${error}", "subtitle": "Stjórna tengingum milli tækja; yfirgefa vanskil í flestum tilfellum", "title": "Sambands-P2P-stillingar", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Torg", "description": "P2P opin samfélagsfærslur, fylgni og skráadeiling", "bootstrapFailed": "Frumstilling samfélagsviðmóts mistókst: ${error}", + "connectNodeFailed": "Tenging við hnút mistókst: ${error}", + "dwellFailed": "Uppfærsla óskagagna út frá áhorfstíma mistókst: ${error}", "home_function_buttons": { "main": { "title": "Torg" diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 0374224f8..cd8d66c17 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Creato `.noupdate`; aggiornamenti automatici sospesi.", "unknownTarget": "Impossibile identificare «${target}»: non è un ramo noto, un commit né una pull request." }, + "version": { + "branch": { + "title": "Ramo: ${branch}", + "detached": "HEAD scollegato" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Stato: ${status}", + "upToDate": "Aggiornato", + "behind": "Indietro rispetto al remoto", + "ahead": "Avanti rispetto al remoto", + "diverged": "Divergente dal remoto", + "detachedNoCompare": "HEAD scollegato, nessun confronto con il remoto", + "fetchFailed": "Impossibile recuperare lo stato del remoto" + }, + "autoUpdatePaused": "Aggiornamenti automatici sospesi (`.noupdate` presente)", + "noRepo": "Repository Git non trovato", + "noGit": "Git non è installato" + }, "shortcut": { "desktopShortcutCreated": "Collegamento sul desktop creato in `${path}`", "startMenuShortcutCreated": "Collegamento al menu Start creato in `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Gruppi e navigazione" }, "operationFailed": "Operazione non riuscita: ${error}", + "warmCharCacheFailed": "Precaricamento della cache dei personaggi non riuscito: ${error}", "shareGroupFailed": "Impossibile condividere il collegamento al gruppo: ${error}", "replyInline": { "title": "Rispondere" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Impossibile caricare: ${error}", + "rebindFailed": "Ricollegamento della stanza federata non riuscito: ${error}", "subtitle": "Controlla le connessioni tra dispositivi: le impostazioni predefinite vanno bene per la maggior parte delle persone", "title": "Impostazioni P2P federate", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "piazza", "description": "P2P apre aggiornamenti social, follower e condivisione di file", "bootstrapFailed": "Inizializzazione dell'interfaccia social non riuscita: ${error}", + "connectNodeFailed": "Connessione al nodo non riuscita: ${error}", + "dwellFailed": "Aggiornamento delle preferenze in base al tempo di visualizzazione non riuscito: ${error}", "home_function_buttons": { "main": { "title": "piazza" diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index e95dc4224..f6f69ea78 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` を作成しました。自動更新を一時停止しました。", "unknownTarget": "「${target}」を識別できません:既知のブランチ、コミット、プルリクエストのいずれでもありません。" }, + "version": { + "branch": { + "title": "ブランチ: ${branch}", + "detached": "detached HEAD" + }, + "commit": "コミット: `${ref}`", + "remote": "リモート: `${ref}`", + "status": { + "title": "状態: ${status}", + "upToDate": "最新", + "behind": "リモートより遅れています", + "ahead": "リモートより進んでいます", + "diverged": "リモートと分岐しています", + "detachedNoCompare": "detached HEAD のためリモートと比較できません", + "fetchFailed": "リモートの状態を取得できません" + }, + "autoUpdatePaused": "自動更新を一時停止中(`.noupdate` あり)", + "noRepo": "Git リポジトリが見つかりません", + "noGit": "Git がインストールされていません" + }, "shortcut": { "desktopShortcutCreated": "`${path}` にデスクトップ ショートカットが作成されました", "startMenuShortcutCreated": "`${path}` にスタート メニューのショートカットが作成されました", @@ -1948,6 +1968,7 @@ "aria-label": "グループとナビゲーション" }, "operationFailed": "操作が失敗しました: ${error}", + "warmCharCacheFailed": "キャラクターキャッシュのウォームアップに失敗しました:${error}", "shareGroupFailed": "グループリンクを共有できませんでした: ${error}", "replyInline": { "title": "返事" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "読み込みに失敗しました: ${error}", + "rebindFailed": "フェデレーションルームの再バインドに失敗しました:${error}", "subtitle": "クロスデバイス接続を制御します。ほとんどの場合はデフォルトのままにします", "title": "フェデレーション P2P 設定", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "広場", "description": "P2P オープンソーシャル・フォロー・ファイル共有", "bootstrapFailed": "ソーシャル インターフェイスの初期化に失敗しました: ${error}", + "connectNodeFailed": "ノードへの接続に失敗しました:${error}", + "dwellFailed": "視聴時間に基づくおすすめデータの更新に失敗しました:${error}", "home_function_buttons": { "main": { "title": "広場" diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index 5cff162a6..9f1c618ac 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate`를 생성했습니다. 자동 업데이트가 일시 중지되었습니다.", "unknownTarget": "'${target}'을(를) 식별할 수 없습니다: 알려진 브랜치, 커밋 또는 풀 리퀘스트가 아닙니다." }, + "version": { + "branch": { + "title": "브랜치: ${branch}", + "detached": "HEAD 분리됨" + }, + "commit": "커밋: `${ref}`", + "remote": "원격: `${ref}`", + "status": { + "title": "상태: ${status}", + "upToDate": "최신 상태", + "behind": "원격보다 뒤쳐짐", + "ahead": "원격보다 앞섬", + "diverged": "원격과 분기됨", + "detachedNoCompare": "HEAD 분리 상태, 원격과 비교 불가", + "fetchFailed": "원격 상태를 가져올 수 없음" + }, + "autoUpdatePaused": "자동 업데이트 일시 중지됨 (`.noupdate` 존재)", + "noRepo": "Git 저장소를 찾을 수 없음", + "noGit": "Git이 설치되지 않음" + }, "shortcut": { "desktopShortcutCreated": "`${path}`에 바탕화면 바로가기가 생성되었습니다.", "startMenuShortcutCreated": "`${path}`에 시작 메뉴 바로가기가 생성되었습니다.", @@ -1948,6 +1968,7 @@ "aria-label": "그룹 및 탐색" }, "operationFailed": "작업 실패: ${error}", + "warmCharCacheFailed": "캐릭터 캐시 준비 실패: ${error}", "shareGroupFailed": "그룹 링크 공유 실패: ${error}", "replyInline": { "title": "답글" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "불러오기 실패: ${error}", + "rebindFailed": "연합 룸 리바인딩 실패: ${error}", "subtitle": "교차 장치 연결을 제어합니다. 대부분의 경우 기본값을 그대로 둡니다.", "title": "연합 P2P 설정", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "정사각형", "description": "P2P 공개 소셜 업데이트, 팔로우 및 파일 공유", "bootstrapFailed": "소셜 인터페이스 초기화 실패: ${error}", + "connectNodeFailed": "노드 연결 실패: ${error}", + "dwellFailed": "시청 시간 기반 취향 데이터 업데이트 실패: ${error}", "home_function_buttons": { "main": { "title": "정사각형" diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index 650fa5d57..93b81e92c 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -363,6 +363,26 @@ "createdNoUpdate": "已立 `.noupdate` 檔,自動更新暫止。", "unknownTarget": "未能辨識「${target}」:非已知分支、提交或拉取請求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已離" + }, + "commit": "提交:`${ref}`", + "remote": "遠端:`${ref}`", + "status": { + "title": "態:${status}", + "upToDate": "已為最新", + "behind": "遜於遠端", + "ahead": "先於遠端", + "diverged": "與遠端歧", + "detachedNoCompare": "HEAD 已離,未與遠端較", + "fetchFailed": "未能察遠端態" + }, + "autoUpdatePaused": "自動更新暫止(有 `.noupdate`)", + "noRepo": "未尋得 Git 倉", + "noGit": "未裝 Git" + }, "shortcut": { "desktopShortcutCreated": "桌面快捷已建於 `${path}`", "startMenuShortcutCreated": "開始綱目快捷已建於 `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "群與導覽" }, "operationFailed": "未竟其功:${error}", + "warmCharCacheFailed": "角色緩存預熱未遂:${error}", "shareGroupFailed": "分享群鏈接未遂:${error}", "replyInline": { "title": "回復" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "取錄有失:${error}", + "rebindFailed": "諸邦房間重新綁定未遂:${error}", "subtitle": "掌跨器連繫;大抵守常制即可", "title": "諸邦 P2P 樞機", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "廣場", "description": "P2P 開放社交言、關注與文卷分享", "bootstrapFailed": "社交界面初設未遂:${error}", + "connectNodeFailed": "連接節點未遂:${error}", + "dwellFailed": "依觀看時長更新偏好未遂:${error}", "home_function_buttons": { "main": { "title": "廣場" diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index 753fbf76a..602ee7383 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` aangemaakt; automatische updates gepauzeerd.", "unknownTarget": "Kan '${target}' niet herkennen: geen bekende branch, commit of pull request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Losgekoppeld HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Bijgewerkt", + "behind": "Achter op remote", + "ahead": "Voor op remote", + "diverged": "Uit elkaar gelopen met remote", + "detachedNoCompare": "Losgekoppeld HEAD, geen vergelijking met remote", + "fetchFailed": "Kan remotestatus niet ophalen" + }, + "autoUpdatePaused": "Automatische updates gepauzeerd (`.noupdate` bestaat)", + "noRepo": "Git-repository niet gevonden", + "noGit": "Git is niet geïnstalleerd" + }, "shortcut": { "desktopShortcutCreated": "Snelkoppeling op het bureaublad gemaakt op `${path}`", "startMenuShortcutCreated": "Snelkoppeling naar startmenu gemaakt op `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Groepen en navigatie" }, "operationFailed": "Bewerking mislukt: ${error}", + "warmCharCacheFailed": "Voorladen van personagecache mislukt: ${error}", "shareGroupFailed": "Kan groepslink niet delen: ${error}", "replyInline": { "title": "Antwoord" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Laden mislukt: ${error}", + "rebindFailed": "Opnieuw koppelen van federatieruimte mislukt: ${error}", "subtitle": "Beheer verbindingen tussen apparaten; laat in de meeste gevallen de standaardwaarden staan", "title": "Federatieve P2P-instellingen", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plein", "description": "Open P2P-socialfeed, volgen en bestandsdeling", "bootstrapFailed": "Initialisatie van sociale interface mislukt: ${error}", + "connectNodeFailed": "Verbinding met knooppunt mislukt: ${error}", + "dwellFailed": "Voorkeursgegevens bijwerken op basis van kijktijd mislukt: ${error}", "home_function_buttons": { "main": { "title": "Plein" diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index 6237fa355..4d3bfed27 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` criado; atualizações automáticas pausadas.", "unknownTarget": "Não foi possível identificar «${target}»: não é um ramo conhecido, um commit nem uma pull request." }, + "version": { + "branch": { + "title": "Ramo: ${branch}", + "detached": "HEAD desanexado" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Estado: ${status}", + "upToDate": "Atualizado", + "behind": "Atrasado em relação ao remoto", + "ahead": "À frente do remoto", + "diverged": "Divergido do remoto", + "detachedNoCompare": "HEAD desanexado, sem comparação com o remoto", + "fetchFailed": "Não foi possível obter o estado do remoto" + }, + "autoUpdatePaused": "Atualizações automáticas pausadas (`.noupdate` existe)", + "noRepo": "Repositório Git não encontrado", + "noGit": "Git não está instalado" + }, "shortcut": { "desktopShortcutCreated": "Atalho na área de trabalho criado em `${path}`", "startMenuShortcutCreated": "Atalho do menu Iniciar criado em `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Grupos e navegação" }, "operationFailed": "Falha na operação: ${error}", + "warmCharCacheFailed": "Falha ao pré-aquecer a cache de personagens: ${error}", "shareGroupFailed": "Falha ao share group link: ${error}", "replyInline": { "title": "Responder" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Falha no carregamento: ${error}", + "rebindFailed": "Falha ao religar a sala da federação: ${error}", "subtitle": "Controlar conexões entre dispositivos; deixe os padrões na maioria dos casos", "title": "Definições P2P federadas", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Praça", "description": "Publicações sociais abertas P2P, seguir e partilha de ficheiros", "bootstrapFailed": "Falha na inicialização da interface social: ${error}", + "connectNodeFailed": "Falha ao ligar ao nó: ${error}", + "dwellFailed": "Falha ao atualizar preferências com base no tempo de visualização: ${error}", "home_function_buttons": { "main": { "title": "Praça" diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index be344f283..019cc501f 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Создан `.noupdate`; автоматические обновления приостановлены.", "unknownTarget": "Не удалось распознать «${target}»: это неизвестная ветка, коммит или pull request." }, + "version": { + "branch": { + "title": "Ветка: ${branch}", + "detached": "Отсоединённый HEAD" + }, + "commit": "Коммит: `${ref}`", + "remote": "Удалённый: `${ref}`", + "status": { + "title": "Статус: ${status}", + "upToDate": "Актуально", + "behind": "Отстаёт от удалённого", + "ahead": "Опережает удалённый", + "diverged": "Разошёлся с удалённым", + "detachedNoCompare": "Отсоединённый HEAD, сравнение с удалённым невозможно", + "fetchFailed": "Не удалось получить состояние удалённого репозитория" + }, + "autoUpdatePaused": "Автообновление приостановлено (есть `.noupdate`)", + "noRepo": "Репозиторий Git не найден", + "noGit": "Git не установлен" + }, "shortcut": { "desktopShortcutCreated": "Ярлык на рабочем столе создан по адресу `${path}`.", "startMenuShortcutCreated": "Ярлык меню «Пуск», созданный по адресу `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Группы и навигация" }, "operationFailed": "Ошибка операции: ${error}", + "warmCharCacheFailed": "Не удалось прогреть кэш персонажей: ${error}", "shareGroupFailed": "Не удалось поделиться ссылкой на группу: ${error}", "replyInline": { "title": "Ответить" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Не удалось загрузить: ${error}", + "rebindFailed": "Не удалось перепривязать комнату федерации: ${error}", "subtitle": "Контролировать соединения между устройствами; в большинстве случаев оставьте значения по умолчанию", "title": "Настройки федеративного P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Площадь", "description": "P2P-соцсеть: лента, подписки и обмен файлами", "bootstrapFailed": "Не удалось инициализировать социнтерфейс: ${error}", + "connectNodeFailed": "Не удалось подключиться к узлу: ${error}", + "dwellFailed": "Не удалось обновить данные о предпочтениях по времени просмотра: ${error}", "home_function_buttons": { "main": { "title": "Площадь" diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index 7e67d3d1a..5c9733af8 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Створено `.noupdate`; автоматичні оновлення призупинено.", "unknownTarget": "Неможливо розпізнати «${target}»: це не відома гілка, коміт або pull request." }, + "version": { + "branch": { + "title": "Гілка: ${branch}", + "detached": "Від'єднаний HEAD" + }, + "commit": "Коміт: `${ref}`", + "remote": "Віддалений: `${ref}`", + "status": { + "title": "Статус: ${status}", + "upToDate": "Актуально", + "behind": "Відстає від віддаленого", + "ahead": "Випереджає віддалений", + "diverged": "Розійшовся з віддаленим", + "detachedNoCompare": "Від'єднаний HEAD, порівняння з віддаленим неможливе", + "fetchFailed": "Не вдалося отримати стан віддаленого репозиторію" + }, + "autoUpdatePaused": "Автооновлення призупинено (існує `.noupdate`)", + "noRepo": "Репозиторій Git не знайдено", + "noGit": "Git не встановлено" + }, "shortcut": { "desktopShortcutCreated": "Ярлик на робочому столі створено в `${path}`", "startMenuShortcutCreated": "Ярлик меню «Пуск» створено в `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Групи та навігація" }, "operationFailed": "Помилка операції: ${error}", + "warmCharCacheFailed": "Не вдалося розігріти кеш персонажів: ${error}", "shareGroupFailed": "Не вдалося поділитися посиланням на групу: ${error}", "replyInline": { "title": "Відповісти" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Помилка завантаження: ${error}", + "rebindFailed": "Не вдалося переприв’язати кімнату федерації: ${error}", "subtitle": "Контроль з'єднань між пристроями; у більшості випадків залишайте значення за замовчуванням", "title": "Об'єднані налаштування P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Стрічка", "description": "P2P відкриті оновлення в соціальних мережах, підписка та обмін файлами", "bootstrapFailed": "Помилка ініціалізації соціального інтерфейсу: ${error}", + "connectNodeFailed": "Не вдалося підключитися до вузла: ${error}", + "dwellFailed": "Не вдалося оновити дані про вподобання за часом перегляду: ${error}", "home_function_buttons": { "main": { "title": "Стрічка" diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index b45aff963..d51db4568 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Đã tạo `.noupdate`; cập nhật tự động đã tạm dừng.", "unknownTarget": "Không thể nhận dạng «${target}»: không phải nhánh, commit hoặc pull request đã biết." }, + "version": { + "branch": { + "title": "Nhánh: ${branch}", + "detached": "HEAD tách rời" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Trạng thái: ${status}", + "upToDate": "Đã cập nhật", + "behind": "Chậm hơn remote", + "ahead": "Đi trước remote", + "diverged": "Phân nhánh với remote", + "detachedNoCompare": "HEAD tách rời, không so sánh với remote", + "fetchFailed": "Không thể lấy trạng thái remote" + }, + "autoUpdatePaused": "Đã tạm dừng cập nhật tự động (có `.noupdate`)", + "noRepo": "Không tìm thấy kho Git", + "noGit": "Git chưa được cài đặt" + }, "shortcut": { "desktopShortcutCreated": "Lối tắt trên màn hình được tạo tại `${path}`", "startMenuShortcutCreated": "Phím tắt menu bắt đầu được tạo tại `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Nhóm và điều hướng" }, "operationFailed": "Thao tác không thành công: ${error}", + "warmCharCacheFailed": "Không thể làm nóng bộ nhớ đệm nhân vật: ${error}", "shareGroupFailed": "Không chia sẻ được liên kết nhóm: ${error}", "replyInline": { "title": "hồi đáp" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Tải không thành công: ${error}", + "rebindFailed": "Không thể liên kết lại phòng liên đoàn: ${error}", "subtitle": "Kiểm soát kết nối thiết bị chéo; để mặc định trong hầu hết các trường hợp", "title": "Cài đặt P2P liên kết", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Quảng trường", "description": "P2P mở cập nhật xã hội, theo dõi và chia sẻ tệp", "bootstrapFailed": "Khởi tạo giao diện xã hội không thành công: ${error}", + "connectNodeFailed": "Không thể kết nối tới nút: ${error}", + "dwellFailed": "Không thể cập nhật dữ liệu sở thích theo thời gian xem: ${error}", "home_function_buttons": { "main": { "title": "Quảng trường" diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index cd2d224b2..b082ef1b7 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "已创建 `.noupdate`,自动更新已暂停。", "unknownTarget": "无法识别「${target}」:不是已知分支、提交或拉取请求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已分离" + }, + "commit": "提交:`${ref}`", + "remote": "远程:`${ref}`", + "status": { + "title": "状态:${status}", + "upToDate": "已是最新", + "behind": "落后于远程", + "ahead": "领先于远程", + "diverged": "与远程已分叉", + "detachedNoCompare": "HEAD 已分离,无法与远程比较", + "fetchFailed": "无法获取远程状态" + }, + "autoUpdatePaused": "自动更新已暂停(存在 `.noupdate`)", + "noRepo": "未找到 Git 仓库", + "noGit": "未安装 Git" + }, "shortcut": { "desktopShortcutCreated": "已在 `${path}` 创建桌面快捷方式", "startMenuShortcutCreated": "已在 `${path}` 创建开始菜单快捷方式", @@ -1948,6 +1968,7 @@ "aria-label": "群组与导航" }, "operationFailed": "操作失败:${error}", + "warmCharCacheFailed": "角色缓存预热失败:${error}", "shareGroupFailed": "分享群链接失败:${error}", "replyInline": { "title": "回复" @@ -2164,6 +2185,7 @@ }, "federation": { "loadFailed": "加载失败:${error}", + "rebindFailed": "联邦房间重新绑定失败:${error}", "subtitle": "控制跨设备连接;大多数情况下保持默认即可", "title": "联邦 P2P 设置", "tooltip": { @@ -4045,6 +4067,8 @@ "title": "广场", "description": "P2P 开放社交动态、关注与文件分享", "bootstrapFailed": "社交界面初始化失败:${error}", + "connectNodeFailed": "连接节点失败:${error}", + "dwellFailed": "观看时长偏好更新失败:${error}", "home_function_buttons": { "main": { "title": "广场" diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index ce09f149d..297e3f4b8 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -362,6 +362,26 @@ "createdNoUpdate": "已建立 `.noupdate`,自動更新已暫停。", "unknownTarget": "無法識別「${target}」:不是已知分支、提交或拉取請求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已分離" + }, + "commit": "提交:`${ref}`", + "remote": "遠端:`${ref}`", + "status": { + "title": "狀態:${status}", + "upToDate": "已是最新", + "behind": "落後於遠端", + "ahead": "領先於遠端", + "diverged": "與遠端已分叉", + "detachedNoCompare": "HEAD 已分離,無法與遠端比較", + "fetchFailed": "無法取得遠端狀態" + }, + "autoUpdatePaused": "自動更新已暫停(存在 `.noupdate`)", + "noRepo": "找不到 Git 儲存庫", + "noGit": "未安裝 Git" + }, "shortcut": { "desktopShortcutCreated": "已在 `${path}` 建立桌面捷徑", "startMenuShortcutCreated": "已在 `${path}` 建立開始選單捷徑", @@ -1947,6 +1967,7 @@ "aria-label": "群組與導覽" }, "operationFailed": "操作失敗:${error}", + "warmCharCacheFailed": "角色快取預熱失敗:${error}", "shareGroupFailed": "分享群連結失敗:${error}", "replyInline": { "title": "回覆" @@ -2159,6 +2180,7 @@ }, "federation": { "loadFailed": "載入失敗:${error}", + "rebindFailed": "聯邦房間重新綁定失敗:${error}", "subtitle": "控制跨裝置連線;大多數情況下保持預設即可", "title": "聯邦 P2P 設定", "tooltip": { @@ -4040,6 +4062,8 @@ "title": "廣場", "description": "P2P 開放社交動態、關注與檔案分享", "bootstrapFailed": "社交介面初始化失敗:${error}", + "connectNodeFailed": "連接節點失敗:${error}", + "dwellFailed": "觀看時長偏好更新失敗:${error}", "home_function_buttons": { "main": { "title": "廣場" 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/endpoints/base.mjs b/src/public/pages/scripts/endpoints/base.mjs new file mode 100644 index 000000000..8bb1b9374 --- /dev/null +++ b/src/public/pages/scripts/endpoints/base.mjs @@ -0,0 +1,283 @@ +/** + * 解析 JSON:`response.ok` 为 false 则 reject 并附带响应体。 + * @param {Response} response - fetch 响应。 + * @returns {Promise<object>} 解析后的 JSON 对象。 + */ +async function finishAuthenticatedJsonMutation(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 })) + } + return response.json() +} + +/** + * Ping 服务器。 + * @param {boolean} [with_cache=false] - 是否使用缓存。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function ping(with_cache = false) { + const response = await fetch('/api/ping', { + 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() +} + +/** + * 获取本地 IP 中的主机 URL。 + * @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) +} + +/** + * 获取 PoW 挑战。 + * @returns {Promise<object>} - PoW 挑战。 + */ +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() +} + +/** + * 兑换 PoW 令牌。 + * @param {string} token - PoW 令牌。 + * @param {object[]} solutions - PoW 解决方案。 + * @returns {Promise<object>} - 兑换结果。 + */ +export async function redeemPoWToken(token, solutions) { + const response = await fetch('/api/pow/redeem', { + method: 'POST', + 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() +} + +/** + * 获取区域设置数据。 + * @param {string[]} [preferred] - 首选语言列表。 + * @returns {Promise<object>} - 区域设置数据。 + */ +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() +} + +/** + * 获取可用的区域设置。 + * @returns {Promise<string[]>} - 可用区域设置的列表。 + */ +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() +} + +/** + * 生成验证码。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function generateVerificationCode() { + return await fetch('/api/register/generateverificationcode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) +} + +/** + * 获取当前用户信息。 + * @returns {Promise<object>} - 当前用户信息。 + */ +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() +} + +/** + * 登录。 + * @param {string} username - 用户名。 + * @param {string} password - 密码。 + * @param {string} deviceid - 设备 ID。 + * @param {string} powToken - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function login(username, password, deviceid, powToken) { + return await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, deviceid, powToken }), + }) +} + +/** + * Passkey 登录:请求挑战选项。 + * @param {string} [powToken] - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function webauthnLoginBegin(powToken) { + return await fetch('/api/webauthn/login/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ powToken }), + }) +} + +/** + * Passkey 登录:提交断言并完成会话。 + * @param {object} credential - 浏览器返回的凭证 JSON。 + * @param {string} authSessionToken - begin 返回的会话令牌。 + * @param {string} deviceid - 设备 ID。 + * @param {string} [powToken] - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function webauthnLoginComplete(credential, authSessionToken, deviceid, powToken) { + return await fetch('/api/webauthn/login/complete', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + credential, + deviceid, + powToken, + authSessionToken: (authSessionToken ?? '').trim(), + }), + }) +} + +/** + * 注册。 + * @param {string} username - 用户名。 + * @param {string} password - 密码。 + * @param {string} deviceid - 设备 ID。 + * @param {string} verificationcode - 验证码。 + * @param {string} powToken - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function register(username, password, deviceid, verificationcode, powToken) { + return await fetch('/api/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, deviceid, verificationcode, powToken }), + }) +} + +/** + * 验证身份。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function authenticate() { + return await fetch('/api/authenticate', { + method: 'POST', + }) +} + + +/** + * 获取用户设置。 + * @param {string} key - 键。 + * @returns {Promise<any>} - 用户设置值。 + */ +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 +} + +/** + * 设置用户设置。 + * @param {string} key - 键。 + * @param {any} value - 值。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function setUserSetting(key, value) { + return await fetch('/api/setusersetting', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, value }), + }) +} + +/** + * 注销。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function logout() { + const response = await fetch('/api/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 获取 API 密钥。 + * @returns {Promise<object>} - API 密钥列表。 + */ +export async function getApiKeys() { + const response = await fetch('/api/apikey/list') + return finishAuthenticatedJsonMutation(response) +} + +/** + * 创建 API 密钥。 + * @param {string} description - 描述。 + * @returns {Promise<object>} - 新的 API 密钥。 + */ +export async function createApiKey(description) { + const response = await fetch('/api/apikey/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description }), + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 撤销 API 密钥。 + * @param {string} jti - JTI。 + * @param {string} password - 密码。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function revokeApiKey(jti, password) { + const response = await fetch('/api/apikey/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jti, password }), + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 验证 API 密钥。 + * @param {string} apiKey - API 密钥。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function verifyApiKey(apiKey) { + return await fetch('/api/apikey/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + 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..b5a5a2a74 --- /dev/null +++ b/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs @@ -0,0 +1,82 @@ +/** + * 浏览器端 EVFS 媒体 HTTP(Chat / Social 共用)。 + * URL 纯函数见 `/parts/shells:chat/shared/evfsMedia.mjs`。 + */ +import { entityFileUrl, mediaRefUrl } from '/parts/shells:chat/shared/evfsMedia.mjs' + +/** + * + */ +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/endpoints/parts.mjs b/src/public/pages/scripts/endpoints/parts.mjs new file mode 100644 index 000000000..91d593ade --- /dev/null +++ b/src/public/pages/scripts/endpoints/parts.mjs @@ -0,0 +1,206 @@ +/** + * 部件相关的 API 函数。 + * + * **partpath 约定(勿在此文件做 colon→slash 转换)** + * - 本模块 POST body 与服务端 `loadPart` 只认斜杠路径:`shells/chat`、`chars/Foo`。 + * - URL / `fount://run/` 里的 `shells:chat` 是另一套语法;调用方自行规整后再传入 + * (例:`protocolhandler/index.mjs` 在调 `runPart` 前 `replaceAll(':', '/')`)。 + * - 不要在 `runPart` / `loadPart` 里偷偷 `replace(/:/g, '/')`:会掩盖调用方 bug,且与 + * 「规整是调用者职责」不一致。 + */ + +/** + * 运行部件。 + * @param {string} partpath - 斜杠 partpath(如 `shells/install`),非 URL colon 形式。 + * @param {object} args - 参数。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function runPart(partpath, args) { + const response = await fetch('/api/runpart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ partpath, args }), + }) + 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() +} + +/** + * 加载部件。 + * @param {string} partpath - 斜杠 partpath(如 `shells/chat`),非 URL colon 形式。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function loadPart(partpath) { + const response = await fetch('/api/loadpart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ partpath }), + }) + 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() +} + +/** + * 获取部件类型列表。 + * @returns {Promise<string[]>} - 部件类型列表。 + */ +export async function getPartTypeList() { + const response = await fetch('/api/getparttypelist') + 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() +} + +/** + * 获取部件列表。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 部件列表。 + */ +export async function getPartList(path) { + const response = await fetch(`/api/getlist/${path}`) + 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() +} + +/** + * 获取已加载部件的列表。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 已加载的部件列表。 + */ +export async function getLoadedPartList(path) { + const response = await fetch(`/api/getloadedlist/${path}`) + 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() +} + +/** + * 获取所有缓存的部件详细信息。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 缓存的部件详细信息。 + */ +export async function getAllCachedPartDetails(path) { + const response = await fetch(`/api/getallcacheddetails/${path}`) + 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() +} + +/** + * 获取部件详细信息。 + * @param {string} path - 路径。 + * @param {boolean} [nocache=false] - 是否不使用缓存。 + * @returns {Promise<object>} - 部件详细信息。 + */ +export async function getPartDetails(path, nocache = false) { + const url = new URL(window.location.origin + `/api/getdetails/${path}`) + if (nocache) url.searchParams.set('nocache', 'true') + 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() +} + +/** + * 获取所有默认部件。 + * @returns {Promise<object>} - 默认部件。 + */ +export async function getAllDefaultParts() { + const response = await fetch('/api/defaultpart/getall') + 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() +} + +/** + * 设置默认部件。 + * @param {string} parent - 父部件。 + * @param {string} child - 子部件。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function setDefaultPart(parent, child) { + const response = await fetch('/api/defaultpart/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent, child }), + }) + 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() +} + +/** + * 取消设置默认部件。 + * @param {string} parent - 父部件。 + * @param {string} child - 子部件。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function unsetDefaultPart(parent, child) { + const response = await fetch('/api/defaultpart/unset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent, child }), + }) + 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() +} + +/** + * 获取任何默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<string>} - 默认子部件。 + */ +export async function getAnyDefaultPart(parent) { + const response = await fetch(`/api/defaultpart/getany/${parent}`) + 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() +} + +/** + * 按类型获取所有默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<object>} - 默认子部件列表。 + */ +export async function getAllDefaultPartsByType(parent) { + const response = await fetch(`/api/defaultpart/getallbytype/${parent}`) + 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() +} + +/** + * 获取任何首选的默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<string>} - 首选的默认子部件。 + */ +export async function getAnyPreferredDefaultPart(parent) { + const response = await fetch(`/api/defaultpart/getanypreferred/${parent}`) + 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() +} + +/** + * 获取部件分支树。 + * @param {boolean} [nocache=false] - 是否绕过缓存。 + * @returns {Promise<object>} - 部件分支对象。 + */ +export async function getPartBranches(nocache = false) { + const url = new URL('/api/getpartbranches', window.location.origin) + if (nocache) url.searchParams.set('nocache', 'true') + return fetch(url).then(async response => { + if (response.ok) return response.json() + else return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + }) +} + + +/** + * 解锁成就。 + * @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 */ }) +} diff --git a/src/public/pages/scripts/endpoints/registries.mjs b/src/public/pages/scripts/endpoints/registries.mjs new file mode 100644 index 000000000..8ca389469 --- /dev/null +++ b/src/public/pages/scripts/endpoints/registries.mjs @@ -0,0 +1,48 @@ +/** + * @typedef {{ id: string, level: number, path: string }} RegistryEntry + */ + +/** @type {Map<string, Promise<RegistryEntry[]>>} */ +const registryFetchCache = new Map() + +/** + * 获取指定 name 的 registry 条目(path 已为前端 URL)。 + * @param {string} name - registry 名称。 + * @param {{ nocache?: boolean }} [options] - 可选项。 + * @returns {Promise<RegistryEntry[]>} registry 条目列表。 + */ +export async function getRegistry(name, { nocache = false } = {}) { + if (!nocache && registryFetchCache.has(name)) + return registryFetchCache.get(name) + + const fetchPromise = (async () => { + const qs = nocache ? '?nocache=1' : '' + const res = await fetch(`/api/registries/${encodeURIComponent(name)}${qs}`) + if (!res.ok) + throw new Error(`registry fetch failed: ${name} ${res.status}`) + return res.json() + })() + + if (!nocache) registryFetchCache.set(name, fetchPromise) + return fetchPromise +} + +/** + * 动态 import registry 条目指向的模块。 + * @param {string} name - registry 名称。 + * @param {{ nocache?: boolean }} [options] - 可选项。 + * @returns {Promise<Array<{ entry: RegistryEntry, module: unknown }>>} 已加载的模块列表。 + */ +export async function importRegistryModules(name, { nocache = false } = {}) { + const entries = await getRegistry(name, { nocache }) + return (await Promise.all(entries.map(async entry => { + try { + const module = await import(entry.path) + return { entry, module } + } + catch (error) { + console.warn(`registry import failed ${name}/${entry.id}:`, error) + return null + } + }))).filter(Boolean) +} diff --git a/src/public/pages/scripts/endpoints/server_events.mjs b/src/public/pages/scripts/endpoints/server_events.mjs new file mode 100644 index 000000000..b4dbb2d9f --- /dev/null +++ b/src/public/pages/scripts/endpoints/server_events.mjs @@ -0,0 +1,52 @@ +const handlers = new Map() + +/** + * 注册一个服务器事件回调。 + * @param {string} type - 事件类型。 + * @param {(data: any) => void} callback - 回调函数。 + * @returns {void} + */ +export function onServerEvent(type, callback) { + if (!handlers.has(type)) + handlers.set(type, []) + + handlers.get(type).push(callback) +} + +/** + * 注销一个服务器事件回调。 + * @param {string} type - 事件类型。 + * @param {(data: any) => void} callback - 回调函数。 + * @returns {void} + */ +export function offServerEvent(type, callback) { + if (handlers.has(type)) { + const typeHandlers = handlers.get(type) + const index = typeHandlers.indexOf(callback) + if (index > -1) + typeHandlers.splice(index, 1) + } +} + +/** + * 由 base.mjs 在收到来自 SW 的消息时调用。 + * @param {{type: string, data: any}} message - 消息。 + * @returns {void} + */ +function dispatchMessage(message) { + const { type, data } = message + if (handlers.has(type)) + for (const handler of handlers.get(type)) try { + handler(data) + } catch (e) { + console.error(`Error in message handler for type "${type}":`, e) + } +} + +try { + navigator.serviceWorker?.addEventListener?.('message', event => { + if (event.data) dispatchMessage(event.data) + }) +} catch (error) { + if (error.name != 'SecurityError') throw error +} 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..e84659c4e --- /dev/null +++ b/src/public/pages/scripts/features/errorHandlers.mjs @@ -0,0 +1,43 @@ +/** + * 前端错误处理:只报 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 + if (Object(error?.message) instanceof String) return new Error(error.message) + return new Error(String(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..e105044ba 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..c49a94b3d --- /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(opts.unlock !== undefined ? 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..cc2ad9d7f 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,7 @@ 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(() => { }) + await finalizeDelete(recoveryToken, { cabinetId }).catch(handleError('cabinet.bootstrapFailed')) } /** @@ -29,7 +29,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 +39,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 }) } /** @@ -105,17 +105,16 @@ export function makeDeleteHistory(ids, initialToken, cabinetId, unlock = current * @returns {import('./commandHistory.mjs').HistoryEntry} 历史 */ export function makePatchHistory({ entryId, before, after, label = 'patch', cabinetId }) { - const path = `/entries/${encodeURIComponent(entryId)}` return { label, /** 撤销 PATCH:写回修改前快照。 */ async undo() { - await cabinetApi('PATCH', path, before, { cabinetId }) + await patchEntry(entryId, before, { cabinetId }) await refreshEntries() }, /** 重做 PATCH:应用修改后快照。 */ async redo() { - await cabinetApi('PATCH', path, after, { cabinetId }) + await patchEntry(entryId, after, { cabinetId }) await refreshEntries() }, } @@ -132,7 +131,7 @@ export function makeMoveHistory({ entryIds, fromParent, toParent, label = 'cut', */ 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 }) 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/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/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..c2feb07d8 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`, `handleError`, …) 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 factory form — first arg is the i18n key. - 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..70edea05a 100644 --- a/src/scripts/checks/i18n_refs.mjs +++ b/src/scripts/checks/i18n_refs.mjs @@ -168,7 +168,7 @@ export function extractI18nRefsFromSource(text) { promptText: 'string', promptTextArea: 'string', confirmAction: 'string', - handleUIError: 'string', + handleError: 'string', } for (const [name, binding] of Object.entries(apis)) { const re = new RegExp(`\\b${name}\\s*\\(\\s*(["'\`])([^"'\`]+)\\1`, 'g') diff --git a/src/scripts/errorHandlers.mjs b/src/scripts/errorHandlers.mjs new file mode 100644 index 000000000..caaf2af67 --- /dev/null +++ b/src/scripts/errorHandlers.mjs @@ -0,0 +1,31 @@ +/** + * 后端错误处理:只报 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 + if (Object(error?.message) instanceof String) return new Error(error.message) + return new Error(String(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 })