mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-09-20 19:17:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5543073ea | ||
|
|
07ec7068a5 | ||
|
|
d7d2c22994 | ||
|
|
e21e92c0d3 | ||
|
|
a0d5dfaed1 | ||
|
|
45fd078e4e | ||
|
|
4676326eb8 | ||
|
|
33bbc9a512 | ||
|
|
77f1ed16b7 | ||
|
|
e410afa9bd | ||
|
|
33c9530658 | ||
|
|
26fc8e46de | ||
|
|
622031f4ff | ||
|
|
4d92ca199b | ||
|
|
80beead2a5 | ||
|
|
4056447ba2 | ||
|
|
e26a21c4ea | ||
|
|
9ba9c3e30c | ||
|
|
810cfb1963 | ||
|
|
93bbb29d54 | ||
|
|
01130f2a0a | ||
|
|
7bd84a7216 | ||
|
|
d3dcd2362b | ||
|
|
e1b04b838e | ||
|
|
43a21de380 | ||
|
|
ff85edeaaf | ||
|
|
ba9fd5c1af | ||
|
|
c7ca0de0c3 | ||
|
|
0e452e1459 | ||
|
|
f05ab6348a | ||
|
|
5d0f01675c | ||
|
|
87d92d51e0 | ||
|
|
ac9a00bf25 | ||
|
|
84fe4259eb | ||
|
|
a1f76cac9a | ||
|
|
d0defeede9 | ||
|
|
5008a92842 | ||
|
|
905e34aefe | ||
|
|
592f9855f8 | ||
|
|
e2e05aef49 | ||
|
|
781cf30c55 | ||
|
|
27464dcf29 | ||
|
|
ef07c15085 |
@@ -22,7 +22,7 @@ body:
|
||||
label: 前置阅读 | Pre-reading
|
||||
description: 我已经自行查找、阅读以下内容(阅读了请打勾) | I have searched and read the following on my own (Please tick after reading)
|
||||
options:
|
||||
- label: "[文档/Doc](https://picgo.github.io/PicGo-Doc/)"
|
||||
- label: "[文档/Doc](https://docs.picgo.app/gui/)"
|
||||
required: true
|
||||
- label: "[Issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed)"
|
||||
required: true
|
||||
@@ -65,4 +65,4 @@ body:
|
||||
最后,喜欢 PicGo 的话不妨给它点个 star~
|
||||
如果可以的话,请我喝杯咖啡?首页有赞助二维码,谢谢你的支持!
|
||||
Finally, if you like PicGo, give it a star~
|
||||
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
|
||||
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Issue Duplicate Detection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
detect-duplicate-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run Warp Agent duplicate detector
|
||||
uses: warpdotdev/warp-agent-action@v1
|
||||
id: duplicate_detector
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
warp_api_key: ${{ secrets.WARP_API_KEY }}
|
||||
profile: ${{ vars.WARP_AGENT_PROFILE || '' }}
|
||||
prompt: |
|
||||
You are triaging duplicate GitHub issues for this repository.
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
Target issue number: #${{ github.event.issue.number }}
|
||||
Target issue URL: ${{ github.event.issue.html_url }}
|
||||
Target issue title: ${{ github.event.issue.title }}
|
||||
Target issue body:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Use the GitHub CLI with GH_TOKEN for all operations.
|
||||
|
||||
Workflow requirements:
|
||||
1. Gather issue context from title/body/error messages/symptoms/components.
|
||||
- Run:
|
||||
gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json number,title,body,url,state,labels
|
||||
2. Search with multiple strategies:
|
||||
- title keywords
|
||||
- error message fragments
|
||||
- symptom words
|
||||
- component/module names
|
||||
Example command pattern:
|
||||
gh issue list --repo ${{ github.repository }} --state all --search "<query>"
|
||||
3. Inspect every candidate in detail:
|
||||
gh issue view <candidate_number> --repo ${{ github.repository }} --json number,title,body,url,state
|
||||
4. Duplicate threshold:
|
||||
- only mark duplicate when confidence >= 90%
|
||||
- same root cause + very similar symptoms/errors/components
|
||||
5. Exclusions:
|
||||
- never include pull requests
|
||||
- never include the current issue itself (#${{ github.event.issue.number }})
|
||||
6. If confidence is insufficient or no duplicates exist, exit without commenting.
|
||||
7. If duplicates exist, create or update exactly one comment on issue #${{ github.event.issue.number }}:
|
||||
- first line must be: <!-- issue-duplicate-detector -->
|
||||
- include markdown bullet list with title + link:
|
||||
- [Issue title](${{ github.server_url }}/${{ github.repository }}/issues/123)
|
||||
8. Before posting, check existing comments on the target issue:
|
||||
- if marker comment exists, update that comment
|
||||
- otherwise create a new comment
|
||||
9. Do not comment on any other issue.
|
||||
|
||||
Expected comment format:
|
||||
<!-- issue-duplicate-detector -->
|
||||
Detected potentially duplicate issues:
|
||||
- [Duplicate issue title](${{ github.server_url }}/${{ github.repository }}/issues/123)
|
||||
- [Another duplicate issue](${{ github.server_url }}/${{ github.repository }}/issues/456)
|
||||
+100
-9
@@ -6,6 +6,11 @@ on:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "GitHub release tag to publish to (optional, defaults to current branch like dev)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
build_os:
|
||||
description: "Build for specific OS: Windows, macOS, Linux, All"
|
||||
required: true
|
||||
@@ -26,11 +31,19 @@ on:
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
skip_notarize:
|
||||
description: "Skip Notarization (true/false)"
|
||||
skip_mac_notarize:
|
||||
description: "Skip Mac Notarization (true/false)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
win_signing_mode:
|
||||
description: "Windows Signing Mode: release-signing(default) or test-signing"
|
||||
required: true
|
||||
default: "release-signing"
|
||||
type: choice
|
||||
options:
|
||||
- release-signing
|
||||
- test-signing
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22.x
|
||||
@@ -58,7 +71,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 10.29.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -82,7 +95,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SKIP_NOTARIZE: ${{ inputs.skip_notarize }}
|
||||
SKIP_NOTARIZE: ${{ inputs.skip_mac_notarize }}
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -114,7 +127,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 10.29.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -138,11 +151,88 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# 1. Build (Unsigned)
|
||||
- name: Build Windows App (${{ matrix.arch }})
|
||||
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --win ${{matrix.target}} ${{ matrix.build_arch }} --publish never
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
# 2. Upload Unsigned Artifact to SignPath (Intermediate Step)
|
||||
- name: Upload Unsigned Artifact for Signing
|
||||
id: upload-unsigned
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-${{ matrix.arch }}
|
||||
path: dist/*.exe
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Notification for Signing Start
|
||||
shell: bash
|
||||
env:
|
||||
NOTIFY_URL: ${{ secrets.SIGN_NOTIFICATION }}
|
||||
run: curl "$NOTIFY_URL"
|
||||
|
||||
# 3. Submit to SignPath and Wait
|
||||
- name: Sign Artifact with SignPath
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
env:
|
||||
SIGNPATH_SIGNING_POLICY_SLUG: |
|
||||
${{ (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || inputs.win_signing_mode == 'release-signing')
|
||||
&& 'release-signing'
|
||||
|| 'test-signing' }}
|
||||
ARTIFACT_SLUG: |
|
||||
${{ (matrix.arch == 'x64-ia32')
|
||||
&& 'PicGo-Windows'
|
||||
|| (matrix.arch == 'arm64')
|
||||
&& 'PicGo-Windows-ARM64'
|
||||
|| '' }}
|
||||
with:
|
||||
api-token: "${{ secrets.SIGNPATH_API_TOKEN }}"
|
||||
organization-id: "${{ secrets.SIGNPATH_ORGANIZATION_ID }}"
|
||||
project-slug: "${{ secrets.SIGNPATH_PROJECT_SLUG }}"
|
||||
signing-policy-slug: "${{ env.SIGNPATH_SIGNING_POLICY_SLUG }}"
|
||||
github-artifact-id: "${{ steps.upload-unsigned.outputs.artifact-id }}"
|
||||
artifact-configuration-slug: "${{ env.ARTIFACT_SLUG }}"
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: "signed-artifact"
|
||||
|
||||
# 4. Replace Unsigned with Signed & Fix Blockmap (Critical for Auto-Update)
|
||||
- name: Replace Unsigned with Signed & Update latest.yml
|
||||
shell: powershell
|
||||
run: |
|
||||
Write-Host "[Info] Contents of signed-artifact:"
|
||||
Get-ChildItem -Path "signed-artifact" -Recurse | Format-Table FullName, Length
|
||||
|
||||
# If returned zip files, extract them first
|
||||
$zips = Get-ChildItem -Path "signed-artifact\*.zip" -ErrorAction SilentlyContinue
|
||||
if ($zips) {
|
||||
Write-Host "[Info] Found zip file(s), extracting..."
|
||||
foreach ($zip in $zips) {
|
||||
Expand-Archive -Path $zip.FullName -DestinationPath "signed-artifact" -Force
|
||||
Remove-Item -Path $zip.FullName -Force
|
||||
Write-Host "[Done] Extracted: $($zip.Name)"
|
||||
}
|
||||
Write-Host "[Info] Contents after extraction:"
|
||||
Get-ChildItem -Path "signed-artifact" -Recurse | Format-Table FullName, Length
|
||||
}
|
||||
|
||||
$exeFiles = Get-ChildItem -Path "signed-artifact\*.exe" -ErrorAction SilentlyContinue
|
||||
|
||||
if ($exeFiles) {
|
||||
foreach ($exe in $exeFiles) {
|
||||
Move-Item -Path $exe.FullName -Destination "dist\" -Force
|
||||
Write-Host "[Done] Moved signed artifact: $($exe.Name)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[Error] No .exe files found in signed-artifact!"
|
||||
Get-ChildItem -Path "signed-artifact" -Recurse
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the Node.js script to update latest.yml
|
||||
node scripts/update-win-yaml.js
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -171,7 +261,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 10.29.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -216,7 +306,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 10.29.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -232,6 +322,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: PicGo-*
|
||||
|
||||
- name: List artifacts
|
||||
run: ls -laR artifacts/
|
||||
@@ -261,13 +352,13 @@ jobs:
|
||||
PICGO_ENV_S3_LEGACY_SECRET_ID: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_ID }}
|
||||
PICGO_ENV_S3_LEGACY_SECRET_KEY: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_KEY }}
|
||||
|
||||
- name: Publish GitHub Dev Release
|
||||
- name: Publish GitHub Workflow Release
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: softprops/action-gh-release@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
tag_name: dev
|
||||
tag_name: ${{ github.event.inputs.release_tag || github.ref_name }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Notify Homepage
|
||||
|
||||
# Releases are created as drafts, so a tag push is too early -- the download
|
||||
# page reads releases/latest, which only reflects a release once it is
|
||||
# published. Firing on `published` also covers the case where a draft sits for
|
||||
# days before going live.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rebuild-homepage:
|
||||
name: Rebuild picgo.app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger homepage deploy
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEPAGE_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::HOMEPAGE_DISPATCH_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The homepage builds its download list from the GitHub API at build
|
||||
# time, so it needs a rebuild to pick up a new release.
|
||||
gh workflow run deploy.yml \
|
||||
--repo PicGo/PicGo-Homepage \
|
||||
--ref production
|
||||
|
||||
echo "Requested a production rebuild of picgo.app"
|
||||
+3
-1
@@ -24,9 +24,11 @@ scripts/*.yml
|
||||
#Electron-builder output
|
||||
/dist_electron
|
||||
.serena/
|
||||
.claude/
|
||||
dist/*
|
||||
test.js
|
||||
specs/
|
||||
.cache/
|
||||
openspec/
|
||||
bug*
|
||||
bug*
|
||||
Trace-*.json
|
||||
@@ -1 +1,2 @@
|
||||
pnpm check
|
||||
pnpm run lint:dpdm
|
||||
|
||||
@@ -13,7 +13,7 @@ PicGo is an Electron + Vue 3 desktop client. Source lives in `src/`: `src/main`
|
||||
- `pnpm lint:dpdm` — fail fast on circular dependencies in `src/`.
|
||||
- `pnpm check` — run `tsc` + `lint` (run once before finishing a task).
|
||||
- Before completing a task, always run `pnpm check` and resolve any issues it reports.
|
||||
- `pnpm gen-i18n` — regenerate typed locales after touching `public/i18n/*.yml`.
|
||||
- i18n type files are auto-generated by the Vite `i18nTypesPlugin` when `public/i18n/*.yml` changes. Do not add or rely on a manual `gen-i18n` step.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow ESLint Standard defaults: two-space indentation, single quotes, trailing commas where allowed, and no stray semicolons. Author new modules in TypeScript. Keep renderer files browser-safe; route Node APIs through IPC helpers such as `src/main/events/picgoCoreIPC.ts`. Name Vue components in PascalCase (`UploadPanel.vue`) and use camelCase for utilities. Centralize IPC event names inside `src/universal/events/constants.ts`, and store enums/types under `src/universal/types/` so they stay reusable. Static assets are served from `public/` and resolved via `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`); avoid using `__static` directly.
|
||||
@@ -21,11 +21,40 @@ Static assets are served from `public/`. In the main process use `getStaticPath`
|
||||
- Do not use `as any` under any circumstances; keep typings explicit and safe.
|
||||
- Avoid `as any` in tests as well; build concrete typed stubs (e.g., `IpcMainInvokeEvent`) instead.
|
||||
- Do not prefix method calls with `void` (e.g. use `store?.refreshPicBeds()` rather than `void store?.refreshPicBeds()`).
|
||||
- Do not write `void someMethod()` or `void object.method()` anywhere in the codebase. If you need fire-and-forget behavior, use an `async` callback with `await`, or handle errors explicitly with `try/catch` and logging instead of swallowing them.
|
||||
- Because the renderer uses React Compiler, do not add `useMemo` or `useCallback` by default. Prefer plain values and inline functions unless a specific API requires stable identity or there is a proven performance issue.
|
||||
- For Zustand store state/actions, prefer reading them directly in the consuming component with `useAppStore` / `useStore` instead of passing them down through unnecessary prop layers. If a child can access the needed store value or action itself, do not thread it through parent props.
|
||||
- If a child component only needs a store action (for example `providerStoreActions.toggleExpanded`, `settingsStoreActions.setSearchValue`, or `galleryStoreActions.setViewMode`), do not pass that store action through props. Import and use the action directly inside the child component.
|
||||
- Renderer Zustand stores must follow the project store architecture:
|
||||
- `src/renderer/store/app-store.ts` is for true global renderer state only.
|
||||
- Feature/page-local UI state must live under `src/renderer/store/<feature>/` (for example `src/renderer/store/gallery/store.ts`, `src/renderer/store/gallery/actions.ts`), not inside `app-store.ts`.
|
||||
- Do not add an extra nested `store/` directory like `src/renderer/store/gallery/store/store.ts`.
|
||||
- Zustand state and actions must be separated:
|
||||
- Do not define state-mutating actions inside `create()`.
|
||||
- Keep store files focused on state shape and initial state.
|
||||
- Put global actions in `src/renderer/store/app-actions.ts`.
|
||||
- Put feature actions in `src/renderer/store/<feature>/actions.ts`.
|
||||
- Actions must update state via `useXxxStore.setState(...)`.
|
||||
- Follow strict IPC boundaries for Zustand actions:
|
||||
- Pure IPC/service calls without Zustand state changes should call the adapter/service directly from the component or helper, not through a Zustand action.
|
||||
- Flows that combine IPC/service work with Zustand state updates must live in actions files.
|
||||
- Keep server state and client state separated:
|
||||
- TanStack Query should own server state: remote API data, loading/error/stale status, refetching, cache, and request dedupe.
|
||||
- Zustand should own client/UI state: selected source, selected ids, filters, view mode, panel open state, and other local user intent.
|
||||
- Connect the two with ids, query params, and local UI state (for example `albumSource`, `typeFilter`, or `searchValue`), but do not mirror query response data back into Zustand.
|
||||
- If server state invalidates a local UI choice (for example a user becomes non-paid while `albumSource` is cloud), use a small effect/action to correct the local UI state instead of storing the whole server response in Zustand.
|
||||
- When updating nested Zustand state (especially config-like objects), use `zustand/middleware/immer`; do not reintroduce deep `...state` spread chains for nested updates.
|
||||
- Components must consume Zustand state through auto-generated selectors (for example `useAppStore.use.appConfig()`), not by destructuring the whole store or writing ad-hoc hook selectors in components.
|
||||
- Renderer-side shared constants (for example responsive breakpoints, UI timing values, fixed dimensions, thresholds, and repeated literal values used across components) should be centralized in `src/renderer/utils/consts.ts` instead of being hardcoded inline in components. When a new renderer constant may be reused or affects shared behavior, add it there first.
|
||||
- Renderer-side date/time formatting should use `dayjs` and shared format constants from `src/renderer/utils/consts.ts` (for example `DEFAULT_DATE_TIME_FORMAT`) instead of `Intl.DateTimeFormat` or ad-hoc inline format strings.
|
||||
- Enum-like object constants declared with `as const` (for example status maps, option maps, and value registries) must use PascalCase names, not camelCase. Prefer names like `PicGoCloudRequestStatusValues`, `SettingsAppearanceValues`, or `AppPlatformValues`.
|
||||
- If a renderer → main request mutates persisted config/state without using `saveConfig`, call `notifyAppConfigUpdated()` in main to inform renderers.
|
||||
- Prefer enums over union types for discrete value sets (e.g., encryption methods). Avoid introducing new string literal union types.
|
||||
- Renderer page/component styles should prefer Tailwind utility classes; avoid adding new Vue `<style>` blocks unless there's no reasonable Tailwind equivalent.
|
||||
- New renderer ↔ main request/response APIs should be implemented via RPC routes (see `src/main/events/rpc/routes/system.ts`) with `RPCRouter` + `IRPCActionType` rather than adding ad-hoc IPC modules (e.g. `picgoCloudIPC`).
|
||||
- For request/response semantics in renderer, prefer `invokeRPC` (backed by `ipcMain.handle(RPC_ACTIONS, ...)` in `src/main/events/rpc/index.ts`).
|
||||
- **AlertDialog async confirm buttons**: Do not use `AlertDialogAction` for confirm buttons that perform async operations (API calls, etc.), because `AlertDialogAction` auto-closes the dialog on click regardless of `event.preventDefault()`. Use a plain `<Button>` instead, manage `open` state manually, and close the dialog only after the async operation completes (success or error). See `src/renderer/components/main/gallery/gallery-delete-dialog.tsx` for reference.
|
||||
- **Nullish coalescing for optional checks**: Prefer `(value?.field ?? fallback) > 0` over verbose `value !== null && value !== undefined && typeof value.field === 'number' && value.field > 0` chains. Use TypeScript's optional chaining (`?.`) and nullish coalescing (`??`) to keep boolean checks concise.
|
||||
|
||||
## Testing Guidelines
|
||||
Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma picks them up via `require.context`. Run them with `npx karma start test/unit/karma.conf.js --single-run` and ensure new renderer folders are covered. Spectron e2e cases live in `test/e2e/specs`; build first (`pnpm build`), then run `npx mocha test/e2e/index.js` so Spectron can launch `dist/electron/main.js`. Document any test data, IPC stubs, or fixtures you add to keep suites reproducible.
|
||||
@@ -34,11 +63,8 @@ Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma
|
||||
Commits follow the PicGo conventional preset enforced by Husky (`pnpm lint:dpdm` + Commitlint). Stage your changes and run `pnpm cz` to craft messages that pass CI. Pull requests should explain the change, link related issues, and attach UI screenshots or recordings. Note how you validated the work (dev server, build, Karma, Spectron) and call out migration or configuration steps reviewers must perform.
|
||||
|
||||
## Internationalization Tips
|
||||
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. After editing `public/i18n/*.yml`, run `pnpm gen-i18n` to regenerate TS typings and keep them in sync.
|
||||
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. Typed i18n declarations are generated automatically from `public/i18n/en.yml` into `src/universal/types/i18n.d.ts` and `src/renderer/i18n/i18next.d.ts`.
|
||||
- Any user-facing copy (UI text, error messages, warnings, prompts, tips, notifications, etc.) MUST use i18n keys. Do not hardcode strings in code.
|
||||
- Renderer: use `$T('KEY')` from `src/renderer/i18n/index.ts`.
|
||||
- Main process: use `T('KEY')` from `src/main/i18n/index.ts`.
|
||||
- Add new keys to all locales under `public/i18n/` (at least `en.yml`, `zh-CN.yml`, `zh-TW.yml`) and run `pnpm gen-i18n`.
|
||||
|
||||
## Serena MCP & Context7 Tools
|
||||
When starting work or if you hit issues, try checking MCP for Serena or Context7 tooling. If available, use those tools to navigate, edit, or fetch docs efficiently.
|
||||
- Add new keys to all locales under `public/i18n/` (at least `en.yml`, `zh-CN.yml`, `zh-TW.yml`). The Vite i18n types plugin will regenerate the shared declarations automatically.
|
||||
|
||||
+116
@@ -1,3 +1,119 @@
|
||||
## :tada: 3.0.2 (2026-08-14)
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **gui:** protect persisted provider credentials ([77f1ed1](https://github.com/Molunerfinn/PicGo/commit/77f1ed1))
|
||||
|
||||
|
||||
### :zap: Performance Improvements
|
||||
|
||||
* **gui:** stop idle GPU churn from uploader status dot ([#1436](https://github.com/Molunerfinn/PicGo/issues/1436)) ([33bbc9a](https://github.com/Molunerfinn/PicGo/commit/33bbc9a))
|
||||
|
||||
|
||||
|
||||
## :tada: 3.0.1 (2026-07-12)
|
||||
|
||||
|
||||
### :sparkles: Features
|
||||
|
||||
* **i18n:** add Japanese (ja) translation ([#1422](https://github.com/Molunerfinn/PicGo/issues/1422)) ([33c9530](https://github.com/Molunerfinn/PicGo/commit/33c9530))
|
||||
* **i18n:** add Korean (ko) translation ([#1420](https://github.com/Molunerfinn/PicGo/issues/1420)) ([622031f](https://github.com/Molunerfinn/PicGo/commit/622031f))
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **gui:** mini window ignores startup HIDE mode on Linux ([#1417](https://github.com/Molunerfinn/PicGo/issues/1417)) ([4d92ca1](https://github.com/Molunerfinn/PicGo/commit/4d92ca1))
|
||||
|
||||
|
||||
### :pencil: Documentation
|
||||
|
||||
* update docs ([4056447](https://github.com/Molunerfinn/PicGo/commit/4056447))
|
||||
* update docs ([e26a21c](https://github.com/Molunerfinn/PicGo/commit/e26a21c))
|
||||
|
||||
|
||||
|
||||
# :tada: 3.0.0 (2026-07-01)
|
||||
|
||||
|
||||
### :sparkles: Features
|
||||
|
||||
* v3 ([#1414](https://github.com/Molunerfinn/PicGo/issues/1414)) ([93bbb29](https://github.com/Molunerfinn/PicGo/commit/93bbb29))
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **gui:** migrate renderer electron access to preload bridge ([#1405](https://github.com/Molunerfinn/PicGo/issues/1405)) ([e1b04b8](https://github.com/Molunerfinn/PicGo/commit/e1b04b8))
|
||||
* picgo-core link bug ([810cfb1](https://github.com/Molunerfinn/PicGo/commit/810cfb1))
|
||||
* signed path extract error ([#1407](https://github.com/Molunerfinn/PicGo/issues/1407)) ([d3dcd23](https://github.com/Molunerfinn/PicGo/commit/d3dcd23))
|
||||
|
||||
|
||||
### :pencil: Documentation
|
||||
|
||||
* add 2.5.3 docs ([ff85ede](https://github.com/Molunerfinn/PicGo/commit/ff85ede))
|
||||
* fix `https//` typo in Chinese FAQ [#1](https://github.com/Molunerfinn/PicGo/issues/1) ([#1410](https://github.com/Molunerfinn/PicGo/issues/1410)) ([7bd84a7](https://github.com/Molunerfinn/PicGo/commit/7bd84a7))
|
||||
* update readme ([01130f2](https://github.com/Molunerfinn/PicGo/commit/01130f2))
|
||||
|
||||
|
||||
|
||||
## :tada: 2.5.3 (2026-03-06)
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **plugin:** refresh plugin config dialog state ([#1395](https://github.com/Molunerfinn/PicGo/issues/1395)) ([0e452e1](https://github.com/Molunerfinn/PicGo/commit/0e452e1)), closes [#1394](https://github.com/Molunerfinn/PicGo/issues/1394)
|
||||
* **update:** correct latest version lookup with beta channel ([#1396](https://github.com/Molunerfinn/PicGo/issues/1396)) ([#1397](https://github.com/Molunerfinn/PicGo/issues/1397)) ([c7ca0de](https://github.com/Molunerfinn/PicGo/commit/c7ca0de))
|
||||
|
||||
|
||||
### :package: Chore
|
||||
|
||||
* add oz agent for issues ([#1392](https://github.com/Molunerfinn/PicGo/issues/1392)) ([f05ab63](https://github.com/Molunerfinn/PicGo/commit/f05ab63))
|
||||
|
||||
|
||||
### :pencil: Documentation
|
||||
|
||||
* **custom:** update README ([#1390](https://github.com/Molunerfinn/PicGo/issues/1390)) ([5d0f016](https://github.com/Molunerfinn/PicGo/commit/5d0f016))
|
||||
* update docs ([ac9a00b](https://github.com/Molunerfinn/PicGo/commit/ac9a00b))
|
||||
* update sponsor ([87d92d5](https://github.com/Molunerfinn/PicGo/commit/87d92d5))
|
||||
|
||||
|
||||
|
||||
## :tada: 2.5.2 (2026-02-10)
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* s.ee upload error ([a1f76ca](https://github.com/Molunerfinn/PicGo/commit/a1f76ca)), closes [#1385](https://github.com/Molunerfinn/PicGo/issues/1385)
|
||||
|
||||
|
||||
### :pencil: Documentation
|
||||
|
||||
* add 2.5.1 docs ([5008a92](https://github.com/Molunerfinn/PicGo/commit/5008a92))
|
||||
* update readme for s.ee ([d0defee](https://github.com/Molunerfinn/PicGo/commit/d0defee))
|
||||
|
||||
|
||||
|
||||
## :tada: 2.5.1 (2026-02-10)
|
||||
|
||||
|
||||
### :sparkles: Features
|
||||
|
||||
* windows signature ([#1386](https://github.com/Molunerfinn/PicGo/issues/1386)) ([781cf30](https://github.com/Molunerfinn/PicGo/commit/781cf30))
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **plugin:** plugin search error ([ef07c15](https://github.com/Molunerfinn/PicGo/commit/ef07c15)), closes [#1383](https://github.com/Molunerfinn/PicGo/issues/1383)
|
||||
|
||||
|
||||
### :package: Chore
|
||||
|
||||
* **notification:** add notification for windows sign ([#1387](https://github.com/Molunerfinn/PicGo/issues/1387)) ([592f985](https://github.com/Molunerfinn/PicGo/commit/592f985))
|
||||
* update link ([27464dc](https://github.com/Molunerfinn/PicGo/commit/27464dc))
|
||||
* update picgo version to support s.ee ([e2e05ae](https://github.com/Molunerfinn/PicGo/commit/e2e05ae)), closes [#1385](https://github.com/Molunerfinn/PicGo/issues/1385)
|
||||
|
||||
|
||||
|
||||
# :tada: 2.5.0 (2026-01-27)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This is usually because the `Set URL` (access URL) in your Qiniu image host conf
|
||||
|
||||
Reference: [issue#79](https://github.com/Molunerfinn/PicGo/issues/79)
|
||||
|
||||
通常是你的七牛图床配置里的`设定访问网址`没有加上`http://`或者`https//`头。
|
||||
通常是你的七牛图床配置里的`设定访问网址`没有加上`http://`或者`https://`头。
|
||||
|
||||
参考:[issue#79](https://github.com/Molunerfinn/PicGo/issues/79)
|
||||
|
||||
@@ -162,3 +162,23 @@ An official PicGo image host (if any) would be built into PicGo out of the box
|
||||
不可信。所有打着「PicGo 官方图床」旗号的第三方插件(包括不限于 www.picgo.net 等)都不是 PicGo 官方提供的图床或服务,请勿轻信。
|
||||
|
||||
PicGo 不会以“第三方插件”的形式要求你另外下载安装所谓的 PicGo 官方图床。如果 PicGo 真的做了官方图床,一定是开箱即用的内置在本体里的。如果你需要使用第三方图床,请优先参考 PicGo 官方维护的插件集合与社区仓库,并自行甄别来源与安全性。
|
||||
|
||||
## 14. SM.MS migrated to S.EE: how should I update my config? / SM.MS 迁移到 S.EE 后,配置应该怎么改?
|
||||
|
||||
SM.MS uploader has migrated to **S.EE** and changed from the original free plan to a paid service.
|
||||
|
||||
To continue uploading normally:
|
||||
|
||||
1. Get your API token from [https://s.ee/user/dashboard/](https://s.ee/user/dashboard/).
|
||||
2. Check your `picBed.smms.backupDomain`:
|
||||
- if it is an old domain such as `sm.ms` or `smms.app`, remove this field, or
|
||||
- change it to `s.ee`.
|
||||
|
||||
SM.MS 上传器已迁移到 **S.EE**,并且服务已从原本免费改为收费。
|
||||
|
||||
如需继续正常上传:
|
||||
|
||||
1. 到 [https://s.ee/user/dashboard/](https://s.ee/user/dashboard/) 获取 API Token。
|
||||
2. 检查你的 `picBed.smms.backupDomain`:
|
||||
- 如果是旧域名(如 `sm.ms`、`smms.app`),请删除该字段,或
|
||||
- 改为 `s.ee`。
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/picgo">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
|
||||
<a href="https://www.nocobase.com/?utm_source=picgo">
|
||||
<img alt="NocoBase sponsorship" width="400" src="https://static-docs.nocobase.com/Logo-Black.png">
|
||||
</a>
|
||||
|
||||
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
|
||||
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
|
||||
### [NocoBase, AI + No-Code Build reliable business systems](https://www.nocobase.com/?utm_source=picgo)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Sponsored by:</sup>
|
||||
<br>
|
||||
<a href="https://console.neon.tech/app/?promo=PicGo">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
|
||||
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
[中文](./README_zh-CN.md) | **English**
|
||||
@@ -35,6 +50,9 @@
|
||||
<a href="https://github.com/PicGo/bump-version">
|
||||
<img src="https://img.shields.io/badge/picgo-convention-blue.svg?style=flat-square" alt="">
|
||||
</a>
|
||||
<a href="https://atomgit.com/Molunerfinn/PicGo">
|
||||
<img src="https://atomgit.com/Molunerfinn/PicGo/star/badge.svg" alt="">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +67,7 @@ Whether you’re writing a blog post, taking notes, or authoring developer docs,
|
||||
PicGo supports mainstream Image hosts out of the box, and can be extended indefinitely through its plugin system:
|
||||
|
||||
- **China cloud vendors**: Qiniu, Tencent Cloud COS, UPYUN, Alibaba Cloud OSS
|
||||
- **International / open platforms**: GitHub, SM.MS, Imgur
|
||||
- **International / open platforms**: GitHub, SM.MS(S.EE), Imgur
|
||||
- **More options via plugins**: AWS S3, Cloudflare R2, MinIO, and more
|
||||
|
||||
> **Note**: PicGo itself will no longer add new third-party Image hosts by default. You can build Image host plugins yourself—see [PicGo-Core](https://docs.picgo.app/core/).
|
||||
@@ -73,6 +91,20 @@ PicGo is built around a fast, low-friction image upload experience:
|
||||
- **Even more possibilities**: image compression, watermarking, renaming, Markdown image migration, and more.
|
||||
- Explore plugins: [Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
|
||||
|
||||
### 🤖 AI-friendly
|
||||
More and more writing is handed off to AI. But the screenshots and charts it produces sit on your disk as ``, and they need to become real links before you publish. PicGo lets AI upload them too:
|
||||
|
||||
- **[PicGo Skills](https://github.com/PicGo/skills)**: official Agent Skills that teach AI when and how to upload. Works with any AI tool that supports the skills format.
|
||||
```bash
|
||||
npx skills@latest add PicGo/skills
|
||||
```
|
||||
- **[DeepSeek Harness plugin](https://github.com/PicGo/dsh-plugin)**: install it in [dsh](https://github.com/deepseek-ai/deepseek-harness) and your agent can upload to your image host on its own.
|
||||
```bash
|
||||
dsh plugin --profile web add @picgo/dsh-plugin
|
||||
```
|
||||
|
||||
Both reuse the image hosts and plugins you already configured in PicGo—nothing to set up twice. Read more in [this post](https://picgo.app/blog/2026/picgo-deepseek-harness-plugin/).
|
||||
|
||||
### 🛠 Developer-friendly
|
||||
- **HTTP API**: upload via HTTP requests (v2.2.0+), making it easy to integrate with other tools.
|
||||
- **Open source**: fully open-source and transparent.
|
||||
@@ -84,18 +116,19 @@ If you’re new to PicGo, start with the [User Guide](https://docs.picgo.app/gui
|
||||
|
||||
## Download & Install
|
||||
|
||||
| Source | Link / Installation | Platform | Notes |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- | ---------- | --------------------------------------- |
|
||||
| GitHub Releases | https://github.com/Molunerfinn/PicGo/releases | All | Downloads may be slow in mainland China |
|
||||
| [Shandong University mirror](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | Thanks to the mirror for hosting |
|
||||
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | Thanks to @huangnauh and @Gladtbam |
|
||||
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | Thanks to @iYato |
|
||||
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | Thanks to @womeimingzi11 |
|
||||
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch Linux | Thanks to @houbaron |
|
||||
| Source | Link / Installation | Platform | Notes |
|
||||
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------- | --------------------------------------- |
|
||||
| GitHub Releases | https://github.com/Molunerfinn/PicGo/releases | All | Downloads may be slow in mainland China |
|
||||
| [Shandong University mirror](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | Thanks to the mirror for hosting |
|
||||
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | Thanks to @huangnauh and @Gladtbam |
|
||||
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | Thanks to @iYato |
|
||||
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | Thanks to @womeimingzi11 |
|
||||
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch Linux | Thanks to @houbaron |
|
||||
| [Nix](https://search.nixos.org/packages?channel=unstable&query=picgo&show=picgo) | `nix-shell -p picgo` | Nix/NixOS | Thanks to @qrzbing |
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@@ -143,6 +176,8 @@ Electron binaries are stored under `~/.electron/`. If you need to refresh them,
|
||||
- [vs-picgo](https://github.com/PicGo/vs-picgo): PicGo for VS Code.
|
||||
- [flutter-picgo](https://github.com/PicGo/flutter-picgo): mobile app (Android & iOS).
|
||||
- [PicHoro](https://github.com/Kuingsmile/PicHoro): another mobile app compatible with PicGo config (Android only for now).
|
||||
- [skills](https://github.com/PicGo/skills): official Agent Skills that teach AI to upload with PicGo.
|
||||
- [dsh-plugin](https://github.com/PicGo/dsh-plugin): PicGo plugin for DeepSeek Harness.
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
+47
-13
@@ -1,12 +1,26 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/picgo">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
|
||||
<a href="https://www.nocobase.com/?utm_source=picgo">
|
||||
<img alt="NocoBase sponsorship" width="400" src="https://static-docs.nocobase.com/Logo-Black.png">
|
||||
</a>
|
||||
|
||||
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
|
||||
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
|
||||
### [NocoBase, AI + No-Code Build reliable business systems](https://www.nocobase.com/?utm_source=picgo)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Sponsored by:</sup>
|
||||
<br>
|
||||
<a href="https://console.neon.tech/app/?promo=PicGo">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
|
||||
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -36,6 +50,9 @@
|
||||
<a href="https://github.com/PicGo/bump-version">
|
||||
<img src="https://img.shields.io/badge/picgo-convention-blue.svg?style=flat-square" alt="">
|
||||
</a>
|
||||
<a href="https://atomgit.com/Molunerfinn/PicGo">
|
||||
<img src="https://atomgit.com/Molunerfinn/PicGo/star/badge.svg" alt="">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +67,7 @@
|
||||
PicGo 原生支持主流图床平台,并可通过插件系统无限扩展:
|
||||
|
||||
- **国内云厂商**:七牛云、腾讯云 COS、又拍云、阿里云 OSS
|
||||
- **国际/开源平台**:GitHub、SM.MS、Imgur
|
||||
- **国际/开源平台**:GitHub、SM.MS(S.EE)、Imgur
|
||||
- **更多支持**:通过插件支持 AWS S3、Cloudflare R2、MinIO 等第三方图床
|
||||
|
||||
> **注意**:PicGo 本体不再增加默认的第三方图床支持。你可以自行开发第三方图床插件。详见 [PicGo-Core](https://docs.picgo.app/core/)。
|
||||
@@ -74,6 +91,20 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
|
||||
- **更多可能**:支持图片压缩、水印、重命名、Markdown 图片迁移等功能插件。
|
||||
- 探索更多插件:[Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
|
||||
|
||||
### 🤖 AI 友好
|
||||
写文档的活儿越来越多地交给了 AI。但 AI 生成的截图、图表存在本地,``,发布前需要转成可访问链接。PicGo 让 AI 也能轻松传图:
|
||||
|
||||
- **[PicGo Skills](https://github.com/PicGo/skills)**:官方 Agent Skills 集合,教会 AI 什么时候该传图、怎么传。适用于任何支持 skills 格式的 AI 工具。
|
||||
```bash
|
||||
npx skills@latest add PicGo/skills
|
||||
```
|
||||
- **[DeepSeek Harness 插件](https://github.com/PicGo/dsh-plugin)**:在 [dsh](https://github.com/deepseek-ai/deepseek-harness) 里装上它,AI 就能自己把图传到你的图床。
|
||||
```bash
|
||||
dsh plugin --profile web add @picgo/dsh-plugin
|
||||
```
|
||||
|
||||
两者都会复用你在 PicGo 里已经配好的图床和插件,不用重新配置一遍。详见[这篇介绍](https://picgo.app/blog/2026/picgo-deepseek-harness-plugin/)。
|
||||
|
||||
### 🛠 开发者友好
|
||||
- **HTTP API**:支持通过 HTTP 请求调用 PicGo 上传 (v2.2.0+),方便与其他工具集成。
|
||||
- **开源透明**:代码完全开源,安全可靠。
|
||||
@@ -85,14 +116,15 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
|
||||
|
||||
## 下载安装
|
||||
|
||||
| 下载源 | 地址/安装方式 | 平台 | 备注 |
|
||||
| --------------------------------------------- | ----------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
|
||||
| GitHub Release | https://github.com/Molunerfinn/PicGo/releases | All | 国内下载速度可能会慢 |
|
||||
| [山东大学镜像站](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | 感谢 [山东大学镜像站](https://mirrors.sdu.edu.cn/) 提供的镜像支持 |
|
||||
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | 感谢 @huangnauh 和 @Gladtbam 的贡献 |
|
||||
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | 感谢 @iYato 的贡献 |
|
||||
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | 感谢 @womeimingzi11 的贡献 |
|
||||
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch-Linux | 感谢 @houbaron 的贡献 |
|
||||
| 下载源 | 地址/安装方式 | 平台 | 备注 |
|
||||
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
|
||||
| GitHub Release | https://github.com/Molunerfinn/PicGo/releases | All | 国内下载速度可能会慢 |
|
||||
| [山东大学镜像站](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | 感谢 [山东大学镜像站](https://mirrors.sdu.edu.cn/) 提供的镜像支持 |
|
||||
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | 感谢 @huangnauh 和 @Gladtbam 的贡献 |
|
||||
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | 感谢 @iYato 的贡献 |
|
||||
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | 感谢 @womeimingzi11 的贡献 |
|
||||
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch-Linux | 感谢 @houbaron 的贡献 |
|
||||
| [Nix](https://search.nixos.org/packages?channel=unstable&query=picgo&show=picgo) | `nix-shell -p picgo` | Nix/NixOS | 感谢 @qrzbing 的贡献 |
|
||||
|
||||
## 应用截图
|
||||
|
||||
@@ -144,6 +176,8 @@ pnpm run build
|
||||
- [vs-picgo](https://github.com/PicGo/vs-picgo):PicGo 的 VS Code 版。
|
||||
- [flutter-picgo](https://github.com/PicGo/flutter-picgo):PicGo 的手机版 App(支持 Android 和 iOS )。
|
||||
- [PicHoro](https://github.com/Kuingsmile/PicHoro):另一款支持 PicGo 配置的手机版 App(暂时只支持 Android)。
|
||||
- [skills](https://github.com/PicGo/skills):PicGo 官方 Agent Skills,让 AI 学会用 PicGo 传图。
|
||||
- [dsh-plugin](https://github.com/PicGo/dsh-plugin):PicGo 的 DeepSeek Harness 插件。
|
||||
|
||||
## 赞助
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 902 KiB |
@@ -0,0 +1,21 @@
|
||||
# PicGo 2.5.1 Changelog
|
||||
|
||||
## Features
|
||||
- Update: Bump `picgo` dependency to `^2.0.1` to support legacy `sm.ms` migrate to `s.ee` (#1385)
|
||||
|
||||
## Bug Fixes
|
||||
- Fix: Plugin search no longer throws when an npm package description is empty (#1383)
|
||||
|
||||
## Other
|
||||
- Update: Refresh the documentation link in the GitHub bug report template
|
||||
|
||||
----------
|
||||
|
||||
## Features
|
||||
- 更新:将 `picgo` 依赖升级到 `^2.0.1` 以支持 `s.ee`。参考 #1385
|
||||
|
||||
## Bug Fixes
|
||||
- 修复:插件搜索在 npm 包描述为空时会报错的问题。参考 #1383
|
||||
|
||||
## Other
|
||||
- 更新:更新 GitHub 问题模板中的文档链接
|
||||
@@ -0,0 +1,9 @@
|
||||
# PicGo 2.5.2 Changelog
|
||||
|
||||
## Bug Fixes
|
||||
- Fix: Resolve `s.ee` compatibility issues that could report upload failure even when the upload actually succeeded (#1385)
|
||||
|
||||
----------
|
||||
|
||||
## Bug Fixes
|
||||
- 修复:解决 `s.ee` 兼容性问题,上传实际成功时仍可能提示上传失败(#1385)
|
||||
@@ -0,0 +1,17 @@
|
||||
# PicGo 2.5.3 Changelog
|
||||
|
||||
## Bug Fixes
|
||||
- Fix: Plugin configuration dialogs now refresh correctly when switching between plugins, avoiding stale form data from the previous plugin (#1394)
|
||||
- Fix: Latest version checking now compares stable and beta releases correctly when beta updates are enabled, avoiding incorrect update prompts (#1396)
|
||||
|
||||
## Other
|
||||
- Update: README now includes a Nix/NixOS setup note, thanks @qrzbing for the contribution! (#1390)
|
||||
|
||||
----------
|
||||
|
||||
## Bug Fixes
|
||||
- 修复:在不同插件之间切换时,插件配置弹窗会正确刷新,避免沿用上一个插件的旧表单数据(#1394)
|
||||
- 修复:开启 beta 更新通道后,最新版本检查会正确比较正式版与 beta 版,避免更新提示不准确(#1396)
|
||||
|
||||
## Other
|
||||
- 更新:README 补充了 Nix/NixOS 相关说明,感谢 @qrzbing 的贡献!(#1390)
|
||||
@@ -10,7 +10,8 @@ This guide describes how to generate a consolidated changelog for any PicGo rele
|
||||
|
||||
## Structure
|
||||
- Target file: `changelog/X.Y.Z.md` (replace with the series version)
|
||||
- Three top-level sections: `## Features`, `## Bug Fixes`, `## Other`
|
||||
- Use these top-level sections when they contain items: `## Features`, `## Bug Fixes`, `## Other`
|
||||
- Omit any empty section title entirely (do not output a heading with no bullets)
|
||||
- Do **not** nest content under individual beta version headers; merge all items into these sections
|
||||
- Keep items in chronological order (early → late) within each section, mirroring the beta sequence
|
||||
- Keep inline issue references, thanks, and notes as-is
|
||||
@@ -33,18 +34,20 @@ This guide describes how to generate a consolidated changelog for any PicGo rele
|
||||
- Features ↔ “Feature(s)” or “Features” blocks
|
||||
- Bug Fixes ↔ “Bug Fixes” blocks
|
||||
- Other ↔ “Other”, “Notice”, or misc notes that are not features/bugs
|
||||
- If one section has no items after merging, skip that section heading in both EN and ZH halves
|
||||
3) Omit “国内可下载链接” (or any download links) entirely
|
||||
4) Drop beta subheadings; keep only section-level bullets in chronological order
|
||||
5) Ensure images remain adjacent to their bullets with indentation
|
||||
6) After the English sections are complete, insert `----------` on its own line
|
||||
7) Append the Chinese translation, preserving bullet order and images, under `## Features`, `## Bug Fixes`, `## Other` again (same section titles, just Chinese content; no “(Chinese)” suffix)
|
||||
7) Append the Chinese translation, preserving bullet order and images, reusing only the section headings that appeared in English (same heading set/order; no “(Chinese)” suffix)
|
||||
8) Save the result to `changelog/X.Y.Z.md`
|
||||
|
||||
## Quick checklist
|
||||
- [ ] All features present with images kept
|
||||
- [ ] All bug fixes present
|
||||
- [ ] All “Other” notes present
|
||||
- [ ] All non-empty Features items present with images kept
|
||||
- [ ] All non-empty Bug Fixes items present
|
||||
- [ ] All non-empty Other notes present
|
||||
- [ ] No beta headers
|
||||
- [ ] No download links
|
||||
- [ ] No empty section headings
|
||||
- [ ] Chronological ordering preserved
|
||||
- [ ] Chinese translation present with matching bullets/images after `----------`
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-vega",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
+18
-5
@@ -1,8 +1,9 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// temp for webUtils
|
||||
import electronRenderer from '@molunerfinn/vite-plugin-electron-renderer'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||
import { resolve } from 'path'
|
||||
import { i18nTypesPlugin } from './scripts/vite-plugin-i18n-types'
|
||||
|
||||
const alias = {
|
||||
'@': resolve(__dirname, 'src/renderer'),
|
||||
@@ -27,7 +28,9 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [externalizeDepsPlugin({
|
||||
exclude: ['@picgo/i18n']
|
||||
})],
|
||||
resolve: { alias },
|
||||
build: {
|
||||
outDir: 'dist_electron/preload',
|
||||
@@ -42,7 +45,17 @@ export default defineConfig({
|
||||
root: 'src/renderer',
|
||||
publicDir: resolve(__dirname, 'src/renderer/public'),
|
||||
resolve: { alias },
|
||||
plugins: [vue(), electronRenderer()],
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
autoCodeSplitting: true,
|
||||
routesDirectory: './routes',
|
||||
generatedRouteTree: './routeTree.gen.ts'
|
||||
}),
|
||||
react(),
|
||||
i18nTypesPlugin(),
|
||||
tailwindcss()
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist_electron/renderer',
|
||||
rollupOptions: {
|
||||
|
||||
+36
-1
@@ -5,9 +5,13 @@ const tsPlugin = require('@typescript-eslint/eslint-plugin')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
const importPlugin = require('eslint-plugin-import')
|
||||
const promisePlugin = require('eslint-plugin-promise')
|
||||
const reactHooksPlugin = require('eslint-plugin-react-hooks')
|
||||
const reactRefreshModule = require('eslint-plugin-react-refresh')
|
||||
const vuePlugin = require('eslint-plugin-vue')
|
||||
const stylistic = require('@stylistic/eslint-plugin')
|
||||
|
||||
const reactRefreshPlugin = reactRefreshModule.default || reactRefreshModule.reactRefresh || reactRefreshModule
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
const vueConfigs = vuePlugin.configs['flat/recommended'].map(config => ({
|
||||
...config,
|
||||
@@ -65,6 +69,8 @@ module.exports = [
|
||||
'test/e2e/*.js',
|
||||
'node_modules/**',
|
||||
'vitest.config.ts',
|
||||
'src/renderer/temp-vue/**',
|
||||
'src/renderer/routeTree.gen.ts'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -79,6 +85,8 @@ module.exports = [
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
promise: promisePlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
'react-refresh': reactRefreshPlugin,
|
||||
'@stylistic': stylistic
|
||||
},
|
||||
settings: {
|
||||
@@ -104,7 +112,8 @@ module.exports = [
|
||||
'no-unused-vars': 'off',
|
||||
'@stylistic/indent': ['error', 2],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'no-unexpected-multiline': 'error'
|
||||
'no-unexpected-multiline': 'error',
|
||||
...reactHooksPlugin.configs.recommended.rules
|
||||
}
|
||||
},
|
||||
...vueConfigs,
|
||||
@@ -122,6 +131,32 @@ module.exports = [
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/renderer/**/*.{ts,tsx,vue}'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [
|
||||
{
|
||||
name: 'electron',
|
||||
allowTypeImports: true,
|
||||
message: 'Use the preload bridge from @/utils/bridge in renderer runtime code.'
|
||||
},
|
||||
{
|
||||
name: 'electron/renderer',
|
||||
allowTypeImports: true,
|
||||
message: 'Use the preload bridge from @/utils/bridge in renderer runtime code.'
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{tsx,jsx}'],
|
||||
ignores: ['src/renderer/routes/**/*.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
|
||||
+54
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picgo",
|
||||
"version": "2.5.0",
|
||||
"version": "3.0.2",
|
||||
"private": true,
|
||||
"main": "dist_electron/main/index.js",
|
||||
"description": "A powerful & simple image uploader for creators.",
|
||||
@@ -14,75 +14,112 @@
|
||||
"build:mac": "npm run build && electron-builder --config electron-builder.config.ts --mac --publish never",
|
||||
"build:linux": "npm run build && electron-builder --config electron-builder.config.ts --linux --publish never",
|
||||
"build:local": "dotenv -e .env -- electron-vite build && electron-builder --config electron-builder.config.ts --publish never",
|
||||
"lint": "pnpm lint:dpdm && eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
|
||||
"lint": "pnpm lint:dpdm && pnpm lint:dpdm-fe && eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
|
||||
"tsc": "tsc --noEmit",
|
||||
"vue-tsc": "vue-tsc --noEmit",
|
||||
"bump": "bump-version",
|
||||
"cz": "git-cz",
|
||||
"dev": "electron-vite dev",
|
||||
"preview": "electron-vite preview",
|
||||
"gen-i18n": "node ./scripts/gen-i18n-types.js",
|
||||
"lint:fix": "eslint --fix --ext .js,.jsx,.ts,.tsx,.vue src/",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postuninstall": "electron-builder install-app-deps",
|
||||
"upload-dist": "node ./scripts/upload-dist.js",
|
||||
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts",
|
||||
"check": "pnpm run tsc && pnpm run vue-tsc && pnpm run lint",
|
||||
"lint:dpdm-fe": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/renderer/main.tsx",
|
||||
"check": "pnpm run tsc && pnpm run lint --fix",
|
||||
"test": "vitest run",
|
||||
"prepare": "husky",
|
||||
"commitlint": "commitlint --edit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@picgo/i18n": "^1.0.0",
|
||||
"@picgo/store": "^2.1.0",
|
||||
"@picgo/store": "^2.2.2",
|
||||
"@picgo/video-duration": "^1.0.1",
|
||||
"axios": "^0.19.0",
|
||||
"@tanstack/history": "^1.161.4",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-router": "^1.166.2",
|
||||
"@tanstack/router-plugin": "^1.166.2",
|
||||
"@virtuoso.dev/masonry": "^1.4.2",
|
||||
"ahooks": "^3.9.7",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clip-filepaths": "^0.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"comment-json": "^4.5.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"core-js": "^3.27.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.2",
|
||||
"element-plus": "^2.3.7",
|
||||
"epipebomb": "^1.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"i18next": "^25.8.14",
|
||||
"immer": "^11.1.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"keycode": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-id": "^0.14.0",
|
||||
"lowdb": "^1.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^7.0.4",
|
||||
"mime": "^4.1.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.35.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"picgo": "^2.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"picgo": "^3.0.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"qrcode.vue": "^3.3.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"semver": "^7.7.3",
|
||||
"shell-path": "2.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"systeminformation": "^5.27.14",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tunnel": "^0.0.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uuid": "^9.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.2",
|
||||
"vue3-lazyload": "^0.3.6",
|
||||
"vue3-photo-preview": "^0.3.0",
|
||||
"write-file-atomic": "^7.0.0"
|
||||
"write-file-atomic": "^7.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.276.0",
|
||||
"@aws-sdk/lib-storage": "^3.276.0",
|
||||
"@commitlint/cli": "^21.2.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@molunerfinn/vite-plugin-electron-renderer": "^0.14.7",
|
||||
"@picgo/bump-version": "^2.0.0",
|
||||
"@picgo/bump-version": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^5.6.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/electron-devtools-installer": "^2.2.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/inquirer": "^6.5.0",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/lowdb": "^1.0.9",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/request-promise-native": "^1.0.17",
|
||||
"@types/semver": "^7.3.8",
|
||||
"@types/tunnel": "^0.0.3",
|
||||
@@ -90,6 +127,7 @@
|
||||
"@types/write-file-atomic": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"commitizen": "^4.3.1",
|
||||
@@ -105,13 +143,17 @@
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.23",
|
||||
"shadcn": "^3.8.5",
|
||||
"stylus": "^0.54.7",
|
||||
"stylus-loader": "^3.0.2",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
@@ -119,7 +161,7 @@
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"./node_modules/@picgo/bump-version/commitlint-picgo"
|
||||
"./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
@@ -127,12 +169,7 @@
|
||||
"path": "./node_modules/cz-customizable"
|
||||
},
|
||||
"cz-customizable": {
|
||||
"config": "./node_modules/@picgo/bump-version/.cz-config.js"
|
||||
}
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"commit-msg": "npm run lint:dpdm && commitlint -E HUSKY_GIT_PARAMS"
|
||||
"config": "./node_modules/@picgo/bump-version/.cz-config.cjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4238
-1128
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -1,3 +1,11 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
vue-demi: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- ejs
|
||||
@@ -7,11 +15,6 @@ onlyBuiltDependencies:
|
||||
- husky
|
||||
- vue-demi
|
||||
|
||||
# for multi-arch builds, include both x64 and arm64 versions of electron
|
||||
# will be deprecated in future(use different arch machine to build different arch binaries)
|
||||
supportedArchitectures:
|
||||
os:
|
||||
- current
|
||||
cpu:
|
||||
- x64
|
||||
- arm64
|
||||
cpu: [ x64, arm64 ]
|
||||
os: [ current ]
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {}
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
+231
-21
@@ -22,6 +22,7 @@ DISABLE: Disable
|
||||
CONFIG_THING: Config ${c}
|
||||
FIND_NEW_VERSION: Find New Version
|
||||
NO_MORE_NOTICE: No More Notice
|
||||
MORE: More
|
||||
SHOW_DEVTOOLS: Show Devtools
|
||||
CURRENT_PICBED: Current Picbed
|
||||
OPEN_TOOLBOX: Open Toolbox
|
||||
@@ -29,13 +30,19 @@ OPEN_TOOLBOX: Open Toolbox
|
||||
# ---renderer i18n begin---
|
||||
|
||||
CHOOSE_YOUR_DEFAULT_PICBED: "Choose ${d} as your default picbed:"
|
||||
SIDEBAR_DASHBOARD: Dashboard
|
||||
UPLOAD_AREA: Upload Area
|
||||
GALLERY: Gallery
|
||||
ALBUM: Album
|
||||
ALBUM_PROVIDERS: Providers
|
||||
PICBEDS_SETTINGS: Picbeds Settings
|
||||
PICGO_SETTINGS: PicGo Settings
|
||||
PLUGIN_SETTINGS: Plugins Settings
|
||||
DASHBOARD_HISTORY_PANEL_TITLE: History Panel
|
||||
PICGO_CLOUD_TITLE: PicGo Cloud
|
||||
PICGO_CLOUD_DESCRIPTION: A cloud service built by PicGo, connecting all your devices.
|
||||
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
|
||||
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud Error
|
||||
PICGO_CLOUD_LOADING: Loading PicGo Cloud status...
|
||||
PICGO_CLOUD_NOT_LOGGED_IN: Not logged in to PicGo Cloud.
|
||||
PICGO_CLOUD_LOGIN: Log In
|
||||
PICGO_CLOUD_LOGOUT: Log Out
|
||||
@@ -98,6 +105,55 @@ PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (empty)
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: Conflicts resolved.
|
||||
PICGO_CLOUD_LAST_SYNC_TIME: "Last local sync time: ${time}"
|
||||
PICGO_CLOUD_LAST_SYNC_TIME_NONE: None
|
||||
PICGO_CLOUD_LAST_SYNC_LABEL: Last sync
|
||||
PICGO_CLOUD_PLAN_USAGE_TITLE: Plan & Usage
|
||||
PICGO_CLOUD_PLAN_USAGE_DESC: View your current plan and usage overview.
|
||||
PICGO_CLOUD_PLAN_PERIOD_LABEL: Plan Period
|
||||
PICGO_CLOUD_STORAGE_LABEL: Storage
|
||||
PICGO_CLOUD_FILES_LABEL: Files
|
||||
PICGO_CLOUD_USAGE_PROGRESS: "${used} of ${total}"
|
||||
PICGO_CLOUD_USAGE_UNLIMITED: Unlimited
|
||||
PICGO_CLOUD_PLAN_PERIOD_RENEWS: "Renews on ${date}"
|
||||
PICGO_CLOUD_PLAN_PERIOD_CANCELS: "Cancels on ${date}"
|
||||
PICGO_CLOUD_PLAN_PERIOD_UNTIL: "Valid until ${date}"
|
||||
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: Lifetime
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: Grace Until
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: "Your paid plan has expired and is now in the grace period. PicGo Cloud images remain accessible until this date, but quota is temporarily downgraded to free. Most paid features are temporarily unavailable. After this date, the account enters the frozen state."
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: Frozen Until
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: "Your account is frozen. PicGo Cloud images return errors. After this date, data may be cleaned up. Renew to restore access."
|
||||
PICGO_CLOUD_QUOTA_DOWNGRADED: "Quota temporarily downgraded to ${plan} during grace period. Renew to restore."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: Plan in grace period
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: "Your paid plan has expired and entered a ${days}-day grace period. Quota is temporarily downgraded to free, and most paid features are temporarily unavailable. Renew to restore full access."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: Account frozen
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: "Your account is frozen — cloud images are temporarily inaccessible and data will be cleaned up in ${days} days. Renew now to restore access."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: Data cleanup pending
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: Your account is pending cleanup and cloud data will be permanently deleted soon. Renew immediately if you want to keep your data.
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: Renew Now
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: Dismiss
|
||||
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: Auto-import is paused while your plan is in grace or frozen period. It will resume after renewal.
|
||||
PICGO_CLOUD_IMAGE_UNAVAILABLE: Image unavailable
|
||||
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: This action is unavailable during the grace period. Please renew your plan and try again.
|
||||
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: Your account is frozen. Please renew your plan to restore access.
|
||||
PICGO_CLOUD_ERROR_IMPORT_DISABLED: Auto-import is disabled. Enable it in PicGo Cloud settings first.
|
||||
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: Your current plan does not support this feature. Please upgrade and try again.
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: You have reached your plan quota. Upgrade to a higher plan to get more.
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: Quota is downgraded to free tier during grace period. Renew to restore.
|
||||
PICGO_CLOUD_ERROR_PLAN_REQUIRED: This feature requires a paid plan.
|
||||
PICGO_CLOUD_USAGE_LOAD_FAILED: Failed to load usage data.
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: Failed to load sync state.
|
||||
PICGO_CLOUD_FREE_PLAN_BANNER: You're on a free plan. Upgrade to unlock more quota and advanced cloud features.
|
||||
PICGO_CLOUD_VIEW_PLANS: View Plans
|
||||
PICGO_CLOUD_CONFIG_SYNC_TITLE: Configuration Sync
|
||||
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: Sync your settings across devices securely.
|
||||
PICGO_CLOUD_SYNC_NOW: Sync Now
|
||||
PICGO_CLOUD_SYNC_QUOTA_LABEL: Sync quota
|
||||
PICGO_CLOUD_SYNC_QUOTA_TIP: "Your plan retains the most recent ${limit} configuration snapshots. Even at ${limit} of ${limit}, syncing remains available — older snapshots beyond the limit are pruned automatically after each sync."
|
||||
PICGO_CLOUD_LOGIN_PANEL_TITLE: Sign in to PicGo Cloud
|
||||
PICGO_CLOUD_LOGIN_PANEL_DESC: Sign in to use PicGo Cloud as PicGo's official image host and enable cloud features.
|
||||
PICGO_CLOUD_LOGIN_FEATURES_TITLE: Cloud features
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: Official Image Host
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo's official image host with built-in album cloud sync.
|
||||
PICGO_CLOUD_PAID_PLAN_BADGE: Paid plan
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: Restart Required?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: Some settings may require a restart to take effect. Restart now?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: Restart Now
|
||||
@@ -142,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: Custom Link Format
|
||||
SETTINGS_SET_PROXY_AND_MIRROR: Set Proxy and Mirror
|
||||
SETTINGS_SET_SERVER: Set Server
|
||||
SETTINGS_CHECK_UPDATE: Check Update
|
||||
SETTINGS_UPDATE_CHECK_RESULT: Latest version is ${version}, ${action}
|
||||
SETTINGS_UPDATE_CHECK_NO_UPDATE: no update needed
|
||||
SETTINGS_UPDATE_CHECK_CAN_UPDATE: update available
|
||||
SETTINGS_OPEN_UPDATE_HELPER: Open Update Helper
|
||||
SETTINGS_OPEN: Open
|
||||
SETTINGS_CLOSE: Close
|
||||
@@ -182,7 +241,7 @@ SETTINGS_LOG_LEVEL_WARN: Warn
|
||||
SETTINGS_LOG_LEVEL_NONE: None
|
||||
SETTINGS_RESULT: Result
|
||||
SETTINGS_DEFAULT_PICBED: Default Picbed
|
||||
SETTINGS_SET_DEFAULT_PICBED: Set Default Picbed
|
||||
SETTINGS_SET_DEFAULT_PICBED: Set Default Uploader
|
||||
SETTINGS_NOT_CONFIG_OPTIONS: Not Config Options
|
||||
SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: Use Builtin Clipboard to Upload
|
||||
SETTINGS_CHOOSE_LANGUAGE: Choose Language
|
||||
@@ -253,21 +312,21 @@ SHORTCUT_DISABLE: Disable
|
||||
SHORTCUT_EDIT: Edit
|
||||
SHORTCUT_CHANGE_UPLOAD: Change Upload Shortcut
|
||||
|
||||
# gallery-page
|
||||
GALLERY_URL_REWRITE_TITLE: Rewrite Selected Image URLs
|
||||
GALLERY_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
|
||||
GALLERY_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
|
||||
# album-page
|
||||
ALBUM_URL_REWRITE_TITLE: Rewrite Selected Image URLs
|
||||
ALBUM_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
|
||||
ALBUM_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
|
||||
|
||||
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
|
||||
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
|
||||
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
|
||||
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
|
||||
GALLERY_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
|
||||
GALLERY_URL_REWRITE_APPLY_ONLY: Apply Only
|
||||
GALLERY_URL_REWRITE_NO_CHANGES: No URLs were changed
|
||||
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
|
||||
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
|
||||
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
|
||||
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
|
||||
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
|
||||
ALBUM_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
|
||||
ALBUM_URL_REWRITE_APPLY_ONLY: Apply Only
|
||||
ALBUM_URL_REWRITE_NO_CHANGES: No URLs were changed
|
||||
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
|
||||
|
||||
# tray-page
|
||||
|
||||
@@ -290,9 +349,12 @@ TIPS_INPUT_VALID_URL: Input valid URL
|
||||
# plugins
|
||||
|
||||
PLUGIN_SEARCH_PLACEHOLDER: Search picgo plugins on npm, or click the button to view the awesome plugins list
|
||||
PLUGIN_SEARCH_EXACT_MATCH: Exact plugin name match
|
||||
PLUGIN_INSTALL: Install
|
||||
PLUGIN_INSTALLING: Installing...
|
||||
PLUGIN_INSTALLED: Installed
|
||||
PLUGIN_DEPRECATED_BADGE: Deprecated
|
||||
PLUGIN_DEPRECATED_TITLE: This plugin has been deprecated by the author
|
||||
PLUGIN_DOING_SOMETHING: Doing...
|
||||
PLUGIN_LIST: Plugin List
|
||||
PLUGIN_IMPORT_LOCAL: Import Local Plugins
|
||||
@@ -331,7 +393,7 @@ TOOLBOX: Toolbox
|
||||
TOOLBOX_TITLE: Troubleshoot PicGo runtime issues
|
||||
TOOLBOX_SUB_TITLE: Scan the following items immediately to fix usage issues
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: Check if the configuration file is damaged
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: Check if the album file is damaged
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: Check if the album file is damaged
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: Check if there is a problem with clipboard picture upload
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: Check if the proxy settings are normal
|
||||
TOOLBOX_FIX_DONE_NEED_RELOAD: Repair completed, need to restart to take effect, restart or not
|
||||
@@ -342,8 +404,8 @@ TOOLBOX_START_FIX: Start fixing
|
||||
TOOLBOX_SUCCESS_TIPS: Congratulations, no problems were found
|
||||
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: "The configuration file path is: ${path}"
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: The configuration file is damaged
|
||||
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: "The album file path is: ${path}"
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: The album file is damaged
|
||||
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: "The album file path is: ${path}"
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: The album file is damaged
|
||||
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: Proxy settings normal
|
||||
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: No proxy settings
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: Proxy settings incorrect
|
||||
@@ -360,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: Skipped ${n} invalid URL(s), see logs for details
|
||||
TIPS_TOO_MANY_URLS_CONFIRM: You are about to upload ${n} URLs at once. This may cause lag. It's recommended to upload in batches. Continue?
|
||||
TIPS_NO_VALID_URLS: No valid URL found
|
||||
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Please install Node.js and restart PicGo to continue
|
||||
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: Plugin is trying to remove some images from the album gallery, continue?
|
||||
TIPS_PLUGIN_OVERWRITE_GALLERY: Plugin is trying to overwrite the album gallery, continue?
|
||||
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: Plugin is trying to remove some images from your album, continue?
|
||||
TIPS_PLUGIN_OVERWRITE_ALBUM: Plugin is trying to overwrite your album, continue?
|
||||
TIPS_UPLOAD_NOT_PICTURES: The latest clipboard item is not a picture
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo config file broken, has been restored to default
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo config file broken, has been restored to backup
|
||||
@@ -381,3 +443,151 @@ TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: Cannot delete the last config
|
||||
PRIVACY: "Please read and agree to the Privacy Policy ${privacyUrl} and Terms of Service ${termsUrl} before using."
|
||||
PRIVACY_TIPS: Please agree the privacy policy to upload
|
||||
QUIT: Quit
|
||||
ALBUM_ALL_PHOTOS: All Photos
|
||||
ALBUM_COLLECTIONS: Collections
|
||||
ALBUM_TAGS: Tags
|
||||
ALBUM_MENU: Menu
|
||||
ALBUM_OPEN_INSPECTOR: Open Inspector
|
||||
ALBUM_INSPECTOR_TITLE: Album Inspector
|
||||
ALBUM_INSPECTOR_DESCRIPTION: Inspect and edit selected images.
|
||||
ALBUM_CLEAR_SELECTION: Clear Selection
|
||||
ALBUM_SELECTED_COUNT: ${count} selected
|
||||
ALBUM_GRID_VIEW: Grid View
|
||||
ALBUM_LIST_VIEW: List View
|
||||
ALBUM_PREVIEW: Fullscreen Preview
|
||||
ALBUM_PREVIEW_EMPTY: No images to preview
|
||||
ALBUM_PREVIEW_PREV: Previous
|
||||
ALBUM_PREVIEW_NEXT: Next
|
||||
ALBUM_PREVIEW_CLOSE: Close preview
|
||||
ALBUM_PREVIEW_COUNT: ${current} / ${total}
|
||||
ALBUM_QUICK_ACTIONS: Quick Actions
|
||||
ALBUM_EXPORT: Download
|
||||
ALBUM_URL: URL
|
||||
ALBUM_BATCH_REWRITE: Batch Rewrite
|
||||
ALBUM_COLLECTION: Collection
|
||||
ALBUM_ADD_TAG: Add tag
|
||||
ALBUM_TAG_SUGGESTIONS: Suggestions
|
||||
ALBUM_COLUMN_NAME: Name
|
||||
ALBUM_COLUMN_PROVIDER: Provider
|
||||
ALBUM_COLUMN_SIZE: Size
|
||||
ALBUM_COLUMN_DATE: Date
|
||||
ALBUM_ADD: Add
|
||||
ALBUM_SOURCE_LOCAL: Local
|
||||
ALBUM_SOURCE_CLOUD: Cloud
|
||||
ALBUM_CLOUD_LOAD_FAILED: Failed to load cloud album
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: Login Required
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: Sign in to a paid PicGo Cloud account to use cloud album
|
||||
ALBUM_CLOUD_LOGIN_BUTTON: Log In
|
||||
ALBUM_CLOUD_UPGRADE_TITLE: Upgrade Required
|
||||
ALBUM_CLOUD_UPGRADE_DESC: Cloud album requires a paid plan
|
||||
ALBUM_CLOUD_UPGRADE_BUTTON: Upgrade
|
||||
ALBUM_CLOUD_FEATURES_TITLE: Why Cloud Album?
|
||||
ALBUM_CLOUD_FEATURE_SYNC: Access your upload history across all devices
|
||||
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: Auto-import new uploads info (requires opt-in)
|
||||
ALBUM_CLOUD_FEATURE_SECURE: One-click import local history to cloud
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: Import Local Records
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_DESC: Import your local upload history to cloud album. Only records are imported, not the actual image files.
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: Start Import
|
||||
ALBUM_CLOUD_EMPTY_TITLE: No Items
|
||||
ALBUM_CLOUD_EMPTY_DESC: Your cloud album is empty. Upload an image to get started.
|
||||
ALBUM_CLOUD_IMPORTING: Importing to cloud album...
|
||||
ALBUM_CLOUD_IMPORT_SUCCESS: Successfully imported ${num} items to cloud album
|
||||
ALBUM_CLOUD_IMPORT_FAILED: Failed to import to cloud album
|
||||
ALBUM_CLOUD_REFRESH: Refresh
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: Enable Auto-Import
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: This will enable auto-import so that future uploads are automatically recorded in your cloud album. All existing local records will also be imported now.
|
||||
ALBUM_CLOUD_DELETE_FAILED: Failed to delete from cloud album
|
||||
ALBUM_INSPECTOR_DETAILS_TITLE: Details
|
||||
ALBUM_INSPECTOR_FILE_NAME: File name
|
||||
ALBUM_INSPECTOR_UPLOADER: Uploader
|
||||
ALBUM_INSPECTOR_CONTENT_TYPE: Content type
|
||||
ALBUM_INSPECTOR_CREATED_AT: Created
|
||||
ALBUM_INSPECTOR_UPDATED_AT: Updated
|
||||
ALBUM_INSPECTOR_FILE_SIZE: Size
|
||||
ALBUM_CLOUD_IMPORT_STATUS_TITLE: Cloud Album
|
||||
ALBUM_CLOUD_IMPORTED: Imported to cloud
|
||||
ALBUM_CLOUD_IMPORTED_TOOLTIP: This is a local marker. If the record was deleted from cloud, this status won't update. You can re-import if needed.
|
||||
ALBUM_CLOUD_REIMPORT: Re-import
|
||||
ALBUM_CLOUD_NATIVE: Uploaded via PicGo Cloud
|
||||
ALBUM_CLOUD_IMPORT_BUTTON: Import to Cloud Album
|
||||
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: Import ${num} item(s) to Cloud Album
|
||||
ALBUM_CLOUD_ALL_IMPORTED: All in cloud album
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: Enable Auto Import
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: Auto import needs to be enabled before importing items to cloud album. Enable it now and continue?
|
||||
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: Enable & Import
|
||||
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: Successfully imported to cloud album
|
||||
PICGO_CLOUD_AUTO_IMPORT_LABEL: Auto-import to Cloud Album
|
||||
PICGO_CLOUD_AUTO_IMPORT_DESC: Auto-import upload records from other uploaders to cloud album (PicGo Cloud is saved by default)
|
||||
SIDEBAR_PLUGINS: Plugins
|
||||
SIDEBAR_SYSTEM: System
|
||||
SIDEBAR_NOTIFICATIONS: Notifications
|
||||
SIDEBAR_EXPAND: Click to Expand
|
||||
SIDEBAR_COLLAPSE: Collapse Sidebar
|
||||
HISTORY_PANEL_TITLE: History
|
||||
HISTORY_PANEL_FILTER_PLACEHOLDER: Filter...
|
||||
HISTORY_PANEL_TODAY: Today
|
||||
HISTORY_PANEL_YESTERDAY: Yesterday
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS: No visible providers
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: All providers are hidden. Open settings to change provider visibility.
|
||||
DASHBOARD_OPEN_SETTINGS: Open Settings
|
||||
DASHBOARD_DROP_IMAGES_HERE: Drop files here
|
||||
DASHBOARD_OR: or
|
||||
DASHBOARD_CLICK_TO_UPLOAD: Click to Upload
|
||||
DASHBOARD_PASTE_FROM_CLIPBOARD: Paste from Clipboard
|
||||
DASHBOARD_PASTE_FROM_URL: Paste from URL
|
||||
DASHBOARD_CLIPBOARD: Clipboard
|
||||
FIELD_IS_REQUIRED: ${field} is required
|
||||
CONFIG_RENAME: Config Rename
|
||||
SETTINGS_SECTION_GENERAL: General
|
||||
SETTINGS_SECTION_APPEARANCE: Appearance
|
||||
SETTINGS_SECTION_UPLOAD_WORKFLOW: Upload Workflow
|
||||
SETTINGS_SECTION_NETWORK: Network
|
||||
SETTINGS_SECTION_ADVANCED: Advanced
|
||||
SETTINGS_SECTION_ABOUT: About
|
||||
SETTINGS_NO_RESULTS_TITLE: No settings found
|
||||
SETTINGS_NO_RESULTS_DESCRIPTION: Try another keyword or clear the search input.
|
||||
SETTINGS_OPEN_SHORTCUTS: Shortcuts
|
||||
SETTINGS_LINK_WEBSITE: Website
|
||||
SETTINGS_LINK_GITHUB: GitHub
|
||||
SETTINGS_LINK_DOCS: Documentation
|
||||
SETTINGS_LINK_PRIVACY: Privacy Policy
|
||||
SETTINGS_LINK_TERMS: Terms of Service
|
||||
SETTINGS_APPEARANCE_MODE: Appearance
|
||||
SETTINGS_APPEARANCE_MODE_LIGHT: Light
|
||||
SETTINGS_APPEARANCE_MODE_DARK: Dark
|
||||
SETTINGS_APPEARANCE_MODE_AUTO: Auto
|
||||
COMMON_FOR_EXAMPLE: "Ex.: ${value}"
|
||||
SETTINGS_SET_DEFAULT_CONFIG: Set as default
|
||||
PROVIDER_DRAFT_CONFIG: Draft
|
||||
PROVIDER_ACTIVE_UPLOADER_LABEL: Active
|
||||
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} is the active uploader
|
||||
PROVIDER_DEFAULT_CONFIG_LABEL: Default
|
||||
PROVIDER_DEFAULT_CONFIG_TOOLTIP: Used when ${uploaderName} is active.
|
||||
PROVIDER_SIDEBAR_EMPTY: No uploaders or configs found.
|
||||
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} actions
|
||||
PROVIDER_SIDEBAR_COLLAPSE: Collapse
|
||||
PROVIDER_SIDEBAR_EXPAND: Expand
|
||||
PROVIDER_CONFIG_ACTIONS: Config actions
|
||||
PROVIDER_CREATE_CONFIG: Create config
|
||||
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: This uploader has no configuration options, creating additional configs is unnecessary
|
||||
PROVIDER_INSTALL_MORE_UPLOADERS: Install more uploaders
|
||||
PROVIDER_NO_UPLOADER_SELECTED: No uploader selected.
|
||||
PROVIDER_NO_CONFIG_YET: No config yet
|
||||
PROVIDER_NO_CONFIG_DESCRIPTION: Create a new config for ${uploaderName} to start editing schema-based options.
|
||||
PROVIDER_CONFIGURATION: Configuration
|
||||
PROVIDER_UPDATED_AT_LABEL: Updated
|
||||
PROVIDER_DELETE_CONFIG_HINT: Permanently remove this configuration.
|
||||
UPLOADER_SWITCHER_SELECT_PROVIDER: Select provider
|
||||
UPLOADER_SWITCHER_CONFIG: Config
|
||||
UPLOADER_SWITCHER_NO_CONFIG: No config
|
||||
ALBUM_URL_REWRITE_DESCRIPTION: Batch rewrite the URLs for selected images.
|
||||
ALBUM_URL_REWRITE_CHANGED: Changed
|
||||
ALBUM_URL_REWRITE_UNCHANGED: Unchanged
|
||||
TRAY_ALREADY_UPLOAD_EMPTY: No uploaded images yet
|
||||
PLUGIN_EMPTY: No plugins found.
|
||||
PLUGIN_EMPTY_DESCRIPTION: Search npm and install plugins to get started.
|
||||
PLUGIN_DETAIL: Detail
|
||||
PLUGIN_NO_CONFIG_SCHEMA: This plugin does not expose config schema.
|
||||
PLUGIN_NO_TRANSFORMER_SCHEMA: This plugin does not expose transformer schema.
|
||||
PLUGIN_NO_README: No README available for this plugin.
|
||||
NO_OPTIONS_FOUND: No options found.
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
LANG_DISPLAY_LABEL: "日本語"
|
||||
ABOUT: 情報
|
||||
OPEN_MAIN_WINDOW: メインウィンドウを開く
|
||||
CHOOSE_DEFAULT_PICBED: デフォルトの画像ホスティングを選択
|
||||
OPEN_UPDATE_HELPER: アップデートヘルパーを開く
|
||||
PRIVACY_TERMS_AGREEMENT: プライバシーポリシーと利用規約への同意
|
||||
RELOAD_APP: アプリを再読み込み
|
||||
UPLOAD_FAILED: アップロード失敗
|
||||
UPLOAD_SUCCEED: アップロード成功
|
||||
UPLOAD_PROGRESS: アップロード進行状況
|
||||
OPERATION_FAILED: 操作失敗
|
||||
OPERATION_SUCCEED: 操作成功
|
||||
UPLOADING: アップロード中
|
||||
QUICK_UPLOAD: クイックアップロード
|
||||
UPLOAD_BY_CLIPBOARD: クリップボードからアップロード
|
||||
HIDE_WINDOW: ウィンドウを隠す
|
||||
SPONSOR_PICGO: PicGoを支援する
|
||||
SHOW_PICBED_QRCODE: 画像ホスティングQRコードを表示
|
||||
PICBED_QRCODE: 画像ホスティングQRコード
|
||||
ENABLE: 有効
|
||||
DISABLE: 無効
|
||||
CONFIG_THING: "${c}を設定"
|
||||
FIND_NEW_VERSION: 新しいバージョンを発見
|
||||
NO_MORE_NOTICE: 今後通知しない
|
||||
MORE: その他
|
||||
SHOW_DEVTOOLS: 開発者ツールを表示
|
||||
CURRENT_PICBED: 現在の画像ホスティング
|
||||
OPEN_TOOLBOX: ツールボックスを開く
|
||||
|
||||
# ---renderer i18n begin---
|
||||
|
||||
CHOOSE_YOUR_DEFAULT_PICBED: "${d}をデフォルトの画像ホスティングに設定:"
|
||||
SIDEBAR_DASHBOARD: ダッシュボード
|
||||
UPLOAD_AREA: アップロードエリア
|
||||
ALBUM: アルバム
|
||||
ALBUM_PROVIDERS: プロバイダー
|
||||
PICBEDS_SETTINGS: 画像ホスティング設定
|
||||
PICGO_SETTINGS: PicGo設定
|
||||
PLUGIN_SETTINGS: プラグイン設定
|
||||
DASHBOARD_HISTORY_PANEL_TITLE: 履歴パネル
|
||||
PICGO_CLOUD_TITLE: PicGo Cloud
|
||||
PICGO_CLOUD_DESCRIPTION: すべてのデバイスをつなぐPicGoのクラウドサービスです。
|
||||
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
|
||||
PICGO_CLOUD_ERROR_TITLE: PicGo Cloudエラー
|
||||
PICGO_CLOUD_LOADING: PicGo Cloudの状態を読み込み中...
|
||||
PICGO_CLOUD_NOT_LOGGED_IN: PicGo Cloudにログインしていません。
|
||||
PICGO_CLOUD_LOGIN: ログイン
|
||||
PICGO_CLOUD_LOGOUT: ログアウト
|
||||
PICGO_CLOUD_CANCEL_LOGIN: ログインをキャンセル
|
||||
PICGO_CLOUD_RETRY: リトライ
|
||||
PICGO_CLOUD_CONFIG_SYNC: 設定同期
|
||||
PICGO_CLOUD_LOGIN_IN_PROGRESS: ログイン処理中です。ブラウザでログインを完了してください。
|
||||
PICGO_CLOUD_LOGGED_IN_AS: "ログイン中: ${user}"
|
||||
PICGO_CLOUD_OPEN: PicGo Cloudを開く
|
||||
PICGO_CLOUD_LOGIN_TIMEOUT: ログインがタイムアウトしました。もう一度お試しください。
|
||||
PICGO_CLOUD_LOGIN_FAILED: ログインに失敗しました。もう一度お試しください。
|
||||
PICGO_CLOUD_AGREE_PREFIX: "PicGoの以下に同意します: "
|
||||
PICGO_CLOUD_TERMS_OF_SERVICE: 利用規約
|
||||
PICGO_CLOUD_AGREE_AND: "および"
|
||||
PICGO_CLOUD_PRIVACY_POLICY: プライバシーポリシー
|
||||
PICGO_CLOUD_LOGIN_EXPIRED: ログインが期限切れです。再度ログインしてください。
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 暗号化モード
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 自動
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: サーバー側暗号化
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_E2E: エンドツーエンド暗号化
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: "自動: クラウドで最後に使用した暗号化方式に従います(デフォルトはサーバー側暗号化)。"
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: "サーバー側暗号化: 設定はサーバーに保存される前に暗号化されます。PINは不要です。"
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: "エンドツーエンド暗号化: PINを使用してデータを暗号化/復号化します。PicGoはPINを保存しません。紛失した場合、データは復元できません。"
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: ドキュメントを読む
|
||||
PICGO_CLOUD_E2E_CHECKBOX_LABEL: E2E暗号化を有効にする
|
||||
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: E2E暗号化を有効にする
|
||||
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: "E2E暗号化を使用するにはPINを安全に保管する必要があります。PINを紛失すると、暗号化されたデータを復元できません。PicGoはPINや復旧データを保存しません。"
|
||||
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: リモート設定がE2E暗号化されています。ローカルでもE2Eが有効になりました。
|
||||
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: E2E暗号化PINの設定
|
||||
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: E2E暗号化PINを入力
|
||||
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PINが正しくありません。もう一度お試しください(${retryCount}回目)。"
|
||||
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
|
||||
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: PINを確認
|
||||
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 設定の同期に成功しました。
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 競合が検出されました。競合を解決してください。
|
||||
PICGO_CLOUD_CONFIG_SYNC_FAILED: 設定の同期に失敗しました。
|
||||
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 設定の同期がキャンセルされました。
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 暗号化方式を切り替えますか?
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "「${from}」から「${to}」に切り替えます。\n\n注意: 暗号化モードを切り替えると、クラウドの履歴バージョンがすべて削除されます。\nこれは古い履歴バージョンが新しいモードで復号化または検証できないためです。\n切り替え後、システムは新しいバックアップを開始点として即座に作成します。"
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 切り替えを確認して履歴を削除
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: キャンセル
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: ユーザーにより暗号化の切り替えがキャンセルされました
|
||||
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "設定同期失敗: ${reason}"
|
||||
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PINの入力回数が上限に達しました。もう一度お試しください。
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: ローカル設定が無効です。設定ファイルを確認してください。
|
||||
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 設定の同期が進行中です。
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 保留中の競合セッションがあります。先に競合を解決してください。
|
||||
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 競合セッションが見つかりません。
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: すべての競合項目についてローカルまたはクラウドを選択してください。
|
||||
PICGO_CLOUD_CONFIG_SYNC_STARTING: 設定の同期を開始しています...
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "競合が検出されました(${count}件)"
|
||||
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: すべてローカルを選択
|
||||
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: すべてクラウドを選択
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: すべてリセット
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: ローカルバージョン
|
||||
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: クラウドバージョン
|
||||
PICGO_CLOUD_CONFIG_SYNC_ABORT: 中止
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 確認して同期
|
||||
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 競合が解決されました。
|
||||
PICGO_CLOUD_LAST_SYNC_TIME: "最終ローカル同期日時: ${time}"
|
||||
PICGO_CLOUD_LAST_SYNC_TIME_NONE: なし
|
||||
PICGO_CLOUD_LAST_SYNC_LABEL: 最終同期
|
||||
PICGO_CLOUD_PLAN_USAGE_TITLE: プランと使用量
|
||||
PICGO_CLOUD_PLAN_USAGE_DESC: 現在のプランと使用量の概要を確認します。
|
||||
PICGO_CLOUD_PLAN_PERIOD_LABEL: プラン期間
|
||||
PICGO_CLOUD_STORAGE_LABEL: ストレージ
|
||||
PICGO_CLOUD_FILES_LABEL: ファイル
|
||||
PICGO_CLOUD_USAGE_PROGRESS: "${total}中${used}使用"
|
||||
PICGO_CLOUD_USAGE_UNLIMITED: 無制限
|
||||
PICGO_CLOUD_PLAN_PERIOD_RENEWS: "${date}に更新"
|
||||
PICGO_CLOUD_PLAN_PERIOD_CANCELS: "${date}に解約"
|
||||
PICGO_CLOUD_PLAN_PERIOD_UNTIL: "${date}まで有効"
|
||||
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 永久ライセンス
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 猶予期限
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: "有料プランが期限切れとなり、現在猶予期間中です。この日付まではPicGo Cloud画像にアクセスできますが、使用量は一時的に無料枠に引き下げられます。ほとんどの有料機能は一時的に利用できません。この日付以降、アカウントは凍結状態になります。"
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 凍結期限
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: "アカウントが凍結されています。PicGo Cloud画像はエラーを返します。この日付以降、データが削除される場合があります。アクセスを復元するには更新してください。"
|
||||
PICGO_CLOUD_QUOTA_DOWNGRADED: "猶予期間中、使用量は一時的に${plan}に引き下げられています。復元するには更新してください。"
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: プランが猶予期間中です
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: "有料プランが期限切れとなり、${days}日間の猶予期間に入りました。使用量は一時的に無料枠に引き下げられ、ほとんどの有料機能が一時的に利用できません。全機能を復元するには更新してください。"
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: アカウントが凍結されました
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: "アカウントが凍結されています。クラウド画像は一時的にアクセスできず、${days}日後にデータが削除されます。今すぐ更新してアクセスを復元してください。"
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: データ削除予定
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: アカウントは削除待ち状態であり、クラウドデータがまもなく完全に削除されます。データを保持するにはすぐに更新してください。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 今すぐ更新
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 閉じる
|
||||
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: プランが猶予期間または凍結期間中のため、自動インポートは一時停止されています。更新後に再開されます。
|
||||
PICGO_CLOUD_IMAGE_UNAVAILABLE: 画像を利用できません
|
||||
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 猶予期間中はこの操作を利用できません。プランを更新してからもう一度お試しください。
|
||||
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: アカウントが凍結されています。アクセスを復元するにはプランを更新してください。
|
||||
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 自動インポートが無効です。PicGo Cloud設定で先に有効にしてください。
|
||||
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 現在のプランではこの機能をサポートしていません。アップグレードしてからもう一度お試しください。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: プランの使用量上限に達しました。上位プランにアップグレードしてください。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 猶予期間中は使用量が無料枠に引き下げられます。復元するには更新してください。
|
||||
PICGO_CLOUD_ERROR_PLAN_REQUIRED: この機能には有料プランが必要です。
|
||||
PICGO_CLOUD_USAGE_LOAD_FAILED: 使用量データの読み込みに失敗しました。
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 同期状態の読み込みに失敗しました。
|
||||
PICGO_CLOUD_FREE_PLAN_BANNER: 無料プランをご利用中です。アップグレードすると、より多くの容量と高度なクラウド機能を利用できます。
|
||||
PICGO_CLOUD_VIEW_PLANS: プランを見る
|
||||
PICGO_CLOUD_CONFIG_SYNC_TITLE: 設定同期
|
||||
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: デバイス間で設定を安全に同期します。
|
||||
PICGO_CLOUD_SYNC_NOW: 今すぐ同期
|
||||
PICGO_CLOUD_SYNC_QUOTA_LABEL: 同期クォータ
|
||||
PICGO_CLOUD_SYNC_QUOTA_TIP: "プランでは最新の${limit}個の設定スナップショットが保持されます。${limit}個中${limit}個を使用していても同期は引き続き可能です。上限を超えた古いスナップショットは同期のたびに自動的に削除されます。"
|
||||
PICGO_CLOUD_LOGIN_PANEL_TITLE: PicGo Cloudにログイン
|
||||
PICGO_CLOUD_LOGIN_PANEL_DESC: PicGo Cloudを公式画像ホスティングとして使用し、クラウド機能を有効にするにはログインしてください。
|
||||
PICGO_CLOUD_LOGIN_FEATURES_TITLE: クラウド機能
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 公式画像ホスティング
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: アルバムクラウド同期が組み込まれたPicGoの公式画像ホスティングです。
|
||||
PICGO_CLOUD_PAID_PLAN_BADGE: 有料プラン
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 再起動が必要ですか?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 一部の設定は再起動が必要な場合があります。今すぐ再起動しますか?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 今すぐ再起動
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: あとで
|
||||
INPUT_BOX_CONFIRM_MISMATCH: 2つの入力値が一致しません。
|
||||
PICGO_SPONSOR_TEXT: PicGoはフリーソフトウェアです。気に入っていただけたら、コーヒー一杯の支援をお忘れなく。
|
||||
ALIPAY: Alipay
|
||||
WECHATPAY: WeChat Pay
|
||||
CHOOSE_PICBED: 画像ホスティングを選択
|
||||
COPY_PICBED_CONFIG: 画像ホスティング設定をコピー
|
||||
COPY_PICBED_CONFIG_SUCCEED: 画像ホスティング設定のコピーに成功
|
||||
INPUT: 入力
|
||||
CANCEL: キャンセル
|
||||
CONFIRM: 確認
|
||||
CHOOSE_SHOWED_PICBED: 表示する画像ホスティングを選択
|
||||
CHOOSE_PASTE_FORMAT: 貼り付け形式を選択
|
||||
SEARCH: 検索
|
||||
COPY: コピー
|
||||
DELETE: 削除
|
||||
SELECT_ALL: すべて選択
|
||||
CHANGE_IMAGE_URL: 画像URLを変更
|
||||
CHANGE_IMAGE_URL_SUCCEED: 画像URLの変更に成功
|
||||
COPY_LINK_SUCCEED: リンクのコピーに成功
|
||||
BATCH_COPY_LINK_SUCCEED: リンクの一括コピーに成功
|
||||
FILE_RENAME: ファイル名を変更
|
||||
COPY_FILE_PATH: ファイルパスをコピー
|
||||
OPEN_FILE_PATH: ファイルパスを開く
|
||||
SUCCESS: 成功
|
||||
FAILED: 失敗
|
||||
|
||||
# settings
|
||||
|
||||
SETTINGS: 設定
|
||||
SETTINGS_OPEN_CONFIG_FILE: 設定ファイルを開く
|
||||
SETTINGS_CLICK_TO_OPEN: クリックして開く
|
||||
SETTINGS_SET_LOG_FILE: ログファイルの設定
|
||||
SETTINGS_CLICK_TO_SET: クリックして設定
|
||||
SETTINGS_CLICK_TO_CHECK: クリックして確認
|
||||
SETTINGS_SET_SHORTCUT: ショートカットの設定
|
||||
SETTINGS_URL_REWRITE: URLリライト
|
||||
SETTINGS_CUSTOM_LINK_FORMAT: カスタムリンク形式
|
||||
SETTINGS_SET_PROXY_AND_MIRROR: プロキシとミラーの設定
|
||||
SETTINGS_SET_SERVER: サーバーの設定
|
||||
SETTINGS_CHECK_UPDATE: アップデートを確認
|
||||
SETTINGS_UPDATE_CHECK_RESULT: "最新バージョンは${version}です。${action}"
|
||||
SETTINGS_UPDATE_CHECK_NO_UPDATE: 更新の必要はありません
|
||||
SETTINGS_UPDATE_CHECK_CAN_UPDATE: アップデート可能
|
||||
SETTINGS_OPEN_UPDATE_HELPER: アップデートヘルパーを開く
|
||||
SETTINGS_OPEN: 開く
|
||||
SETTINGS_CLOSE: 閉じる
|
||||
SETTINGS_ACCEPT_BETA_UPDATE: ベータ版アップデートを許可
|
||||
SETTINGS_LAUNCH_ON_BOOT: 起動時に自動実行
|
||||
SETTINGS_RENAME_BEFORE_UPLOAD: アップロード前にリネーム
|
||||
SETTINGS_TIMESTAMP_RENAME: タイムスタンプでリネーム
|
||||
SETTINGS_OPEN_UPLOAD_TIPS: アップロードのヒントを表示
|
||||
SETTINGS_NOTIFICATION_SOUND: 通知音を再生
|
||||
SETTINGS_MINI_WINDOW_ON_TOP: ミニウィンドウを常に最前面に
|
||||
SETTINGS_AUTO_COPY_URL_AFTER_UPLOAD: アップロード後にURLを自動コピー
|
||||
SETTINGS_TIPS_PLACEHOLDER_URL: $urlでURL位置を表します
|
||||
SETTINGS_TIPS_PLACEHOLDER_FILENAME: $fileNameでファイル名の位置を表します
|
||||
SETTINGS_TIPS_PLACEHOLDER_EXTNAME: $extNameでファイル拡張子の位置を表します
|
||||
SETTINGS_TIPS_SUCH_AS: "例: $url/$fileName"
|
||||
SETTINGS_UPLOAD_PROXY: アップロードプロキシ
|
||||
SETTINGS_PLUGIN_INSTALL_PROXY: プラグインインストール用プロキシ
|
||||
SETTINGS_PLUGIN_INSTALL_MIRROR: プラグインインストール用ミラー
|
||||
SETTINGS_CURRENT_VERSION: 現在のバージョン
|
||||
SETTINGS_NEWEST_VERSION: 最新バージョン
|
||||
SETTINGS_GETING: 取得中...
|
||||
SETTINGS_TIPS_HAS_NEW_VERSION: PicGoに新しいバージョンがあります。確認をクリックしてダウンロードページを開いてください
|
||||
SETTINGS_LOG_FILE: ログファイル
|
||||
SETTINGS_LOG_LEVEL: ログレベル
|
||||
SETTINGS_LOG_FILE_SIZE: ログファイルサイズ
|
||||
SETTINGS_SET_PICGO_SERVER: PicGoサーバーの設定
|
||||
SETTINGS_TIPS_SERVER_NOTICE: サーバー機能がわからない場合は、ドキュメントを参照するか、設定を変更しないでください。
|
||||
SETTINGS_ENABLE_SERVER: サーバーを有効にする
|
||||
SETTINGS_SET_SERVER_HOST: サーバーホストを設定
|
||||
SETTINGS_SET_SERVER_PORT: サーバーポートを設定
|
||||
SETTINGS_TIP_PLACEHOLDER_HOST: デフォルト:127.0.0.1
|
||||
SETTINGS_TIP_PLACEHOLDER_PORT: デフォルト:36677
|
||||
SETTINGS_LOG_LEVEL_ALL: すべて
|
||||
SETTINGS_LOG_LEVEL_SUCCESS: 成功
|
||||
SETTINGS_LOG_LEVEL_ERROR: エラー
|
||||
SETTINGS_LOG_LEVEL_INFO: 情報
|
||||
SETTINGS_LOG_LEVEL_WARN: 警告
|
||||
SETTINGS_LOG_LEVEL_NONE: なし
|
||||
SETTINGS_RESULT: 結果
|
||||
SETTINGS_DEFAULT_PICBED: デフォルトの画像ホスティング
|
||||
SETTINGS_SET_DEFAULT_PICBED: デフォルトのアップローダーを設定
|
||||
SETTINGS_NOT_CONFIG_OPTIONS: 設定項目なし
|
||||
SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 内蔵クリップボードを使用してアップロード
|
||||
SETTINGS_CHOOSE_LANGUAGE: 言語を選択
|
||||
UPLOADER_CONFIG_NAME: 設定名
|
||||
BUILTIN_CLIPBOARD_TIPS: スクリプトの代わりに内蔵クリップボード機能を使用してアップロード
|
||||
|
||||
# url rewrite
|
||||
|
||||
URL_REWRITE_HELP: アップロードされた画像URLをリライトします。ルールは順番に評価され、最初にマッチしたルールが適用されます。
|
||||
URL_REWRITE_ADD_RULE: ルールを追加
|
||||
URL_REWRITE_EDIT_RULE: ルールを編集
|
||||
URL_REWRITE_EMPTY: ルールなし
|
||||
URL_REWRITE_ORDER: 順序
|
||||
URL_REWRITE_MATCH: マッチパターン
|
||||
URL_REWRITE_REPLACE: 置換文字列
|
||||
URL_REWRITE_FLAGS: フラグ
|
||||
URL_REWRITE_ENABLED: 有効
|
||||
URL_REWRITE_ACTIONS: 操作
|
||||
URL_REWRITE_MOVE_UP: 上へ
|
||||
URL_REWRITE_MOVE_DOWN: 下へ
|
||||
URL_REWRITE_EDIT: 編集
|
||||
URL_REWRITE_DELETE: 削除
|
||||
URL_REWRITE_DELETE_CONFIRM: このルールを削除しますか?
|
||||
URL_REWRITE_MATCH_TIPS: 正規表現(JavaScript RegExp)に対応しています
|
||||
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
|
||||
URL_REWRITE_REPLACE_TIPS: 置換文字列($1, $2, ... に対応)
|
||||
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
|
||||
URL_REWRITE_OPTIONS: オプション
|
||||
URL_REWRITE_RULE_ENABLED: このルールを有効にする
|
||||
URL_REWRITE_FLAG_GLOBAL_LABEL: グローバル (g)
|
||||
URL_REWRITE_FLAG_GLOBAL_DESC: 最初のマッチだけでなく、すべてのマッチを置換します
|
||||
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 大文字小文字を区別しない (i)
|
||||
URL_REWRITE_FLAG_IGNORE_CASE_DESC: "大文字小文字を区別せずにマッチします(例: JPGとjpgを同一視)"
|
||||
URL_REWRITE_MATCH_REQUIRED: マッチパターンは必須です
|
||||
URL_REWRITE_REPLACE_REQUIRED: 置換文字列は必須です
|
||||
URL_REWRITE_INVALID_REGEX: 無効な正規表現です
|
||||
URL_REWRITE_PREVIEW_TITLE: プレビュー
|
||||
URL_REWRITE_PREVIEW_TIPS: URLを入力すると、現在のルールがどのようにリライトするかを確認できます(順番にマッチし、最初のマッチのみ適用されます)
|
||||
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
|
||||
URL_REWRITE_PREVIEW_RUN: プレビュー
|
||||
URL_REWRITE_PREVIEW_OUTPUT: 出力URL
|
||||
URL_REWRITE_PREVIEW_INPUT_REQUIRED: プレビューするURLを入力してください
|
||||
URL_REWRITE_PREVIEW_RULE_INVALID: 無効なルールです
|
||||
URL_REWRITE_PREVIEW_MATCHED_RULE: マッチしたルール
|
||||
URL_REWRITE_PREVIEW_NO_MATCH: マッチするルールなし
|
||||
UPLOADER_CONFIG_PLACEHOLDER: 設定名を入力してください
|
||||
SELECTED_SETTING_HINT: 選択済み
|
||||
SETTINGS_ENCODE_OUTPUT_URL: 出力(またはコピーした)URLをエンコード
|
||||
SETTINGS_SHOW_DOCK_ICON: Dockアイコンを表示
|
||||
SETTINGS_SHOW_MENUBAR_ICON: メニューバーアイコンを表示
|
||||
SETTINGS_SHOW_MENUBAR_ICON_TIPS: "「Dockアイコンを表示」と「メニューバーアイコンを表示」の両方がオフの場合、PicGoのメインウィンドウが見つからなくなります。設定ファイルを編集してshowDockIconまたはshowMenubarIconをtrueに設定すると復元できます。"
|
||||
SETTINGS_STARTUP_MODE: 起動モード
|
||||
SETTINGS_STARTUP_MODE_MAIN_WINDOW: メインウィンドウを開く
|
||||
SETTINGS_STARTUP_MODE_MINI_WINDOW: ミニウィンドウを開く
|
||||
SETTINGS_STARTUP_MODE_HIDE: サイレント起動
|
||||
|
||||
# shortcut-page
|
||||
|
||||
SHORTCUT_NAME: ショートカット名
|
||||
SHORTCUT_BIND: ショートカットバインド
|
||||
SHORTCUT_STATUS: ステータス
|
||||
SHORTCUT_ENABLED: 有効
|
||||
SHORTCUT_DISABLED: 無効
|
||||
SHORTCUT_SOURCE: ソース
|
||||
SHORTCUT_HANDLE: ハンドル
|
||||
SHORTCUT_ENABLE: 有効にする
|
||||
SHORTCUT_DISABLE: 無効にする
|
||||
SHORTCUT_EDIT: 編集
|
||||
SHORTCUT_CHANGE_UPLOAD: アップロードショートカットを変更
|
||||
|
||||
# album-page
|
||||
ALBUM_URL_REWRITE_TITLE: 選択した画像URLをリライト
|
||||
ALBUM_URL_REWRITE_RESULT_TITLE: 画像URLリライト結果
|
||||
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 最低1枚の画像を選択してください
|
||||
|
||||
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: グローバルURLリライトルールを適用
|
||||
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: グローバルルール
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 任意項目です。一時ルールをスキップするには空のままにしてください。一時ルールはグローバルルールより優先されます。
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 一時ルールにはマッチパターンと置換文字列の両方が必要です
|
||||
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 有効なグローバルルールがなく、一時ルールも入力されていません
|
||||
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 一時ルールをグローバルURLリライトルールに保存しますか?
|
||||
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 適用して保存
|
||||
ALBUM_URL_REWRITE_APPLY_ONLY: 適用のみ
|
||||
ALBUM_URL_REWRITE_NO_CHANGES: 変更されたURLはありません
|
||||
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: リライト結果が空のためスキップしました
|
||||
|
||||
# tray-page
|
||||
|
||||
WAIT_TO_UPLOAD: アップロード待ち
|
||||
ALREADY_UPLOAD: アップロード済み
|
||||
|
||||
# upload-page
|
||||
|
||||
PICTURE_UPLOAD: 画像アップロード
|
||||
DRAG_FILE_TO_HERE: ファイルをここにドラッグ、または
|
||||
CLICK_TO_UPLOAD: クリックしてアップロード
|
||||
LINK_FORMAT: リンク形式
|
||||
CUSTOM: カスタム
|
||||
CLIPBOARD_PICTURE: クリップボード
|
||||
TIPS_DRAG_VALID_PICTURE_OR_URL: 有効な画像またはURLをここにドラッグしてください
|
||||
TIPS_INPUT_URL: URLを入力
|
||||
TIPS_HTTP_PREFIX: http://またはhttps://で始めてください。複数URL対応(1行に1つ)
|
||||
TIPS_INPUT_VALID_URL: 有効なURLを入力してください
|
||||
|
||||
# plugins
|
||||
|
||||
PLUGIN_SEARCH_PLACEHOLDER: npmでpicgoプラグインを検索するか、ボタンをクリックしておすすめプラグイン一覧を確認してください
|
||||
PLUGIN_SEARCH_EXACT_MATCH: プラグイン名の完全一致
|
||||
PLUGIN_INSTALL: インストール
|
||||
PLUGIN_INSTALLING: インストール中...
|
||||
PLUGIN_INSTALLED: インストール済み
|
||||
PLUGIN_DEPRECATED_BADGE: 非推奨
|
||||
PLUGIN_DEPRECATED_TITLE: このプラグインは作者によりサポートが終了しています
|
||||
PLUGIN_DOING_SOMETHING: 処理中...
|
||||
PLUGIN_LIST: プラグイン一覧
|
||||
PLUGIN_IMPORT_LOCAL: ローカルプラグインをインポート
|
||||
|
||||
# tips
|
||||
|
||||
TIPS_REMOVE_LINK: この操作で画像がアルバムから削除されます。続行しますか?
|
||||
TIPS_WILL_REMOVE_CHOOSED_IMAGES: この操作で画像がアルバムから削除されます。続行しますか?
|
||||
TIPS_MUST_CONTAINS_URL: $url、$fileName、$extNameのいずれかを含める必要があります
|
||||
TIPS_NETWORK_ERROR: ネットワークエラー
|
||||
TIPS_NEED_RELOAD: アプリの再読み込みが必要です
|
||||
TIPS_PLEASE_CHOOSE_LOG_LEVEL: ログレベルを選択してください
|
||||
TIPS_SET_SUCCEED: 設定成功
|
||||
TIPS_PLUGIN_NOT_GUI_IMPLEMENT: このプラグインはGUIに最適化されていません。続行しますか?
|
||||
TIPS_CLICK_NOTIFICATION_TO_RELOAD: 通知をクリックしてアプリを再読み込み
|
||||
TIPS_GET_PLUGIN_LIST_FAILED: プラグイン一覧の取得に失敗しました
|
||||
|
||||
# ---renderer i18n end---
|
||||
|
||||
# plugins
|
||||
PLUGIN_INSTALL_SUCCEED: プラグインのインストールに成功
|
||||
PLUGIN_INSTALL_FAILED: プラグインのインストールに失敗
|
||||
PLUGIN_UNINSTALL_SUCCEED: プラグインのアンインストールに成功
|
||||
PLUGIN_UNINSTALL_FAILED: プラグインのアンインストールに失敗
|
||||
PLUGIN_UPDATE_SUCCEED: プラグインのアップデートに成功
|
||||
PLUGIN_UPDATE_FAILED: プラグインのアップデートに失敗
|
||||
PLUGIN_IMPORT_SUCCEED: プラグインのインポートに成功
|
||||
PLUGIN_IMPORT_FAILED: プラグインのインポートに失敗
|
||||
ENABLE_PLUGIN: プラグインを有効にする
|
||||
DISABLE_PLUGIN: プラグインを無効にする
|
||||
UNINSTALL_PLUGIN: プラグインをアンインストール
|
||||
UPDATE_PLUGIN: プラグインをアップデート
|
||||
|
||||
# toolbox
|
||||
TOOLBOX: ツールボックス
|
||||
TOOLBOX_TITLE: PicGoの実行問題を診断
|
||||
TOOLBOX_SUB_TITLE: 以下の項目を即座に検査して使用上の問題を修正します
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 設定ファイルの破損を確認
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: アルバムファイルの破損を確認
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: クリップボード画像アップロードの問題を確認
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: プロキシ設定の正常性を確認
|
||||
TOOLBOX_FIX_DONE_NEED_RELOAD: 修復が完了しました。適用するには再起動が必要です。再起動しますか?
|
||||
TOOLBOX_CANT_AUTO_FIX: 自動修復できません。以下の問題を手動で修正してください
|
||||
TOOLBOX_START_SCAN: スキャン開始
|
||||
TOOLBOX_RE_SCAN: 再スキャン
|
||||
TOOLBOX_START_FIX: 修復開始
|
||||
TOOLBOX_SUCCESS_TIPS: おめでとうございます。問題は見つかりませんでした
|
||||
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: "設定ファイルのパス: ${path}"
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 設定ファイルが破損しています
|
||||
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: "アルバムファイルのパス: ${path}"
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: アルバムファイルが破損しています
|
||||
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: プロキシ設定正常
|
||||
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: プロキシ設定なし
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: プロキシ設定が正しくありません
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_WORKING: プロキシ設定が利用できません
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_TIPS: "クリップボード画像の一時フォルダのパス: ${path}"
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_NOT_EXIST_TIPS: "クリップボード画像の一時フォルダが存在しません: ${path}"
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: "次のフォルダを手動で作成してください: ${path}"
|
||||
|
||||
# tips
|
||||
TIPS_NOTICE: お知らせ
|
||||
TIPS_WARNING: 警告
|
||||
TIPS_ERROR: エラー
|
||||
TIPS_SKIPPED_INVALID_URLS: "無効なURL${n}件をスキップしました。詳細はログを確認してください"
|
||||
TIPS_TOO_MANY_URLS_CONFIRM: "一度にURL${n}件をアップロードしようとしています。遅延が発生する可能性があるため、分割してアップロードすることをお勧めします。続行しますか?"
|
||||
TIPS_NO_VALID_URLS: 有効なURLが見つかりません
|
||||
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Node.jsをインストールしてPicGoを再起動してください
|
||||
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: プラグインがアルバムから一部の画像を削除しようとしています。続行しますか?
|
||||
TIPS_PLUGIN_OVERWRITE_ALBUM: プラグインがアルバムを上書きしようとしています。続行しますか?
|
||||
TIPS_UPLOAD_NOT_PICTURES: 最新のクリップボード項目は画像ではありません
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo設定ファイルが破損したため、デフォルトに復元されました
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo設定ファイルが破損したため、バックアップから復元されました
|
||||
TIPS_PICGO_BACKUP_FILE_VERSION: "バックアップファイルのバージョン: ${v}"
|
||||
TIPS_CUSTOM_CONFIG_FILE_PATH_ERROR: カスタム設定ファイルの解析エラーです。パスの内容を確認してください
|
||||
TIPS_SHORTCUT_MODIFIED_SUCCEED: ショートカットの変更に成功しました
|
||||
TIPS_SHORTCUT_MODIFIED_CONFLICT: ショートカットが競合しています。再設定してください
|
||||
TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: カスタムリンクスタイルの変更に成功しました
|
||||
TIPS_FIND_NEW_VERSION: "新しいバージョン${v}が見つかりました。多くの新機能が追加されています。最新バージョンをダウンロードしますか?"
|
||||
TIPS_DELETE_UPLOADER_CONFIG: この設定を削除しますか?
|
||||
TIPS_COPY_UPLOADER_CONFIG: この設定をコピーしますか?
|
||||
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 設定名を空にすることはできません
|
||||
TIPS_UPLOADER_CONFIG_NOT_FOUND: 設定が見つかりません
|
||||
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 最後の設定は削除できません
|
||||
|
||||
# privacy
|
||||
PRIVACY: "ご利用の前にプライバシーポリシー${privacyUrl}と利用規約${termsUrl}をお読みいただき、同意してください。"
|
||||
PRIVACY_TIPS: アップロードするにはプライバシーポリシーに同意してください
|
||||
QUIT: 終了
|
||||
ALBUM_ALL_PHOTOS: すべての写真
|
||||
ALBUM_COLLECTIONS: コレクション
|
||||
ALBUM_TAGS: タグ
|
||||
ALBUM_MENU: メニュー
|
||||
ALBUM_OPEN_INSPECTOR: インスペクターを開く
|
||||
ALBUM_INSPECTOR_TITLE: アルバムインスペクター
|
||||
ALBUM_INSPECTOR_DESCRIPTION: 選択した画像を検査・編集します。
|
||||
ALBUM_CLEAR_SELECTION: 選択を解除
|
||||
ALBUM_SELECTED_COUNT: "${count}件選択中"
|
||||
ALBUM_GRID_VIEW: グリッド表示
|
||||
ALBUM_LIST_VIEW: リスト表示
|
||||
ALBUM_PREVIEW: フルスクリーンプレビュー
|
||||
ALBUM_PREVIEW_EMPTY: プレビューする画像がありません
|
||||
ALBUM_PREVIEW_PREV: 前へ
|
||||
ALBUM_PREVIEW_NEXT: 次へ
|
||||
ALBUM_PREVIEW_CLOSE: プレビューを閉じる
|
||||
ALBUM_PREVIEW_COUNT: "${current} / ${total}"
|
||||
ALBUM_QUICK_ACTIONS: クイック操作
|
||||
ALBUM_EXPORT: ダウンロード
|
||||
ALBUM_URL: URL
|
||||
ALBUM_BATCH_REWRITE: 一括リライト
|
||||
ALBUM_COLLECTION: コレクション
|
||||
ALBUM_ADD_TAG: タグを追加
|
||||
ALBUM_TAG_SUGGESTIONS: おすすめタグ
|
||||
ALBUM_COLUMN_NAME: 名前
|
||||
ALBUM_COLUMN_PROVIDER: プロバイダー
|
||||
ALBUM_COLUMN_SIZE: サイズ
|
||||
ALBUM_COLUMN_DATE: 日付
|
||||
ALBUM_ADD: 追加
|
||||
ALBUM_SOURCE_LOCAL: ローカル
|
||||
ALBUM_SOURCE_CLOUD: クラウド
|
||||
ALBUM_CLOUD_LOAD_FAILED: クラウドアルバムの読み込みに失敗しました
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: ログインが必要です
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: クラウドアルバムを使用するには有料のPicGo Cloudアカウントでログインしてください
|
||||
ALBUM_CLOUD_LOGIN_BUTTON: ログイン
|
||||
ALBUM_CLOUD_UPGRADE_TITLE: アップグレードが必要です
|
||||
ALBUM_CLOUD_UPGRADE_DESC: クラウドアルバムには有料プランが必要です
|
||||
ALBUM_CLOUD_UPGRADE_BUTTON: アップグレード
|
||||
ALBUM_CLOUD_FEATURES_TITLE: クラウドアルバムを使う理由
|
||||
ALBUM_CLOUD_FEATURE_SYNC: すべてのデバイスからアップロード履歴にアクセス
|
||||
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 新しいアップロード情報を自動インポート(オプトインが必要)
|
||||
ALBUM_CLOUD_FEATURE_SECURE: ローカル履歴をワンクリックでクラウドにインポート
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: ローカル記録をインポート
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_DESC: ローカルのアップロード履歴をクラウドアルバムにインポートします。実際の画像ファイルではなく、記録のみがインポートされます。
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: インポート開始
|
||||
ALBUM_CLOUD_EMPTY_TITLE: 項目なし
|
||||
ALBUM_CLOUD_EMPTY_DESC: クラウドアルバムは空です。画像をアップロードして始めましょう。
|
||||
ALBUM_CLOUD_IMPORTING: クラウドアルバムにインポート中...
|
||||
ALBUM_CLOUD_IMPORT_SUCCESS: "${num}件をクラウドアルバムにインポートしました"
|
||||
ALBUM_CLOUD_IMPORT_FAILED: クラウドアルバムへのインポートに失敗しました
|
||||
ALBUM_CLOUD_REFRESH: 更新
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 自動インポートを有効にする
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: この操作を実行すると自動インポートが有効になり、以降のアップロードがクラウドアルバムに自動記録されます。既存のローカル記録も同時にインポートされます。
|
||||
ALBUM_CLOUD_DELETE_FAILED: クラウドアルバムからの削除に失敗しました
|
||||
ALBUM_INSPECTOR_DETAILS_TITLE: 詳細情報
|
||||
ALBUM_INSPECTOR_FILE_NAME: ファイル名
|
||||
ALBUM_INSPECTOR_UPLOADER: アップローダー
|
||||
ALBUM_INSPECTOR_CONTENT_TYPE: コンテンツタイプ
|
||||
ALBUM_INSPECTOR_CREATED_AT: 作成日
|
||||
ALBUM_INSPECTOR_UPDATED_AT: 更新日
|
||||
ALBUM_INSPECTOR_FILE_SIZE: サイズ
|
||||
ALBUM_CLOUD_IMPORT_STATUS_TITLE: クラウドアルバム
|
||||
ALBUM_CLOUD_IMPORTED: クラウドにインポート済み
|
||||
ALBUM_CLOUD_IMPORTED_TOOLTIP: これはローカルマーカーです。クラウドから記録が削除されても、このステータスは更新されません。必要に応じて再インポートできます。
|
||||
ALBUM_CLOUD_REIMPORT: 再インポート
|
||||
ALBUM_CLOUD_NATIVE: PicGo Cloud経由でアップロード
|
||||
ALBUM_CLOUD_IMPORT_BUTTON: クラウドアルバムにインポート
|
||||
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: "${num}件をクラウドアルバムにインポート"
|
||||
ALBUM_CLOUD_ALL_IMPORTED: すべてクラウドアルバムに登録済み
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 自動インポートを有効にする
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: クラウドアルバムにインポートするには、まず自動インポートを有効にする必要があります。今すぐ有効にして続行しますか?
|
||||
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 有効にしてインポート
|
||||
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: クラウドアルバムへのインポートに成功しました
|
||||
PICGO_CLOUD_AUTO_IMPORT_LABEL: クラウドアルバムへ自動インポート
|
||||
PICGO_CLOUD_AUTO_IMPORT_DESC: 他のアップローダーのアップロード記録をクラウドアルバムに自動インポートします(PicGo Cloudはデフォルトで保存されます)
|
||||
SIDEBAR_PLUGINS: プラグイン
|
||||
SIDEBAR_SYSTEM: システム
|
||||
SIDEBAR_NOTIFICATIONS: 通知
|
||||
SIDEBAR_EXPAND: クリックして展開
|
||||
SIDEBAR_COLLAPSE: サイドバーを折りたたむ
|
||||
HISTORY_PANEL_TITLE: 履歴
|
||||
HISTORY_PANEL_FILTER_PLACEHOLDER: フィルター...
|
||||
HISTORY_PANEL_TODAY: 今日
|
||||
HISTORY_PANEL_YESTERDAY: 昨日
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS: 表示するプロバイダーがありません
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: すべてのプロバイダーが非表示になっています。設定でプロバイダーの表示を変更してください。
|
||||
DASHBOARD_OPEN_SETTINGS: 設定を開く
|
||||
DASHBOARD_DROP_IMAGES_HERE: ファイルをここにドロップ
|
||||
DASHBOARD_OR: または
|
||||
DASHBOARD_CLICK_TO_UPLOAD: クリックしてアップロード
|
||||
DASHBOARD_PASTE_FROM_CLIPBOARD: クリップボードから貼り付け
|
||||
DASHBOARD_PASTE_FROM_URL: URLから貼り付け
|
||||
DASHBOARD_CLIPBOARD: クリップボード
|
||||
FIELD_IS_REQUIRED: ${field}は必須です
|
||||
CONFIG_RENAME: 設定名を変更
|
||||
SETTINGS_SECTION_GENERAL: 一般
|
||||
SETTINGS_SECTION_APPEARANCE: 外観
|
||||
SETTINGS_SECTION_UPLOAD_WORKFLOW: アップロードワークフロー
|
||||
SETTINGS_SECTION_NETWORK: ネットワーク
|
||||
SETTINGS_SECTION_ADVANCED: 詳細設定
|
||||
SETTINGS_SECTION_ABOUT: 情報
|
||||
SETTINGS_NO_RESULTS_TITLE: 設定が見つかりません
|
||||
SETTINGS_NO_RESULTS_DESCRIPTION: 別のキーワードを試すか、検索入力をクリアしてください。
|
||||
SETTINGS_OPEN_SHORTCUTS: ショートカット
|
||||
SETTINGS_LINK_WEBSITE: Webサイト
|
||||
SETTINGS_LINK_GITHUB: GitHub
|
||||
SETTINGS_LINK_DOCS: ドキュメント
|
||||
SETTINGS_LINK_PRIVACY: プライバシーポリシー
|
||||
SETTINGS_LINK_TERMS: 利用規約
|
||||
SETTINGS_APPEARANCE_MODE: 外観
|
||||
SETTINGS_APPEARANCE_MODE_LIGHT: ライト
|
||||
SETTINGS_APPEARANCE_MODE_DARK: ダーク
|
||||
SETTINGS_APPEARANCE_MODE_AUTO: 自動
|
||||
COMMON_FOR_EXAMPLE: "例: ${value}"
|
||||
SETTINGS_SET_DEFAULT_CONFIG: デフォルトに設定
|
||||
PROVIDER_DRAFT_CONFIG: 下書き
|
||||
PROVIDER_ACTIVE_UPLOADER_LABEL: アクティブ
|
||||
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName}がアクティブなアップローダーです
|
||||
PROVIDER_DEFAULT_CONFIG_LABEL: デフォルト
|
||||
PROVIDER_DEFAULT_CONFIG_TOOLTIP: ${uploaderName}がアクティブな場合に使用されます。
|
||||
PROVIDER_SIDEBAR_EMPTY: アップローダーまたは設定が見つかりません。
|
||||
PROVIDER_UPLOADER_ACTIONS: ${uploaderName}の操作
|
||||
PROVIDER_SIDEBAR_COLLAPSE: 折りたたむ
|
||||
PROVIDER_SIDEBAR_EXPAND: 展開
|
||||
PROVIDER_CONFIG_ACTIONS: 設定の操作
|
||||
PROVIDER_CREATE_CONFIG: 設定を作成
|
||||
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: このアップローダーには設定項目がないため、追加の設定を作成する必要はありません
|
||||
PROVIDER_INSTALL_MORE_UPLOADERS: アップローダーを追加インストール
|
||||
PROVIDER_NO_UPLOADER_SELECTED: アップローダーが選択されていません。
|
||||
PROVIDER_NO_CONFIG_YET: 設定はまだありません
|
||||
PROVIDER_NO_CONFIG_DESCRIPTION: ${uploaderName}のスキーマベースのオプションを編集するには、新しい設定を作成してください。
|
||||
PROVIDER_CONFIGURATION: 設定
|
||||
PROVIDER_UPDATED_AT_LABEL: 更新日
|
||||
PROVIDER_DELETE_CONFIG_HINT: この設定を完全に削除します。
|
||||
UPLOADER_SWITCHER_SELECT_PROVIDER: プロバイダーを選択
|
||||
UPLOADER_SWITCHER_CONFIG: 設定
|
||||
UPLOADER_SWITCHER_NO_CONFIG: 設定なし
|
||||
ALBUM_URL_REWRITE_DESCRIPTION: 選択した画像のURLを一括リライトします。
|
||||
ALBUM_URL_REWRITE_CHANGED: 変更あり
|
||||
ALBUM_URL_REWRITE_UNCHANGED: 変更なし
|
||||
TRAY_ALREADY_UPLOAD_EMPTY: アップロードされた画像はまだありません
|
||||
PLUGIN_EMPTY: プラグインが見つかりません。
|
||||
PLUGIN_EMPTY_DESCRIPTION: npmを検索してプラグインをインストールして始めましょう。
|
||||
PLUGIN_DETAIL: 詳細
|
||||
PLUGIN_NO_CONFIG_SCHEMA: このプラグインは設定スキーマを提供していません。
|
||||
PLUGIN_NO_TRANSFORMER_SCHEMA: このプラグインはトランスフォーマースキーマを提供していません。
|
||||
PLUGIN_NO_README: このプラグインのREADMEはありません。
|
||||
NO_OPTIONS_FOUND: オプションが見つかりません。
|
||||
@@ -0,0 +1,593 @@
|
||||
LANG_DISPLAY_LABEL: "한국어"
|
||||
ABOUT: 정보
|
||||
OPEN_MAIN_WINDOW: 메인 창 열기
|
||||
CHOOSE_DEFAULT_PICBED: 기본 이미지 호스팅 선택
|
||||
OPEN_UPDATE_HELPER: 업데이트 도우미 열기
|
||||
PRIVACY_TERMS_AGREEMENT: 개인정보 처리방침 및 이용약관 동의
|
||||
RELOAD_APP: 앱 새로고침
|
||||
UPLOAD_FAILED: 업로드 실패
|
||||
UPLOAD_SUCCEED: 업로드 성공
|
||||
UPLOAD_PROGRESS: 업로드 진행률
|
||||
OPERATION_FAILED: 작업 실패
|
||||
OPERATION_SUCCEED: 작업 성공
|
||||
UPLOADING: 업로드 중
|
||||
QUICK_UPLOAD: 빠른 업로드
|
||||
UPLOAD_BY_CLIPBOARD: 클립보드로 업로드
|
||||
HIDE_WINDOW: 창 숨기기
|
||||
SPONSOR_PICGO: PicGo 후원하기
|
||||
SHOW_PICBED_QRCODE: 이미지 호스팅 QR 코드 표시
|
||||
PICBED_QRCODE: 이미지 호스팅 QR 코드
|
||||
ENABLE: 사용
|
||||
DISABLE: 사용 안 함
|
||||
CONFIG_THING: "${c} 설정"
|
||||
FIND_NEW_VERSION: 새 버전 찾기
|
||||
NO_MORE_NOTICE: 다시 알리지 않기
|
||||
MORE: 더 보기
|
||||
SHOW_DEVTOOLS: 개발자 도구 표시
|
||||
CURRENT_PICBED: 현재 이미지 호스팅
|
||||
OPEN_TOOLBOX: 도구 상자 열기
|
||||
|
||||
# ---renderer i18n begin---
|
||||
|
||||
CHOOSE_YOUR_DEFAULT_PICBED: "${d}을(를) 기본 이미지 호스팅으로 선택:"
|
||||
SIDEBAR_DASHBOARD: 대시보드
|
||||
UPLOAD_AREA: 업로드 영역
|
||||
ALBUM: 앨범
|
||||
ALBUM_PROVIDERS: 제공자
|
||||
PICBEDS_SETTINGS: 이미지 호스팅 설정
|
||||
PICGO_SETTINGS: PicGo 설정
|
||||
PLUGIN_SETTINGS: 플러그인 설정
|
||||
DASHBOARD_HISTORY_PANEL_TITLE: 기록 패널
|
||||
PICGO_CLOUD_TITLE: PicGo Cloud
|
||||
PICGO_CLOUD_DESCRIPTION: 모든 기기를 연결하는 PicGo의 클라우드 서비스입니다.
|
||||
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
|
||||
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 오류
|
||||
PICGO_CLOUD_LOADING: PicGo Cloud 상태를 불러오는 중...
|
||||
PICGO_CLOUD_NOT_LOGGED_IN: PicGo Cloud에 로그인되어 있지 않습니다.
|
||||
PICGO_CLOUD_LOGIN: 로그인
|
||||
PICGO_CLOUD_LOGOUT: 로그아웃
|
||||
PICGO_CLOUD_CANCEL_LOGIN: 로그인 취소
|
||||
PICGO_CLOUD_RETRY: 재시도
|
||||
PICGO_CLOUD_CONFIG_SYNC: 설정 동기화
|
||||
PICGO_CLOUD_LOGIN_IN_PROGRESS: 로그인이 진행 중입니다. 브라우저에서 로그인을 완료해 주세요.
|
||||
PICGO_CLOUD_LOGGED_IN_AS: "로그인 계정: ${user}"
|
||||
PICGO_CLOUD_OPEN: PicGo Cloud 열기
|
||||
PICGO_CLOUD_LOGIN_TIMEOUT: 로그인 시간이 초과되었습니다. 다시 시도해 주세요.
|
||||
PICGO_CLOUD_LOGIN_FAILED: 로그인에 실패했습니다. 다시 시도해 주세요.
|
||||
PICGO_CLOUD_AGREE_PREFIX: "PicGo의 "
|
||||
PICGO_CLOUD_TERMS_OF_SERVICE: 이용약관
|
||||
PICGO_CLOUD_AGREE_AND: " 및 "
|
||||
PICGO_CLOUD_PRIVACY_POLICY: 개인정보 처리방침
|
||||
PICGO_CLOUD_LOGIN_EXPIRED: 로그인이 만료되었습니다. 다시 로그인해 주세요.
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 암호화 모드
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 자동
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: 서버 측 암호화
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_E2E: 종단간 암호화
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: "자동: 클라우드에서 마지막으로 사용한 암호화 방식을 따릅니다(기본값은 서버 측 암호화)."
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: "서버 측 암호화: 설정이 서버에 저장되기 전에 암호화됩니다. PIN이 필요하지 않습니다."
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: "종단간 암호화: PIN을 사용해 데이터를 암호화/복호화합니다. PicGo는 PIN을 저장하지 않으며, 분실 시 데이터를 복구할 수 없습니다."
|
||||
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: 전체 문서 보기
|
||||
PICGO_CLOUD_E2E_CHECKBOX_LABEL: 종단간 암호화 사용
|
||||
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: 종단간 암호화 사용
|
||||
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: "종단간 암호화를 사용하려면 PIN을 안전하게 보관해야 합니다. PIN을 분실하면 암호화된 데이터를 복구할 수 없습니다. PicGo는 PIN이나 복구 데이터를 저장하지 않습니다."
|
||||
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: 원격 설정이 종단간 암호화되어 있습니다. 로컬에서도 종단간 암호화가 활성화되었습니다.
|
||||
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: 종단간 암호화 PIN 설정
|
||||
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: 종단간 암호화 PIN 입력
|
||||
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PIN이 올바르지 않습니다. 다시 시도해 주세요 (시도 ${retryCount}회)."
|
||||
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
|
||||
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: PIN 확인
|
||||
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 설정 동기화에 성공했습니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 충돌이 발견되었습니다. 충돌을 해결해 주세요.
|
||||
PICGO_CLOUD_CONFIG_SYNC_FAILED: 설정 동기화에 실패했습니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 설정 동기화가 취소되었습니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 암호화 방식을 전환하시겠습니까?
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "\"${from}\"에서 \"${to}\"(으)로 전환합니다.\n\n참고: 암호화 모드를 전환하면 클라우드 기록 버전이 모두 삭제됩니다.\n이는 이전 기록 버전을 새 모드에서 복호화하거나 검증할 수 없기 때문입니다.\n전환 후에는 시스템이 즉시 새로운 백업을 시작점으로 생성합니다."
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 전환 확인 및 기록 삭제
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: 취소
|
||||
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: 사용자가 암호화 전환을 취소했습니다
|
||||
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "설정 동기화 실패: ${reason}"
|
||||
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PIN을 너무 많이 잘못 입력했습니다. 다시 시도해 주세요.
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: 로컬 설정이 유효하지 않습니다. 설정 파일을 확인해 주세요.
|
||||
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 설정 동기화가 진행 중입니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 대기 중인 충돌 세션이 있습니다. 먼저 충돌을 해결해 주세요.
|
||||
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 충돌 세션을 찾을 수 없습니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: 모든 충돌 항목에 대해 로컬 또는 클라우드를 선택해 주세요.
|
||||
PICGO_CLOUD_CONFIG_SYNC_STARTING: 설정 동기화를 시작합니다...
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "충돌 발견 (${count}개)"
|
||||
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: 모두 로컬 선택
|
||||
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: 모두 클라우드 선택
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: 전체 초기화
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: 로컬 버전
|
||||
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: 클라우드 버전
|
||||
PICGO_CLOUD_CONFIG_SYNC_ABORT: 중단
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 확인 및 동기화
|
||||
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (비어 있음)
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 충돌이 해결되었습니다.
|
||||
PICGO_CLOUD_LAST_SYNC_TIME: "마지막 로컬 동기화 시간: ${time}"
|
||||
PICGO_CLOUD_LAST_SYNC_TIME_NONE: 없음
|
||||
PICGO_CLOUD_LAST_SYNC_LABEL: 마지막 동기화
|
||||
PICGO_CLOUD_PLAN_USAGE_TITLE: 요금제 및 사용량
|
||||
PICGO_CLOUD_PLAN_USAGE_DESC: 현재 요금제와 사용량 개요를 확인하세요.
|
||||
PICGO_CLOUD_PLAN_PERIOD_LABEL: 요금제 기간
|
||||
PICGO_CLOUD_STORAGE_LABEL: 저장 공간
|
||||
PICGO_CLOUD_FILES_LABEL: 파일
|
||||
PICGO_CLOUD_USAGE_PROGRESS: "${total} 중 ${used}"
|
||||
PICGO_CLOUD_USAGE_UNLIMITED: 무제한
|
||||
PICGO_CLOUD_PLAN_PERIOD_RENEWS: "${date}에 갱신"
|
||||
PICGO_CLOUD_PLAN_PERIOD_CANCELS: "${date}에 해지"
|
||||
PICGO_CLOUD_PLAN_PERIOD_UNTIL: "${date}까지 유효"
|
||||
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 평생 이용
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 유예 기간 종료일
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: "유료 요금제가 만료되어 현재 유예 기간입니다. 이 날짜까지는 PicGo Cloud 이미지에 계속 접근할 수 있지만, 할당량은 일시적으로 무료 등급으로 낮아집니다. 대부분의 유료 기능은 일시적으로 사용할 수 없습니다. 이 날짜 이후에는 계정이 정지 상태가 됩니다."
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 정지 종료일
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: "계정이 정지되었습니다. PicGo Cloud 이미지는 오류를 반환합니다. 이 날짜 이후에는 데이터가 삭제될 수 있습니다. 접근 권한을 복구하려면 갱신해 주세요."
|
||||
PICGO_CLOUD_QUOTA_DOWNGRADED: "유예 기간 동안 할당량이 일시적으로 ${plan}(으)로 낮아졌습니다. 복구하려면 갱신해 주세요."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: 요금제가 유예 기간입니다
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: "유료 요금제가 만료되어 ${days}일간의 유예 기간에 들어갔습니다. 할당량은 일시적으로 무료 등급으로 낮아지고, 대부분의 유료 기능을 사용할 수 없습니다. 전체 기능을 복구하려면 갱신해 주세요."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: 계정이 정지되었습니다
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: "계정이 정지되어 클라우드 이미지에 일시적으로 접근할 수 없으며, ${days}일 후 데이터가 삭제됩니다. 지금 갱신하면 접근 권한을 복구할 수 있습니다."
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: 데이터 삭제 예정
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: 계정이 삭제 대기 상태이며 클라우드 데이터가 곧 영구적으로 삭제됩니다. 데이터를 유지하려면 즉시 갱신해 주세요.
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 지금 갱신
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 닫기
|
||||
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: 요금제가 유예 또는 정지 상태인 동안에는 자동 가져오기가 일시 중지됩니다. 갱신 후 다시 시작됩니다.
|
||||
PICGO_CLOUD_IMAGE_UNAVAILABLE: 이미지를 사용할 수 없음
|
||||
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 유예 기간 동안에는 이 작업을 사용할 수 없습니다. 요금제를 갱신한 후 다시 시도해 주세요.
|
||||
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: 계정이 정지되었습니다. 접근 권한을 복구하려면 요금제를 갱신해 주세요.
|
||||
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 자동 가져오기가 비활성화되어 있습니다. PicGo Cloud 설정에서 먼저 활성화해 주세요.
|
||||
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 현재 요금제에서는 이 기능을 지원하지 않습니다. 업그레이드 후 다시 시도해 주세요.
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: 요금제 할당량에 도달했습니다. 더 많은 용량을 사용하려면 상위 요금제로 업그레이드하세요.
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 유예 기간 동안에는 할당량이 무료 등급으로 낮아집니다. 복구하려면 갱신해 주세요.
|
||||
PICGO_CLOUD_ERROR_PLAN_REQUIRED: 이 기능은 유료 요금제가 필요합니다.
|
||||
PICGO_CLOUD_USAGE_LOAD_FAILED: 사용량 데이터를 불러오지 못했습니다.
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 동기화 상태를 불러오지 못했습니다.
|
||||
PICGO_CLOUD_FREE_PLAN_BANNER: 무료 요금제를 사용 중입니다. 업그레이드하면 더 많은 할당량과 고급 클라우드 기능을 이용할 수 있습니다.
|
||||
PICGO_CLOUD_VIEW_PLANS: 요금제 보기
|
||||
PICGO_CLOUD_CONFIG_SYNC_TITLE: 설정 동기화
|
||||
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: 기기 간 설정을 안전하게 동기화하세요.
|
||||
PICGO_CLOUD_SYNC_NOW: 지금 동기화
|
||||
PICGO_CLOUD_SYNC_QUOTA_LABEL: 동기화 할당량
|
||||
PICGO_CLOUD_SYNC_QUOTA_TIP: "요금제는 최근 ${limit}개의 설정 스냅샷을 보관합니다. ${limit}개 중 ${limit}개를 모두 사용해도 동기화는 계속 가능합니다 — 한도를 초과한 오래된 스냅샷은 매번 동기화 후 자동으로 정리됩니다."
|
||||
PICGO_CLOUD_LOGIN_PANEL_TITLE: PicGo Cloud에 로그인
|
||||
PICGO_CLOUD_LOGIN_PANEL_DESC: PicGo Cloud를 PicGo의 공식 이미지 호스팅으로 사용하고 클라우드 기능을 활성화하려면 로그인하세요.
|
||||
PICGO_CLOUD_LOGIN_FEATURES_TITLE: 클라우드 기능
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 공식 이미지 호스팅
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: 앨범 클라우드 동기화가 내장된 PicGo의 공식 이미지 호스팅입니다.
|
||||
PICGO_CLOUD_PAID_PLAN_BADGE: 유료 요금제
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 재시작이 필요한가요?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 일부 설정은 적용하려면 재시작이 필요할 수 있습니다. 지금 재시작하시겠습니까?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 지금 재시작
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: 나중에
|
||||
INPUT_BOX_CONFIRM_MISMATCH: 두 입력값이 일치하지 않습니다.
|
||||
PICGO_SPONSOR_TEXT: PicGo는 무료 소프트웨어입니다. 마음에 드신다면 커피 한 잔 후원하는 것을 잊지 말아 주세요.
|
||||
ALIPAY: 알리페이
|
||||
WECHATPAY: 위챗페이
|
||||
CHOOSE_PICBED: 이미지 호스팅 선택
|
||||
COPY_PICBED_CONFIG: 이미지 호스팅 설정 복사
|
||||
COPY_PICBED_CONFIG_SUCCEED: 이미지 호스팅 설정 복사 성공
|
||||
INPUT: 입력
|
||||
CANCEL: 취소
|
||||
CONFIRM: 확인
|
||||
CHOOSE_SHOWED_PICBED: 표시할 이미지 호스팅 선택
|
||||
CHOOSE_PASTE_FORMAT: 붙여넣기 형식 선택
|
||||
SEARCH: 검색
|
||||
COPY: 복사
|
||||
DELETE: 삭제
|
||||
SELECT_ALL: 전체 선택
|
||||
CHANGE_IMAGE_URL: 이미지 URL 변경
|
||||
CHANGE_IMAGE_URL_SUCCEED: 이미지 URL 변경 성공
|
||||
COPY_LINK_SUCCEED: 링크 복사 성공
|
||||
BATCH_COPY_LINK_SUCCEED: 링크 일괄 복사 성공
|
||||
FILE_RENAME: 파일 이름 변경
|
||||
COPY_FILE_PATH: 파일 경로 복사
|
||||
OPEN_FILE_PATH: 파일 경로 열기
|
||||
SUCCESS: 성공
|
||||
FAILED: 실패
|
||||
|
||||
# settings
|
||||
|
||||
SETTINGS: 설정
|
||||
SETTINGS_OPEN_CONFIG_FILE: 설정 파일 열기
|
||||
SETTINGS_CLICK_TO_OPEN: 클릭하여 열기
|
||||
SETTINGS_SET_LOG_FILE: 로그 파일 설정
|
||||
SETTINGS_CLICK_TO_SET: 클릭하여 설정
|
||||
SETTINGS_CLICK_TO_CHECK: 클릭하여 확인
|
||||
SETTINGS_SET_SHORTCUT: 단축키 설정
|
||||
SETTINGS_URL_REWRITE: URL 재작성
|
||||
SETTINGS_CUSTOM_LINK_FORMAT: 사용자 지정 링크 형식
|
||||
SETTINGS_SET_PROXY_AND_MIRROR: 프록시 및 미러 설정
|
||||
SETTINGS_SET_SERVER: 서버 설정
|
||||
SETTINGS_CHECK_UPDATE: 업데이트 확인
|
||||
SETTINGS_UPDATE_CHECK_RESULT: "최신 버전은 ${version}이며, ${action}"
|
||||
SETTINGS_UPDATE_CHECK_NO_UPDATE: 업데이트가 필요하지 않습니다
|
||||
SETTINGS_UPDATE_CHECK_CAN_UPDATE: 업데이트 가능
|
||||
SETTINGS_OPEN_UPDATE_HELPER: 업데이트 도우미 열기
|
||||
SETTINGS_OPEN: 열기
|
||||
SETTINGS_CLOSE: 닫기
|
||||
SETTINGS_ACCEPT_BETA_UPDATE: 베타 업데이트 허용
|
||||
SETTINGS_LAUNCH_ON_BOOT: 부팅 시 자동 실행
|
||||
SETTINGS_RENAME_BEFORE_UPLOAD: 업로드 전 이름 변경
|
||||
SETTINGS_TIMESTAMP_RENAME: 타임스탬프로 이름 변경
|
||||
SETTINGS_OPEN_UPLOAD_TIPS: 업로드 팁 표시
|
||||
SETTINGS_NOTIFICATION_SOUND: 알림 소리 재생
|
||||
SETTINGS_MINI_WINDOW_ON_TOP: 미니 창 항상 위
|
||||
SETTINGS_AUTO_COPY_URL_AFTER_UPLOAD: 업로드 후 URL 자동 복사
|
||||
SETTINGS_TIPS_PLACEHOLDER_URL: $url를 사용하여 URL 위치를 표시
|
||||
SETTINGS_TIPS_PLACEHOLDER_FILENAME: $fileName을 사용하여 파일 이름 위치를 표시
|
||||
SETTINGS_TIPS_PLACEHOLDER_EXTNAME: $extName을 사용하여 파일 확장자 위치를 표시
|
||||
SETTINGS_TIPS_SUCH_AS: "예: $url/$fileName"
|
||||
SETTINGS_UPLOAD_PROXY: 업로드 프록시
|
||||
SETTINGS_PLUGIN_INSTALL_PROXY: 플러그인 설치 프록시
|
||||
SETTINGS_PLUGIN_INSTALL_MIRROR: 플러그인 설치 미러
|
||||
SETTINGS_CURRENT_VERSION: 현재 버전
|
||||
SETTINGS_NEWEST_VERSION: 최신 버전
|
||||
SETTINGS_GETING: 가져오는 중...
|
||||
SETTINGS_TIPS_HAS_NEW_VERSION: PicGo에 새 버전이 있습니다. 확인을 클릭하여 다운로드 페이지를 여세요
|
||||
SETTINGS_LOG_FILE: 로그 파일
|
||||
SETTINGS_LOG_LEVEL: 로그 수준
|
||||
SETTINGS_LOG_FILE_SIZE: 로그 파일 크기
|
||||
SETTINGS_SET_PICGO_SERVER: PicGo 서버 설정
|
||||
SETTINGS_TIPS_SERVER_NOTICE: 서버 기능이 무엇인지 모른다면 문서를 참고하거나 설정을 변경하지 마세요.
|
||||
SETTINGS_ENABLE_SERVER: 서버 사용
|
||||
SETTINGS_SET_SERVER_HOST: 서버 호스트 설정
|
||||
SETTINGS_SET_SERVER_PORT: 서버 포트 설정
|
||||
SETTINGS_TIP_PLACEHOLDER_HOST: 기본값:127.0.0.1
|
||||
SETTINGS_TIP_PLACEHOLDER_PORT: 기본값:36677
|
||||
SETTINGS_LOG_LEVEL_ALL: 전체
|
||||
SETTINGS_LOG_LEVEL_SUCCESS: 성공
|
||||
SETTINGS_LOG_LEVEL_ERROR: 오류
|
||||
SETTINGS_LOG_LEVEL_INFO: 정보
|
||||
SETTINGS_LOG_LEVEL_WARN: 경고
|
||||
SETTINGS_LOG_LEVEL_NONE: 없음
|
||||
SETTINGS_RESULT: 결과
|
||||
SETTINGS_DEFAULT_PICBED: 기본 이미지 호스팅
|
||||
SETTINGS_SET_DEFAULT_PICBED: 기본 업로더 설정
|
||||
SETTINGS_NOT_CONFIG_OPTIONS: 설정 옵션 없음
|
||||
SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 내장 클립보드로 업로드 사용
|
||||
SETTINGS_CHOOSE_LANGUAGE: 언어 선택
|
||||
UPLOADER_CONFIG_NAME: 설정 이름
|
||||
BUILTIN_CLIPBOARD_TIPS: 스크립트 대신 내장 클립보드 기능을 사용하여 업로드
|
||||
|
||||
# url rewrite
|
||||
|
||||
URL_REWRITE_HELP: 업로드된 이미지 URL을 재작성합니다. 규칙은 순서대로 평가되며, 처음 일치한 규칙이 적용됩니다.
|
||||
URL_REWRITE_ADD_RULE: 규칙 추가
|
||||
URL_REWRITE_EDIT_RULE: 규칙 편집
|
||||
URL_REWRITE_EMPTY: 규칙 없음
|
||||
URL_REWRITE_ORDER: 순서
|
||||
URL_REWRITE_MATCH: 일치 패턴
|
||||
URL_REWRITE_REPLACE: 대체 문자열
|
||||
URL_REWRITE_FLAGS: 플래그
|
||||
URL_REWRITE_ENABLED: 사용함
|
||||
URL_REWRITE_ACTIONS: 작업
|
||||
URL_REWRITE_MOVE_UP: 위로
|
||||
URL_REWRITE_MOVE_DOWN: 아래로
|
||||
URL_REWRITE_EDIT: 편집
|
||||
URL_REWRITE_DELETE: 삭제
|
||||
URL_REWRITE_DELETE_CONFIRM: 이 규칙을 삭제하시겠습니까?
|
||||
URL_REWRITE_MATCH_TIPS: 정규식(JavaScript RegExp)을 지원합니다
|
||||
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
|
||||
URL_REWRITE_REPLACE_TIPS: 대체 문자열($1, $2, ... 지원)
|
||||
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
|
||||
URL_REWRITE_OPTIONS: 옵션
|
||||
URL_REWRITE_RULE_ENABLED: 이 규칙 사용
|
||||
URL_REWRITE_FLAG_GLOBAL_LABEL: 전역(g)
|
||||
URL_REWRITE_FLAG_GLOBAL_DESC: 첫 번째 항목만이 아니라 일치하는 모든 항목을 대체합니다
|
||||
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 대소문자 무시(i)
|
||||
URL_REWRITE_FLAG_IGNORE_CASE_DESC: "대소문자를 구분하지 않고 일치시킵니다(예: JPG와 jpg를 동일하게 취급)"
|
||||
URL_REWRITE_MATCH_REQUIRED: 일치 패턴은 필수입니다
|
||||
URL_REWRITE_REPLACE_REQUIRED: 대체 문자열은 필수입니다
|
||||
URL_REWRITE_INVALID_REGEX: 잘못된 정규식입니다
|
||||
URL_REWRITE_PREVIEW_TITLE: 미리보기
|
||||
URL_REWRITE_PREVIEW_TIPS: URL을 입력하면 현재 규칙이 어떻게 재작성하는지 확인할 수 있습니다(순서대로 일치하며, 첫 번째 일치 항목만 적용됩니다)
|
||||
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
|
||||
URL_REWRITE_PREVIEW_RUN: 미리보기
|
||||
URL_REWRITE_PREVIEW_OUTPUT: 출력 URL
|
||||
URL_REWRITE_PREVIEW_INPUT_REQUIRED: 미리보기할 URL을 입력해 주세요
|
||||
URL_REWRITE_PREVIEW_RULE_INVALID: 잘못된 규칙입니다
|
||||
URL_REWRITE_PREVIEW_MATCHED_RULE: 일치한 규칙
|
||||
URL_REWRITE_PREVIEW_NO_MATCH: 일치하는 규칙 없음
|
||||
UPLOADER_CONFIG_PLACEHOLDER: 설정 이름을 입력해 주세요
|
||||
SELECTED_SETTING_HINT: 선택됨
|
||||
SETTINGS_ENCODE_OUTPUT_URL: 출력(또는 복사한) URL 인코딩
|
||||
SETTINGS_SHOW_DOCK_ICON: Dock 아이콘 표시
|
||||
SETTINGS_SHOW_MENUBAR_ICON: 메뉴바 아이콘 표시
|
||||
SETTINGS_SHOW_MENUBAR_ICON_TIPS: "\"Dock 아이콘 표시\"와 \"메뉴바 아이콘 표시\"가 모두 꺼져 있으면 PicGo의 메인 창을 찾을 수 없게 됩니다. 설정 파일을 편집하여 showDockIcon 또는 showMenubarIcon을 true로 설정하면 복구할 수 있습니다."
|
||||
SETTINGS_STARTUP_MODE: 시작 모드
|
||||
SETTINGS_STARTUP_MODE_MAIN_WINDOW: 메인 창 열기
|
||||
SETTINGS_STARTUP_MODE_MINI_WINDOW: 미니 창 열기
|
||||
SETTINGS_STARTUP_MODE_HIDE: 조용히 시작
|
||||
|
||||
# shortcut-page
|
||||
|
||||
SHORTCUT_NAME: 단축키 이름
|
||||
SHORTCUT_BIND: 단축키 바인딩
|
||||
SHORTCUT_STATUS: 상태
|
||||
SHORTCUT_ENABLED: 사용함
|
||||
SHORTCUT_DISABLED: 사용 안 함
|
||||
SHORTCUT_SOURCE: 소스
|
||||
SHORTCUT_HANDLE: 처리
|
||||
SHORTCUT_ENABLE: 사용
|
||||
SHORTCUT_DISABLE: 사용 안 함
|
||||
SHORTCUT_EDIT: 편집
|
||||
SHORTCUT_CHANGE_UPLOAD: 업로드 단축키 변경
|
||||
|
||||
# album-page
|
||||
ALBUM_URL_REWRITE_TITLE: 선택한 이미지 URL 재작성
|
||||
ALBUM_URL_REWRITE_RESULT_TITLE: 이미지 URL 재작성 결과
|
||||
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 먼저 사진을 하나 이상 선택해야 합니다
|
||||
|
||||
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: 전역 URL 재작성 규칙 적용
|
||||
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: 전역 규칙
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 선택 사항입니다. 임시 규칙을 건너뛰려면 비워 두세요. 임시 규칙은 전역 규칙보다 우선순위가 높습니다.
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 임시 규칙에는 일치 패턴과 대체 문자열이 모두 필요합니다
|
||||
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 사용 중인 전역 규칙이 없고 임시 규칙도 입력되지 않았습니다
|
||||
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 임시 규칙을 전역 URL 재작성 규칙에 저장하시겠습니까?
|
||||
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 적용 및 저장
|
||||
ALBUM_URL_REWRITE_APPLY_ONLY: 적용만
|
||||
ALBUM_URL_REWRITE_NO_CHANGES: 변경된 URL이 없습니다
|
||||
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: 재작성 결과가 비어 있어 건너뛰었습니다
|
||||
|
||||
# tray-page
|
||||
|
||||
WAIT_TO_UPLOAD: 업로드 대기 중
|
||||
ALREADY_UPLOAD: 업로드 완료
|
||||
|
||||
# upload-page
|
||||
|
||||
PICTURE_UPLOAD: 사진 업로드
|
||||
DRAG_FILE_TO_HERE: 파일을 여기로 드래그하거나
|
||||
CLICK_TO_UPLOAD: 클릭하여 업로드
|
||||
LINK_FORMAT: 링크 형식
|
||||
CUSTOM: 사용자 지정
|
||||
CLIPBOARD_PICTURE: 클립보드
|
||||
TIPS_DRAG_VALID_PICTURE_OR_URL: 유효한 사진 또는 URL을 여기로 드래그하세요
|
||||
TIPS_INPUT_URL: URL 입력
|
||||
TIPS_HTTP_PREFIX: http:// 또는 https://로 시작해야 합니다. 여러 URL 지원(줄당 하나씩)
|
||||
TIPS_INPUT_VALID_URL: 유효한 URL을 입력하세요
|
||||
|
||||
# plugins
|
||||
|
||||
PLUGIN_SEARCH_PLACEHOLDER: npm에서 picgo 플러그인을 검색하거나, 버튼을 클릭하여 추천 플러그인 목록을 확인하세요
|
||||
PLUGIN_SEARCH_EXACT_MATCH: 플러그인 이름 정확히 일치
|
||||
PLUGIN_INSTALL: 설치
|
||||
PLUGIN_INSTALLING: 설치 중...
|
||||
PLUGIN_INSTALLED: 설치됨
|
||||
PLUGIN_DEPRECATED_BADGE: 지원 종료
|
||||
PLUGIN_DEPRECATED_TITLE: 이 플러그인은 제작자가 지원을 종료했습니다
|
||||
PLUGIN_DOING_SOMETHING: 처리 중...
|
||||
PLUGIN_LIST: 플러그인 목록
|
||||
PLUGIN_IMPORT_LOCAL: 로컬 플러그인 가져오기
|
||||
|
||||
# tips
|
||||
|
||||
TIPS_REMOVE_LINK: 이 작업은 사진을 앨범에서 제거합니다. 계속하시겠습니까?
|
||||
TIPS_WILL_REMOVE_CHOOSED_IMAGES: 이 작업은 사진을 앨범에서 제거합니다. 계속하시겠습니까?
|
||||
TIPS_MUST_CONTAINS_URL: $url, $fileName, $extName 중 하나를 포함해야 합니다
|
||||
TIPS_NETWORK_ERROR: 네트워크 오류
|
||||
TIPS_NEED_RELOAD: 앱을 새로고침해야 합니다
|
||||
TIPS_PLEASE_CHOOSE_LOG_LEVEL: 로그 수준을 선택해 주세요
|
||||
TIPS_SET_SUCCEED: 설정 성공
|
||||
TIPS_PLUGIN_NOT_GUI_IMPLEMENT: 이 플러그인은 GUI에 최적화되어 있지 않습니다. 계속하시겠습니까?
|
||||
TIPS_CLICK_NOTIFICATION_TO_RELOAD: 알림을 클릭하여 앱을 새로고침하세요
|
||||
TIPS_GET_PLUGIN_LIST_FAILED: 플러그인 목록을 가져오지 못했습니다
|
||||
|
||||
# ---renderer i18n end---
|
||||
|
||||
# plugins
|
||||
PLUGIN_INSTALL_SUCCEED: 플러그인 설치 성공
|
||||
PLUGIN_INSTALL_FAILED: 플러그인 설치 실패
|
||||
PLUGIN_UNINSTALL_SUCCEED: 플러그인 제거 성공
|
||||
PLUGIN_UNINSTALL_FAILED: 플러그인 제거 실패
|
||||
PLUGIN_UPDATE_SUCCEED: 플러그인 업데이트 성공
|
||||
PLUGIN_UPDATE_FAILED: 플러그인 업데이트 실패
|
||||
PLUGIN_IMPORT_SUCCEED: 플러그인 가져오기 성공
|
||||
PLUGIN_IMPORT_FAILED: 플러그인 가져오기 실패
|
||||
ENABLE_PLUGIN: 플러그인 사용
|
||||
DISABLE_PLUGIN: 플러그인 사용 안 함
|
||||
UNINSTALL_PLUGIN: 플러그인 제거
|
||||
UPDATE_PLUGIN: 플러그인 업데이트
|
||||
|
||||
# toolbox
|
||||
TOOLBOX: 도구 상자
|
||||
TOOLBOX_TITLE: PicGo 실행 문제 진단
|
||||
TOOLBOX_SUB_TITLE: 다음 항목을 즉시 검사하여 사용 문제를 해결하세요
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 설정 파일 손상 여부 확인
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: 앨범 파일 손상 여부 확인
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: 클립보드 사진 업로드 문제 확인
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: 프록시 설정 정상 여부 확인
|
||||
TOOLBOX_FIX_DONE_NEED_RELOAD: 수리가 완료되었습니다. 적용하려면 재시작이 필요합니다. 재시작하시겠습니까?
|
||||
TOOLBOX_CANT_AUTO_FIX: 자동으로 수리할 수 없습니다. 아래 문제를 직접 수리해 주세요
|
||||
TOOLBOX_START_SCAN: 검사 시작
|
||||
TOOLBOX_RE_SCAN: 다시 검사
|
||||
TOOLBOX_START_FIX: 수리 시작
|
||||
TOOLBOX_SUCCESS_TIPS: 축하합니다, 문제가 발견되지 않았습니다
|
||||
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: "설정 파일 경로: ${path}"
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 설정 파일이 손상되었습니다
|
||||
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: "앨범 파일 경로: ${path}"
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: 앨범 파일이 손상되었습니다
|
||||
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: 프록시 설정 정상
|
||||
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: 프록시 설정 없음
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: 프록시 설정이 올바르지 않습니다
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_WORKING: 프록시 설정을 사용할 수 없습니다
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_TIPS: "클립보드 사진의 임시 폴더 경로: ${path}"
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_NOT_EXIST_TIPS: "클립보드 사진의 임시 폴더가 존재하지 않습니다: ${path}"
|
||||
TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: "다음 폴더를 직접 생성해 주세요: ${path}"
|
||||
|
||||
# tips
|
||||
TIPS_NOTICE: 알림
|
||||
TIPS_WARNING: 경고
|
||||
TIPS_ERROR: 오류
|
||||
TIPS_SKIPPED_INVALID_URLS: "잘못된 URL ${n}개를 건너뛰었습니다. 자세한 내용은 로그를 확인하세요"
|
||||
TIPS_TOO_MANY_URLS_CONFIRM: "한 번에 URL ${n}개를 업로드하려고 합니다. 지연이 발생할 수 있으니 나누어 업로드하는 것을 권장합니다. 계속하시겠습니까?"
|
||||
TIPS_NO_VALID_URLS: 유효한 URL을 찾을 수 없습니다
|
||||
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Node.js를 설치한 후 PicGo를 재시작해 주세요
|
||||
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: 플러그인이 앨범에서 일부 이미지를 제거하려고 합니다. 계속하시겠습니까?
|
||||
TIPS_PLUGIN_OVERWRITE_ALBUM: 플러그인이 앨범을 덮어쓰려고 합니다. 계속하시겠습니까?
|
||||
TIPS_UPLOAD_NOT_PICTURES: 최근 클립보드 항목이 사진이 아닙니다
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo 설정 파일이 손상되어 기본값으로 복원되었습니다
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo 설정 파일이 손상되어 백업본으로 복원되었습니다
|
||||
TIPS_PICGO_BACKUP_FILE_VERSION: "백업 파일 버전: ${v}"
|
||||
TIPS_CUSTOM_CONFIG_FILE_PATH_ERROR: 사용자 지정 설정 파일 구문 분석 오류입니다. 경로 내용을 확인해 주세요
|
||||
TIPS_SHORTCUT_MODIFIED_SUCCEED: 단축키가 성공적으로 변경되었습니다
|
||||
TIPS_SHORTCUT_MODIFIED_CONFLICT: 단축키가 충돌합니다. 다시 설정해 주세요
|
||||
TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: 사용자 지정 링크 스타일이 성공적으로 변경되었습니다
|
||||
TIPS_FIND_NEW_VERSION: "새 버전 ${v}을(를) 찾았습니다. 다양한 새 기능이 추가되었습니다. 최신 버전을 다운로드하시겠습니까?"
|
||||
TIPS_DELETE_UPLOADER_CONFIG: 이 설정을 삭제하시겠습니까?
|
||||
TIPS_COPY_UPLOADER_CONFIG: 이 설정을 복사하시겠습니까?
|
||||
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 설정 이름은 비워 둘 수 없습니다
|
||||
TIPS_UPLOADER_CONFIG_NOT_FOUND: 설정을 찾을 수 없습니다
|
||||
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 마지막 설정은 삭제할 수 없습니다
|
||||
|
||||
# privacy
|
||||
PRIVACY: "이용하기 전에 개인정보 처리방침 ${privacyUrl} 및 이용약관 ${termsUrl}을(를) 읽고 동의해 주세요."
|
||||
PRIVACY_TIPS: 업로드하려면 개인정보 처리방침에 동의해 주세요
|
||||
QUIT: 종료
|
||||
ALBUM_ALL_PHOTOS: 전체 사진
|
||||
ALBUM_COLLECTIONS: 컬렉션
|
||||
ALBUM_TAGS: 태그
|
||||
ALBUM_MENU: 메뉴
|
||||
ALBUM_OPEN_INSPECTOR: 인스펙터 열기
|
||||
ALBUM_INSPECTOR_TITLE: 앨범 인스펙터
|
||||
ALBUM_INSPECTOR_DESCRIPTION: 선택한 이미지를 검사하고 편집합니다.
|
||||
ALBUM_CLEAR_SELECTION: 선택 해제
|
||||
ALBUM_SELECTED_COUNT: "${count}개 선택됨"
|
||||
ALBUM_GRID_VIEW: 그리드 보기
|
||||
ALBUM_LIST_VIEW: 목록 보기
|
||||
ALBUM_PREVIEW: 전체 화면 미리보기
|
||||
ALBUM_PREVIEW_EMPTY: 미리볼 이미지가 없습니다
|
||||
ALBUM_PREVIEW_PREV: 이전
|
||||
ALBUM_PREVIEW_NEXT: 다음
|
||||
ALBUM_PREVIEW_CLOSE: 미리보기 닫기
|
||||
ALBUM_PREVIEW_COUNT: "${current} / ${total}"
|
||||
ALBUM_QUICK_ACTIONS: 빠른 작업
|
||||
ALBUM_EXPORT: 다운로드
|
||||
ALBUM_URL: URL
|
||||
ALBUM_BATCH_REWRITE: 일괄 재작성
|
||||
ALBUM_COLLECTION: 컬렉션
|
||||
ALBUM_ADD_TAG: 태그 추가
|
||||
ALBUM_TAG_SUGGESTIONS: 추천 태그
|
||||
ALBUM_COLUMN_NAME: 이름
|
||||
ALBUM_COLUMN_PROVIDER: 제공자
|
||||
ALBUM_COLUMN_SIZE: 크기
|
||||
ALBUM_COLUMN_DATE: 날짜
|
||||
ALBUM_ADD: 추가
|
||||
ALBUM_SOURCE_LOCAL: 로컬
|
||||
ALBUM_SOURCE_CLOUD: 클라우드
|
||||
ALBUM_CLOUD_LOAD_FAILED: 클라우드 앨범을 불러오지 못했습니다
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: 로그인 필요
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: 클라우드 앨범을 사용하려면 유료 PicGo Cloud 계정으로 로그인하세요
|
||||
ALBUM_CLOUD_LOGIN_BUTTON: 로그인
|
||||
ALBUM_CLOUD_UPGRADE_TITLE: 업그레이드 필요
|
||||
ALBUM_CLOUD_UPGRADE_DESC: 클라우드 앨범은 유료 요금제가 필요합니다
|
||||
ALBUM_CLOUD_UPGRADE_BUTTON: 업그레이드
|
||||
ALBUM_CLOUD_FEATURES_TITLE: 클라우드 앨범을 사용해야 하는 이유
|
||||
ALBUM_CLOUD_FEATURE_SYNC: 모든 기기에서 업로드 기록에 접근
|
||||
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 새 업로드 정보 자동 가져오기(옵트인 필요)
|
||||
ALBUM_CLOUD_FEATURE_SECURE: 로컬 기록을 클라우드로 원클릭 가져오기
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: 로컬 기록 가져오기
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_DESC: 로컬 업로드 기록을 클라우드 앨범으로 가져옵니다. 실제 이미지 파일이 아닌 기록만 가져옵니다.
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: 가져오기 시작
|
||||
ALBUM_CLOUD_EMPTY_TITLE: 항목 없음
|
||||
ALBUM_CLOUD_EMPTY_DESC: 클라우드 앨범이 비어 있습니다. 이미지를 업로드하여 시작하세요.
|
||||
ALBUM_CLOUD_IMPORTING: 클라우드 앨범으로 가져오는 중...
|
||||
ALBUM_CLOUD_IMPORT_SUCCESS: "클라우드 앨범으로 ${num}개 항목을 성공적으로 가져왔습니다"
|
||||
ALBUM_CLOUD_IMPORT_FAILED: 클라우드 앨범으로 가져오지 못했습니다
|
||||
ALBUM_CLOUD_REFRESH: 새로고침
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 자동 가져오기 사용
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: 이 작업을 사용하면 자동 가져오기가 활성화되어 이후 업로드가 클라우드 앨범에 자동으로 기록됩니다. 기존 로컬 기록도 지금 함께 가져옵니다.
|
||||
ALBUM_CLOUD_DELETE_FAILED: 클라우드 앨범에서 삭제하지 못했습니다
|
||||
ALBUM_INSPECTOR_DETAILS_TITLE: 상세 정보
|
||||
ALBUM_INSPECTOR_FILE_NAME: 파일 이름
|
||||
ALBUM_INSPECTOR_UPLOADER: 업로더
|
||||
ALBUM_INSPECTOR_CONTENT_TYPE: 콘텐츠 유형
|
||||
ALBUM_INSPECTOR_CREATED_AT: 생성일
|
||||
ALBUM_INSPECTOR_UPDATED_AT: 수정일
|
||||
ALBUM_INSPECTOR_FILE_SIZE: 크기
|
||||
ALBUM_CLOUD_IMPORT_STATUS_TITLE: 클라우드 앨범
|
||||
ALBUM_CLOUD_IMPORTED: 클라우드로 가져옴
|
||||
ALBUM_CLOUD_IMPORTED_TOOLTIP: 이것은 로컬 표시자입니다. 클라우드에서 기록이 삭제되어도 이 상태는 갱신되지 않습니다. 필요하면 다시 가져올 수 있습니다.
|
||||
ALBUM_CLOUD_REIMPORT: 다시 가져오기
|
||||
ALBUM_CLOUD_NATIVE: PicGo Cloud를 통해 업로드됨
|
||||
ALBUM_CLOUD_IMPORT_BUTTON: 클라우드 앨범으로 가져오기
|
||||
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: "항목 ${num}개를 클라우드 앨범으로 가져오기"
|
||||
ALBUM_CLOUD_ALL_IMPORTED: 모두 클라우드 앨범에 있음
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 자동 가져오기 사용
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: 항목을 클라우드 앨범으로 가져오려면 먼저 자동 가져오기를 사용해야 합니다. 지금 사용하고 계속하시겠습니까?
|
||||
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 사용 및 가져오기
|
||||
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: 클라우드 앨범으로 성공적으로 가져왔습니다
|
||||
PICGO_CLOUD_AUTO_IMPORT_LABEL: 클라우드 앨범으로 자동 가져오기
|
||||
PICGO_CLOUD_AUTO_IMPORT_DESC: 다른 업로더의 업로드 기록을 클라우드 앨범으로 자동 가져오기(PicGo Cloud는 기본적으로 저장됨)
|
||||
SIDEBAR_PLUGINS: 플러그인
|
||||
SIDEBAR_SYSTEM: 시스템
|
||||
SIDEBAR_NOTIFICATIONS: 알림
|
||||
SIDEBAR_EXPAND: 클릭하여 펼치기
|
||||
SIDEBAR_COLLAPSE: 사이드바 접기
|
||||
HISTORY_PANEL_TITLE: 기록
|
||||
HISTORY_PANEL_FILTER_PLACEHOLDER: 필터...
|
||||
HISTORY_PANEL_TODAY: 오늘
|
||||
HISTORY_PANEL_YESTERDAY: 어제
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS: 표시할 제공자가 없습니다
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: 모든 제공자가 숨겨져 있습니다. 설정에서 제공자 표시 여부를 변경하세요.
|
||||
DASHBOARD_OPEN_SETTINGS: 설정 열기
|
||||
DASHBOARD_DROP_IMAGES_HERE: 파일을 여기로 드롭하세요
|
||||
DASHBOARD_OR: 또는
|
||||
DASHBOARD_CLICK_TO_UPLOAD: 클릭하여 업로드
|
||||
DASHBOARD_PASTE_FROM_CLIPBOARD: 클립보드에서 붙여넣기
|
||||
DASHBOARD_PASTE_FROM_URL: URL에서 붙여넣기
|
||||
DASHBOARD_CLIPBOARD: 클립보드
|
||||
FIELD_IS_REQUIRED: "${field}은(는) 필수입니다"
|
||||
CONFIG_RENAME: 설정 이름 변경
|
||||
SETTINGS_SECTION_GENERAL: 일반
|
||||
SETTINGS_SECTION_APPEARANCE: 외관
|
||||
SETTINGS_SECTION_UPLOAD_WORKFLOW: 업로드 워크플로
|
||||
SETTINGS_SECTION_NETWORK: 네트워크
|
||||
SETTINGS_SECTION_ADVANCED: 고급
|
||||
SETTINGS_SECTION_ABOUT: 정보
|
||||
SETTINGS_NO_RESULTS_TITLE: 설정을 찾을 수 없습니다
|
||||
SETTINGS_NO_RESULTS_DESCRIPTION: 다른 키워드를 시도하거나 검색어를 지워 보세요.
|
||||
SETTINGS_OPEN_SHORTCUTS: 단축키
|
||||
SETTINGS_LINK_WEBSITE: 웹사이트
|
||||
SETTINGS_LINK_GITHUB: GitHub
|
||||
SETTINGS_LINK_DOCS: 문서
|
||||
SETTINGS_LINK_PRIVACY: 개인정보 처리방침
|
||||
SETTINGS_LINK_TERMS: 이용약관
|
||||
SETTINGS_APPEARANCE_MODE: 외관
|
||||
SETTINGS_APPEARANCE_MODE_LIGHT: 밝게
|
||||
SETTINGS_APPEARANCE_MODE_DARK: 어둡게
|
||||
SETTINGS_APPEARANCE_MODE_AUTO: 자동
|
||||
COMMON_FOR_EXAMPLE: "예: ${value}"
|
||||
SETTINGS_SET_DEFAULT_CONFIG: 기본값으로 설정
|
||||
PROVIDER_DRAFT_CONFIG: 임시 저장
|
||||
PROVIDER_ACTIVE_UPLOADER_LABEL: 활성
|
||||
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: "${uploaderName}이(가) 활성 업로더입니다"
|
||||
PROVIDER_DEFAULT_CONFIG_LABEL: 기본값
|
||||
PROVIDER_DEFAULT_CONFIG_TOOLTIP: "${uploaderName}이(가) 활성 상태일 때 사용됩니다."
|
||||
PROVIDER_SIDEBAR_EMPTY: 업로더 또는 설정을 찾을 수 없습니다.
|
||||
PROVIDER_UPLOADER_ACTIONS: "${uploaderName} 작업"
|
||||
PROVIDER_SIDEBAR_COLLAPSE: 접기
|
||||
PROVIDER_SIDEBAR_EXPAND: 펼치기
|
||||
PROVIDER_CONFIG_ACTIONS: 설정 작업
|
||||
PROVIDER_CREATE_CONFIG: 설정 생성
|
||||
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: 이 업로더에는 설정 옵션이 없어 추가 설정을 생성할 필요가 없습니다
|
||||
PROVIDER_INSTALL_MORE_UPLOADERS: 업로더 추가 설치
|
||||
PROVIDER_NO_UPLOADER_SELECTED: 선택된 업로더가 없습니다.
|
||||
PROVIDER_NO_CONFIG_YET: 아직 설정이 없습니다
|
||||
PROVIDER_NO_CONFIG_DESCRIPTION: "${uploaderName}의 스키마 기반 옵션을 편집하려면 새 설정을 생성하세요."
|
||||
PROVIDER_CONFIGURATION: 설정
|
||||
PROVIDER_UPDATED_AT_LABEL: 수정일
|
||||
PROVIDER_DELETE_CONFIG_HINT: 이 설정을 영구적으로 삭제합니다.
|
||||
UPLOADER_SWITCHER_SELECT_PROVIDER: 제공자 선택
|
||||
UPLOADER_SWITCHER_CONFIG: 설정
|
||||
UPLOADER_SWITCHER_NO_CONFIG: 설정 없음
|
||||
ALBUM_URL_REWRITE_DESCRIPTION: 선택한 이미지의 URL을 일괄 재작성합니다.
|
||||
ALBUM_URL_REWRITE_CHANGED: 변경됨
|
||||
ALBUM_URL_REWRITE_UNCHANGED: 변경 없음
|
||||
TRAY_ALREADY_UPLOAD_EMPTY: 아직 업로드된 이미지가 없습니다
|
||||
PLUGIN_EMPTY: 플러그인을 찾을 수 없습니다.
|
||||
PLUGIN_EMPTY_DESCRIPTION: npm을 검색하여 플러그인을 설치해 시작하세요.
|
||||
PLUGIN_DETAIL: 상세 정보
|
||||
PLUGIN_NO_CONFIG_SCHEMA: 이 플러그인은 설정 스키마를 제공하지 않습니다.
|
||||
PLUGIN_NO_TRANSFORMER_SCHEMA: 이 플러그인은 트랜스포머 스키마를 제공하지 않습니다.
|
||||
PLUGIN_NO_README: 이 플러그인에 대한 README가 없습니다.
|
||||
NO_OPTIONS_FOUND: 옵션을 찾을 수 없습니다.
|
||||
+230
-20
@@ -22,6 +22,7 @@ DISABLE: 禁用
|
||||
CONFIG_THING: 配置${c}
|
||||
FIND_NEW_VERSION: 发现新版本
|
||||
NO_MORE_NOTICE: 以后不再提醒
|
||||
MORE: 更多
|
||||
SHOW_DEVTOOLS: 打开开发者工具
|
||||
CURRENT_PICBED: 当前图床
|
||||
OPEN_TOOLBOX: 打开修复工具箱
|
||||
@@ -29,13 +30,19 @@ OPEN_TOOLBOX: 打开修复工具箱
|
||||
# ---renderer i18n begin---
|
||||
|
||||
CHOOSE_YOUR_DEFAULT_PICBED: 选择 ${d} 作为你默认图床:
|
||||
SIDEBAR_DASHBOARD: 仪表盘
|
||||
UPLOAD_AREA: 上传区
|
||||
GALLERY: 相册
|
||||
ALBUM: 相册
|
||||
ALBUM_PROVIDERS: 图床列表
|
||||
PICBEDS_SETTINGS: 图床设置
|
||||
PICGO_SETTINGS: PicGo设置
|
||||
PLUGIN_SETTINGS: 插件设置
|
||||
DASHBOARD_HISTORY_PANEL_TITLE: 历史面板
|
||||
PICGO_CLOUD_TITLE: PicGo Cloud
|
||||
PICGO_CLOUD_DESCRIPTION: PicGo 打造的云端服务,连接你的每一台设备。
|
||||
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
|
||||
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 错误
|
||||
PICGO_CLOUD_LOADING: 正在加载 PicGo Cloud 状态…
|
||||
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登录 PicGo Cloud
|
||||
PICGO_CLOUD_LOGIN: 登录
|
||||
PICGO_CLOUD_LOGOUT: 退出登录
|
||||
@@ -98,6 +105,55 @@ PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置冲突已解决。
|
||||
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步时间:${time}"
|
||||
PICGO_CLOUD_LAST_SYNC_TIME_NONE: 无
|
||||
PICGO_CLOUD_LAST_SYNC_LABEL: 上次同步
|
||||
PICGO_CLOUD_PLAN_USAGE_TITLE: 套餐与用量
|
||||
PICGO_CLOUD_PLAN_USAGE_DESC: 查看当前套餐与用量概览。
|
||||
PICGO_CLOUD_PLAN_PERIOD_LABEL: 套餐周期
|
||||
PICGO_CLOUD_STORAGE_LABEL: 存储空间
|
||||
PICGO_CLOUD_FILES_LABEL: 文件数
|
||||
PICGO_CLOUD_USAGE_PROGRESS: "${used} / ${total}"
|
||||
PICGO_CLOUD_USAGE_UNLIMITED: 无限
|
||||
PICGO_CLOUD_PLAN_PERIOD_RENEWS: 续费日 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_CANCELS: 失效日 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_UNTIL: 有效期至 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 长期有效
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 宽限期至
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: 付费套餐已过期,目前处于宽限期。该日期前 PicGo Cloud 图片仍可正常访问,但配额已临时降级到免费版。大部分付费功能将暂时不可使用。过期后账户将进入冻结状态。
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 冻结期至
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: 账户已冻结,PicGo Cloud 图片无法访问。该日期后数据可能被清理,请尽快续费以恢复权益。
|
||||
PICGO_CLOUD_QUOTA_DOWNGRADED: 宽限期内配额已临时降级到 ${plan},续费后将恢复
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: 套餐已进入宽限期
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: 你的付费套餐已过期,目前进入 ${days} 天宽限期。期间配额已临时降级到免费版,大部分付费功能将暂时不可使用。请尽快续费以恢复完整权益。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: 账户已冻结
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: 你的账户已进入冻结期,云端图片暂时无法访问,距离数据被清理还剩 ${days} 天。请立即续费以恢复访问。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: 数据即将清理
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: 你的账户已进入待清理状态,云端数据即将被永久删除。如需保留数据,请立即续费。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 立即续费
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 关闭
|
||||
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: 套餐处于宽限期或冻结期,自动导入已暂停。续费后将恢复。
|
||||
PICGO_CLOUD_IMAGE_UNAVAILABLE: 图片暂不可用
|
||||
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 套餐处于宽限期,此操作暂不可用,请续费后重试。
|
||||
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: 账户已冻结,请续费以恢复使用。
|
||||
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 自动导入已被关闭,请先在 PicGo Cloud 设置中启用。
|
||||
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 当前套餐不支持此功能,请升级后重试。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: 已达套餐配额上限,升级到更高套餐可获得更多配额。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 宽限期内配额已降级到免费版,请续费以恢复完整配额。
|
||||
PICGO_CLOUD_ERROR_PLAN_REQUIRED: 此功能需要付费套餐。
|
||||
PICGO_CLOUD_USAGE_LOAD_FAILED: 用量数据加载失败
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 同步状态加载失败
|
||||
PICGO_CLOUD_FREE_PLAN_BANNER: 你当前使用免费套餐,升级后可解锁更多配额和高级云功能。
|
||||
PICGO_CLOUD_VIEW_PLANS: 查看套餐
|
||||
PICGO_CLOUD_CONFIG_SYNC_TITLE: 配置同步
|
||||
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: 在不同设备间安全同步你的配置。
|
||||
PICGO_CLOUD_SYNC_NOW: 立即同步
|
||||
PICGO_CLOUD_SYNC_QUOTA_LABEL: 同步配额
|
||||
PICGO_CLOUD_SYNC_QUOTA_TIP: "当前套餐保留最近 ${limit} 份历史版本。即使显示 ${limit} / ${limit},依然可以同步,每次同步后会自动清理超出范围的旧版本。"
|
||||
PICGO_CLOUD_LOGIN_PANEL_TITLE: 登录 PicGo Cloud
|
||||
PICGO_CLOUD_LOGIN_PANEL_DESC: 登录即可使用 PicGo 官方图床,并启用云端相关功能。
|
||||
PICGO_CLOUD_LOGIN_FEATURES_TITLE: 云端功能
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 官方图床
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo 官方图床,内置相册云同步。
|
||||
PICGO_CLOUD_PAID_PLAN_BADGE: 付费套餐
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重启吗?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重启后生效,是否立即重启?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重启
|
||||
@@ -142,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: 自定义链接格式
|
||||
SETTINGS_SET_PROXY_AND_MIRROR: 设置代理和镜像地址
|
||||
SETTINGS_SET_SERVER: 设置Server
|
||||
SETTINGS_CHECK_UPDATE: 检查更新
|
||||
SETTINGS_UPDATE_CHECK_RESULT: 最新版本为 ${version},${action}
|
||||
SETTINGS_UPDATE_CHECK_NO_UPDATE: 无需更新
|
||||
SETTINGS_UPDATE_CHECK_CAN_UPDATE: 可以更新
|
||||
SETTINGS_OPEN_UPDATE_HELPER: 打开更新助手
|
||||
SETTINGS_OPEN: 开
|
||||
SETTINGS_CLOSE: 关
|
||||
@@ -253,21 +312,21 @@ SHORTCUT_DISABLE: 禁用
|
||||
SHORTCUT_EDIT: 编辑
|
||||
SHORTCUT_CHANGE_UPLOAD: 修改上传快捷键
|
||||
|
||||
# gallery-page
|
||||
GALLERY_URL_REWRITE_TITLE: 重写选中图片 URL
|
||||
GALLERY_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
|
||||
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
|
||||
# album-page
|
||||
ALBUM_URL_REWRITE_TITLE: 重写选中图片 URL
|
||||
ALBUM_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
|
||||
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
|
||||
|
||||
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
|
||||
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
|
||||
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
|
||||
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
|
||||
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
|
||||
GALLERY_URL_REWRITE_APPLY_ONLY: 仅应用
|
||||
GALLERY_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
|
||||
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
|
||||
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
|
||||
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
|
||||
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
|
||||
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
|
||||
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
|
||||
ALBUM_URL_REWRITE_APPLY_ONLY: 仅应用
|
||||
ALBUM_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
|
||||
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
|
||||
|
||||
# tray-page
|
||||
|
||||
@@ -290,9 +349,12 @@ TIPS_INPUT_VALID_URL: 请输入合法的URL
|
||||
# plugins
|
||||
|
||||
PLUGIN_SEARCH_PLACEHOLDER: 搜索npm上的PicGo插件,或者点击上方按钮查看优秀插件列表
|
||||
PLUGIN_SEARCH_EXACT_MATCH: 插件名精确匹配
|
||||
PLUGIN_INSTALL: 安装
|
||||
PLUGIN_INSTALLING: 安装中
|
||||
PLUGIN_INSTALLED: 已安装
|
||||
PLUGIN_DEPRECATED_BADGE: 已弃用
|
||||
PLUGIN_DEPRECATED_TITLE: 此插件已被作者标记为弃用
|
||||
PLUGIN_DOING_SOMETHING: 进行中
|
||||
PLUGIN_LIST: 插件列表
|
||||
PLUGIN_IMPORT_LOCAL: 导入本地插件
|
||||
@@ -331,7 +393,7 @@ TOOLBOX: 工具箱
|
||||
TOOLBOX_TITLE: 排查 PicGo 运行时问题
|
||||
TOOLBOX_SUB_TITLE: 立即扫描以下项目,修复使用问题
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 检查配置文件是否损坏
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: 检查相册文件是否损坏
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: 检查相册文件是否损坏
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: 检查剪贴板图片上传是否存在问题
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: 检查代理设置是否正常
|
||||
TOOLBOX_FIX_DONE_NEED_RELOAD: 修复完成,需要重启生效,是否重启
|
||||
@@ -342,8 +404,8 @@ TOOLBOX_START_FIX: 开始修复
|
||||
TOOLBOX_SUCCESS_TIPS: 恭喜你,没有检查出问题
|
||||
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: 配置文件路径是:${path}
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 配置文件已损坏
|
||||
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: 相册文件路径是:${path}
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: 相册文件已损坏
|
||||
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: 相册文件路径是:${path}
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: 相册文件已损坏
|
||||
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: 代理设置正常
|
||||
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: 无代理设置
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: 代理设置不正确
|
||||
@@ -360,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: 已跳过 ${n} 条非法 URL,请查看日志了解
|
||||
TIPS_TOO_MANY_URLS_CONFIRM: 你将一次上传 ${n} 条 URL,可能会引起卡顿,建议分批上传。是否继续?
|
||||
TIPS_NO_VALID_URLS: 未检测到合法的 URL
|
||||
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 请安装Node.js并重启PicGo再继续操作
|
||||
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在试图删除一些相册图片,是否继续
|
||||
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在试图覆盖相册列表,是否继续
|
||||
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: 有插件正在试图删除一些相册图片,是否继续
|
||||
TIPS_PLUGIN_OVERWRITE_ALBUM: 有插件正在试图覆盖相册列表,是否继续
|
||||
TIPS_UPLOAD_NOT_PICTURES: 剪贴板最新的一条记录不是图片
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo 配置文件损坏,已经恢复为默认配置
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo 配置文件损坏,已经恢复为备份配置
|
||||
@@ -381,3 +443,151 @@ TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 无法删除最后一个配置
|
||||
PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款 ${termsUrl},同意后方可使用。"
|
||||
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
|
||||
QUIT: 退出
|
||||
ALBUM_ALL_PHOTOS: 全部照片
|
||||
ALBUM_COLLECTIONS: 合集
|
||||
ALBUM_TAGS: 标签
|
||||
ALBUM_MENU: 菜单
|
||||
ALBUM_OPEN_INSPECTOR: 打开检查器
|
||||
ALBUM_INSPECTOR_TITLE: 相册检查器
|
||||
ALBUM_INSPECTOR_DESCRIPTION: 查看并编辑已选图片。
|
||||
ALBUM_CLEAR_SELECTION: 取消选择
|
||||
ALBUM_SELECTED_COUNT: 已选 ${count} 项
|
||||
ALBUM_GRID_VIEW: 网格视图
|
||||
ALBUM_LIST_VIEW: 列表视图
|
||||
ALBUM_PREVIEW: 全屏预览
|
||||
ALBUM_PREVIEW_EMPTY: 没有可预览的图片
|
||||
ALBUM_PREVIEW_PREV: 上一张
|
||||
ALBUM_PREVIEW_NEXT: 下一张
|
||||
ALBUM_PREVIEW_CLOSE: 关闭预览
|
||||
ALBUM_PREVIEW_COUNT: ${current} / ${total}
|
||||
ALBUM_QUICK_ACTIONS: 快速操作
|
||||
ALBUM_EXPORT: 下载
|
||||
ALBUM_URL: URL
|
||||
ALBUM_BATCH_REWRITE: 批量重写
|
||||
ALBUM_COLLECTION: 合集
|
||||
ALBUM_ADD_TAG: 添加标签
|
||||
ALBUM_TAG_SUGGESTIONS: 推荐标签
|
||||
ALBUM_COLUMN_NAME: 名称
|
||||
ALBUM_COLUMN_PROVIDER: 图床
|
||||
ALBUM_COLUMN_SIZE: 大小
|
||||
ALBUM_COLUMN_DATE: 日期
|
||||
ALBUM_ADD: 添加
|
||||
ALBUM_SOURCE_LOCAL: 本地
|
||||
ALBUM_SOURCE_CLOUD: 云端
|
||||
ALBUM_CLOUD_LOAD_FAILED: 加载云端相册失败
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: 需要登录
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: 登录 PicGo Cloud 付费账户以使用云端相册
|
||||
ALBUM_CLOUD_LOGIN_BUTTON: 前往登录
|
||||
ALBUM_CLOUD_UPGRADE_TITLE: 需要升级
|
||||
ALBUM_CLOUD_UPGRADE_DESC: 云端相册需要付费套餐
|
||||
ALBUM_CLOUD_UPGRADE_BUTTON: 升级套餐
|
||||
ALBUM_CLOUD_FEATURES_TITLE: 为什么使用云端相册?
|
||||
ALBUM_CLOUD_FEATURE_SYNC: 多设备访问上传历史记录
|
||||
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 上传后自动导入相册记录到云端(需开启该功能)
|
||||
ALBUM_CLOUD_FEATURE_SECURE: 一键导入本地历史到云端
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: 导入本地记录
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_DESC: 将本地上传记录导入到云端相册。仅导入记录,图片文件本身不会被上传。
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: 开始导入
|
||||
ALBUM_CLOUD_EMPTY_TITLE: 暂无内容
|
||||
ALBUM_CLOUD_EMPTY_DESC: 云端相册为空,上传图片即可开始使用。
|
||||
ALBUM_CLOUD_IMPORTING: 正在导入到云端相册...
|
||||
ALBUM_CLOUD_IMPORT_SUCCESS: 成功导入 ${num} 条记录到云端相册
|
||||
ALBUM_CLOUD_IMPORT_FAILED: 导入云端相册失败
|
||||
ALBUM_CLOUD_REFRESH: 刷新
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 开启自动导入
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: 此操作将开启自动导入功能,后续上传的图片记录会自动同步到云端相册。同时会将现有的本地记录一并导入。
|
||||
ALBUM_CLOUD_DELETE_FAILED: 从云端相册删除失败
|
||||
ALBUM_INSPECTOR_DETAILS_TITLE: 详情
|
||||
ALBUM_INSPECTOR_FILE_NAME: 文件名
|
||||
ALBUM_INSPECTOR_UPLOADER: 上传器
|
||||
ALBUM_INSPECTOR_CONTENT_TYPE: 内容类型
|
||||
ALBUM_INSPECTOR_CREATED_AT: 创建时间
|
||||
ALBUM_INSPECTOR_UPDATED_AT: 更新时间
|
||||
ALBUM_INSPECTOR_FILE_SIZE: 文件大小
|
||||
ALBUM_CLOUD_IMPORT_STATUS_TITLE: 云端相册
|
||||
ALBUM_CLOUD_IMPORTED: 已导入云端
|
||||
ALBUM_CLOUD_IMPORTED_TOOLTIP: 这是本地标记,不会与云端同步。如果云端记录已被删除,此状态不会更新。你可以重新导入。
|
||||
ALBUM_CLOUD_REIMPORT: 重新导入
|
||||
ALBUM_CLOUD_NATIVE: 通过 PicGo Cloud 上传
|
||||
ALBUM_CLOUD_IMPORT_BUTTON: 导入到云端相册
|
||||
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: 导入 ${num} 条记录到云端相册
|
||||
ALBUM_CLOUD_ALL_IMPORTED: 已全部在云端相册中
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 需要开启自动导入
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: 导入到云端相册前需要先开启自动导入功能。是否立即开启并继续导入?
|
||||
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 开启并导入
|
||||
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: 已成功导入到云端相册
|
||||
PICGO_CLOUD_AUTO_IMPORT_LABEL: 自动导入云端相册
|
||||
PICGO_CLOUD_AUTO_IMPORT_DESC: 自动将其他图床的上传记录导入云端相册(PicGo Cloud 默认已写入)
|
||||
SIDEBAR_PLUGINS: 插件
|
||||
SIDEBAR_SYSTEM: 系统
|
||||
SIDEBAR_NOTIFICATIONS: 通知
|
||||
SIDEBAR_EXPAND: 点击展开
|
||||
SIDEBAR_COLLAPSE: 收起侧边栏
|
||||
HISTORY_PANEL_TITLE: 历史记录
|
||||
HISTORY_PANEL_FILTER_PLACEHOLDER: 筛选...
|
||||
HISTORY_PANEL_TODAY: 今天
|
||||
HISTORY_PANEL_YESTERDAY: 昨天
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS: 没有可见图床
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: 当前所有图床都被隐藏了,请前往设置调整可见图床。
|
||||
DASHBOARD_OPEN_SETTINGS: 打开设置
|
||||
DASHBOARD_DROP_IMAGES_HERE: 将文件拖拽到此处
|
||||
DASHBOARD_OR: 或
|
||||
DASHBOARD_CLICK_TO_UPLOAD: 点击上传
|
||||
DASHBOARD_PASTE_FROM_CLIPBOARD: 从剪贴板粘贴
|
||||
DASHBOARD_PASTE_FROM_URL: 从 URL 粘贴
|
||||
DASHBOARD_CLIPBOARD: 剪贴板
|
||||
FIELD_IS_REQUIRED: ${field} 为必填项
|
||||
CONFIG_RENAME: 配置改名
|
||||
SETTINGS_SECTION_GENERAL: 通用
|
||||
SETTINGS_SECTION_APPEARANCE: 外观
|
||||
SETTINGS_SECTION_UPLOAD_WORKFLOW: 上传流程
|
||||
SETTINGS_SECTION_NETWORK: 网络
|
||||
SETTINGS_SECTION_ADVANCED: 高级
|
||||
SETTINGS_SECTION_ABOUT: 关于
|
||||
SETTINGS_NO_RESULTS_TITLE: 没有匹配的设置项
|
||||
SETTINGS_NO_RESULTS_DESCRIPTION: 请尝试其他关键词,或清空搜索条件。
|
||||
SETTINGS_OPEN_SHORTCUTS: 快捷键
|
||||
SETTINGS_LINK_WEBSITE: 官网
|
||||
SETTINGS_LINK_GITHUB: GitHub
|
||||
SETTINGS_LINK_DOCS: 文档
|
||||
SETTINGS_LINK_PRIVACY: 隐私协议
|
||||
SETTINGS_LINK_TERMS: 服务条款
|
||||
SETTINGS_APPEARANCE_MODE: 主题模式
|
||||
SETTINGS_APPEARANCE_MODE_LIGHT: 浅色
|
||||
SETTINGS_APPEARANCE_MODE_DARK: 深色
|
||||
SETTINGS_APPEARANCE_MODE_AUTO: 跟随系统
|
||||
COMMON_FOR_EXAMPLE: 例如:${value}
|
||||
SETTINGS_SET_DEFAULT_CONFIG: 设为默认配置
|
||||
PROVIDER_DRAFT_CONFIG: 草稿
|
||||
PROVIDER_ACTIVE_UPLOADER_LABEL: 当前
|
||||
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} 是当前使用的上传器
|
||||
PROVIDER_DEFAULT_CONFIG_LABEL: 默认
|
||||
PROVIDER_DEFAULT_CONFIG_TOOLTIP: 当 ${uploaderName} 处于当前激活状态时使用。
|
||||
PROVIDER_SIDEBAR_EMPTY: 未找到图床或配置。
|
||||
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} 操作
|
||||
PROVIDER_SIDEBAR_COLLAPSE: 收起
|
||||
PROVIDER_SIDEBAR_EXPAND: 展开
|
||||
PROVIDER_CONFIG_ACTIONS: 配置操作
|
||||
PROVIDER_CREATE_CONFIG: 新建配置
|
||||
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: 该上传器没有配置项,无需新建配置
|
||||
PROVIDER_INSTALL_MORE_UPLOADERS: 安装更多图床
|
||||
PROVIDER_NO_UPLOADER_SELECTED: 尚未选择图床。
|
||||
PROVIDER_NO_CONFIG_YET: 暂无配置
|
||||
PROVIDER_NO_CONFIG_DESCRIPTION: 为 ${uploaderName} 新建一个配置后即可开始编辑配置项。
|
||||
PROVIDER_CONFIGURATION: 配置项
|
||||
PROVIDER_UPDATED_AT_LABEL: 更新时间
|
||||
PROVIDER_DELETE_CONFIG_HINT: 该操作会永久删除当前配置。
|
||||
UPLOADER_SWITCHER_SELECT_PROVIDER: 选择图床
|
||||
UPLOADER_SWITCHER_CONFIG: 配置
|
||||
UPLOADER_SWITCHER_NO_CONFIG: 暂无配置
|
||||
ALBUM_URL_REWRITE_DESCRIPTION: 批量重写所选图片的 URL。
|
||||
ALBUM_URL_REWRITE_CHANGED: 已修改
|
||||
ALBUM_URL_REWRITE_UNCHANGED: 未修改
|
||||
TRAY_ALREADY_UPLOAD_EMPTY: 暂无已上传图片
|
||||
PLUGIN_EMPTY: 暂未发现插件
|
||||
PLUGIN_EMPTY_DESCRIPTION: 可以先在上方搜索并安装一些插件
|
||||
PLUGIN_DETAIL: 详情
|
||||
PLUGIN_NO_CONFIG_SCHEMA: 该插件未暴露 config 配置项。
|
||||
PLUGIN_NO_TRANSFORMER_SCHEMA: 该插件未暴露 transformer 配置项。
|
||||
PLUGIN_NO_README: 该插件暂无 README 内容。
|
||||
NO_OPTIONS_FOUND: 没有可选项
|
||||
|
||||
+230
-20
@@ -22,6 +22,7 @@ DISABLE: 禁用
|
||||
CONFIG_THING: 設定${c}
|
||||
FIND_NEW_VERSION: 發現新版本
|
||||
NO_MORE_NOTICE: 以後不再提醒
|
||||
MORE: 更多
|
||||
SHOW_DEVTOOLS: 開啟開發者工具
|
||||
CURRENT_PICBED: 當前圖床
|
||||
OPEN_TOOLBOX: 開啟修復工具箱
|
||||
@@ -29,13 +30,19 @@ OPEN_TOOLBOX: 開啟修復工具箱
|
||||
# ---renderer i18n begin---
|
||||
|
||||
CHOOSE_YOUR_DEFAULT_PICBED: 選擇 ${d} 作為你的預設圖床:
|
||||
SIDEBAR_DASHBOARD: 儀表板
|
||||
UPLOAD_AREA: 上傳區
|
||||
GALLERY: 相簿
|
||||
ALBUM: 相簿
|
||||
ALBUM_PROVIDERS: 圖床列表
|
||||
PICBEDS_SETTINGS: 圖床設定
|
||||
PICGO_SETTINGS: PicGo設定
|
||||
PLUGIN_SETTINGS: 插件設定
|
||||
DASHBOARD_HISTORY_PANEL_TITLE: 歷史面板
|
||||
PICGO_CLOUD_TITLE: PicGo Cloud
|
||||
PICGO_CLOUD_DESCRIPTION: PicGo 打造的雲端服務,連接你的每一台裝置。
|
||||
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
|
||||
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 錯誤
|
||||
PICGO_CLOUD_LOADING: 正在載入 PicGo Cloud 狀態…
|
||||
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登入 PicGo Cloud
|
||||
PICGO_CLOUD_LOGIN: 登入
|
||||
PICGO_CLOUD_LOGOUT: 登出
|
||||
@@ -98,6 +105,55 @@ PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
|
||||
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置衝突已解決。
|
||||
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步時間:${time}"
|
||||
PICGO_CLOUD_LAST_SYNC_TIME_NONE: 無
|
||||
PICGO_CLOUD_LAST_SYNC_LABEL: 上次同步
|
||||
PICGO_CLOUD_PLAN_USAGE_TITLE: 方案與用量
|
||||
PICGO_CLOUD_PLAN_USAGE_DESC: 查看目前方案與用量概覽。
|
||||
PICGO_CLOUD_PLAN_PERIOD_LABEL: 方案週期
|
||||
PICGO_CLOUD_STORAGE_LABEL: 儲存空間
|
||||
PICGO_CLOUD_FILES_LABEL: 檔案數
|
||||
PICGO_CLOUD_USAGE_PROGRESS: "${used} / ${total}"
|
||||
PICGO_CLOUD_USAGE_UNLIMITED: 無限
|
||||
PICGO_CLOUD_PLAN_PERIOD_RENEWS: 續費日 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_CANCELS: 失效日 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_UNTIL: 有效期至 ${date}
|
||||
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 長期有效
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 寬限期至
|
||||
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: 付費套餐已過期,目前處於寬限期。該日期前 PicGo Cloud 圖片仍可正常存取,但配額已暫時降級到免費版。大部分付費功能將暫時不可使用。過期後帳戶將進入凍結狀態。
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 凍結期至
|
||||
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: 帳戶已凍結,PicGo Cloud 圖片無法存取。該日期後資料可能被清理,請儘快續費以恢復權益。
|
||||
PICGO_CLOUD_QUOTA_DOWNGRADED: 寬限期內配額已暫時降級到 ${plan},續費後將恢復
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: 套餐已進入寬限期
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: 你的付費套餐已過期,目前進入 ${days} 天寬限期。期間配額已暫時降級到免費版,大部分付費功能將暫時不可使用。請儘快續費以恢復完整權益。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: 帳戶已凍結
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: 你的帳戶已進入凍結期,雲端圖片暫時無法存取,距離資料被清理還剩 ${days} 天。請立即續費以恢復存取。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: 資料即將清理
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: 你的帳戶已進入待清理狀態,雲端資料即將被永久刪除。如需保留資料,請立即續費。
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 立即續費
|
||||
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 關閉
|
||||
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: 套餐處於寬限期或凍結期,自動匯入已暫停。續費後將恢復。
|
||||
PICGO_CLOUD_IMAGE_UNAVAILABLE: 圖片暫時無法顯示
|
||||
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 套餐處於寬限期,此操作暫不可用,請續費後重試。
|
||||
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: 帳戶已凍結,請續費以恢復使用。
|
||||
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 自動匯入已被關閉,請先在 PicGo Cloud 設定中啟用。
|
||||
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 目前方案不支援此功能,請升級後重試。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: 已達方案配額上限,升級到更高方案可獲得更多配額。
|
||||
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 寬限期內配額已降級到免費版,請續費以恢復完整配額。
|
||||
PICGO_CLOUD_ERROR_PLAN_REQUIRED: 此功能需要付費方案。
|
||||
PICGO_CLOUD_USAGE_LOAD_FAILED: 用量資料載入失敗
|
||||
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 同步狀態載入失敗
|
||||
PICGO_CLOUD_FREE_PLAN_BANNER: 你目前使用免費方案,升級後可解鎖更多配額與進階雲端功能。
|
||||
PICGO_CLOUD_VIEW_PLANS: 查看方案
|
||||
PICGO_CLOUD_CONFIG_SYNC_TITLE: 配置同步
|
||||
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: 在不同裝置間安全同步你的配置。
|
||||
PICGO_CLOUD_SYNC_NOW: 立即同步
|
||||
PICGO_CLOUD_SYNC_QUOTA_LABEL: 同步配額
|
||||
PICGO_CLOUD_SYNC_QUOTA_TIP: "目前方案保留最近 ${limit} 份歷史版本。即使顯示 ${limit} / ${limit},依然可以同步,每次同步後會自動清理超出範圍的舊版本。"
|
||||
PICGO_CLOUD_LOGIN_PANEL_TITLE: 登入 PicGo Cloud
|
||||
PICGO_CLOUD_LOGIN_PANEL_DESC: 登入即可使用 PicGo 官方圖床,並啟用雲端相關功能。
|
||||
PICGO_CLOUD_LOGIN_FEATURES_TITLE: 雲端功能
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 官方圖床
|
||||
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo 官方圖床,內建相簿雲同步。
|
||||
PICGO_CLOUD_PAID_PLAN_BADGE: 付費方案
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重啟嗎?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重啟後生效,是否立即重啟?
|
||||
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重啟
|
||||
@@ -142,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: 自訂連結格式
|
||||
SETTINGS_SET_PROXY_AND_MIRROR: 設定PROXY和鏡像地址
|
||||
SETTINGS_SET_SERVER: 設定Server
|
||||
SETTINGS_CHECK_UPDATE: 檢查更新
|
||||
SETTINGS_UPDATE_CHECK_RESULT: 最新版本為 ${version},${action}
|
||||
SETTINGS_UPDATE_CHECK_NO_UPDATE: 無需更新
|
||||
SETTINGS_UPDATE_CHECK_CAN_UPDATE: 可以更新
|
||||
SETTINGS_OPEN_UPDATE_HELPER: 打開更新助手
|
||||
SETTINGS_OPEN: 開
|
||||
SETTINGS_CLOSE: 關
|
||||
@@ -253,21 +312,21 @@ SHORTCUT_DISABLE: 禁用
|
||||
SHORTCUT_EDIT: 編輯
|
||||
SHORTCUT_CHANGE_UPLOAD: 修改上傳快捷鍵
|
||||
|
||||
# gallery-page
|
||||
GALLERY_URL_REWRITE_TITLE: 重寫選中圖片 URL
|
||||
GALLERY_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
|
||||
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
|
||||
# album-page
|
||||
ALBUM_URL_REWRITE_TITLE: 重寫選中圖片 URL
|
||||
ALBUM_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
|
||||
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
|
||||
|
||||
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
|
||||
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
|
||||
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
|
||||
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
|
||||
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
|
||||
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
|
||||
GALLERY_URL_REWRITE_APPLY_ONLY: 僅套用
|
||||
GALLERY_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
|
||||
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
|
||||
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
|
||||
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
|
||||
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
|
||||
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
|
||||
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
|
||||
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
|
||||
ALBUM_URL_REWRITE_APPLY_ONLY: 僅套用
|
||||
ALBUM_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
|
||||
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
|
||||
|
||||
# tray-page
|
||||
|
||||
@@ -290,9 +349,12 @@ TIPS_INPUT_VALID_URL: 請輸入合法的URL
|
||||
# plugins
|
||||
|
||||
PLUGIN_SEARCH_PLACEHOLDER: 搜尋npm上的PicGo插件,或者點擊上方按鈕查看優秀插件列表
|
||||
PLUGIN_SEARCH_EXACT_MATCH: 插件名精確匹配
|
||||
PLUGIN_INSTALL: 安裝
|
||||
PLUGIN_INSTALLING: 安裝中
|
||||
PLUGIN_INSTALLED: 已安裝
|
||||
PLUGIN_DEPRECATED_BADGE: 已棄用
|
||||
PLUGIN_DEPRECATED_TITLE: 此插件已被作者標記為棄用
|
||||
PLUGIN_DOING_SOMETHING: 進行中
|
||||
PLUGIN_LIST: 插件列表
|
||||
PLUGIN_IMPORT_LOCAL: 導入本地插件
|
||||
@@ -331,7 +393,7 @@ TOOLBOX: 工具箱
|
||||
TOOLBOX_TITLE: 排查 PicGo 執行時問題
|
||||
TOOLBOX_SUB_TITLE: 立即掃描以下項目,修復使用問題
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 檢查配置文件是否損壞
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: 檢查相冊文件是否損壞
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: 檢查相冊文件是否損壞
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: 檢查剪貼板圖片上傳是否存在問題
|
||||
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: 檢查代理設置是否正常
|
||||
TOOLBOX_FIX_DONE_NEED_RELOAD: 修復完成,需要重啓生效,是否重啓
|
||||
@@ -342,8 +404,8 @@ TOOLBOX_START_FIX: 開始修復
|
||||
TOOLBOX_SUCCESS_TIPS: 恭喜你,沒有檢查出問題
|
||||
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: 配置文件路徑是:${path}
|
||||
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 配置文件已損壞
|
||||
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: 相冊文件路徑是:${path}
|
||||
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: 相冊文件已損壞
|
||||
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: 相冊文件路徑是:${path}
|
||||
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: 相冊文件已損壞
|
||||
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: 代理設置正常
|
||||
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: 無代理設置
|
||||
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: 代理設置不正確
|
||||
@@ -360,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: 已跳過 ${n} 條非法 URL,請查看日誌了解
|
||||
TIPS_TOO_MANY_URLS_CONFIRM: 你將一次上傳 ${n} 條 URL,可能會引起卡頓,建議分批上傳。是否繼續?
|
||||
TIPS_NO_VALID_URLS: 未偵測到合法的 URL
|
||||
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 請安裝Node.js並重新啟動PicGo再繼續操作
|
||||
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
|
||||
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在試圖覆蓋相簿列表,是否繼續?
|
||||
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
|
||||
TIPS_PLUGIN_OVERWRITE_ALBUM: 有插件正在試圖覆蓋相簿列表,是否繼續?
|
||||
TIPS_UPLOAD_NOT_PICTURES: 剪貼簿最新的一條記錄不是圖片
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo 設定檔案已損壞,已經恢復為預設設定
|
||||
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo 設定檔案已損壞,已經恢復為備份設定
|
||||
@@ -381,3 +443,151 @@ TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 無法刪除最後一個配置
|
||||
PRIVACY: "使用前請閱讀並同意隱私政策 ${privacyUrl} 與服務條款 ${termsUrl},同意後方可使用。"
|
||||
PRIVACY_TIPS: 請同意隱私協議,否則無法上傳。
|
||||
QUIT: 退出
|
||||
ALBUM_ALL_PHOTOS: 全部相片
|
||||
ALBUM_COLLECTIONS: 合輯
|
||||
ALBUM_TAGS: 標籤
|
||||
ALBUM_MENU: 選單
|
||||
ALBUM_OPEN_INSPECTOR: 開啟檢查器
|
||||
ALBUM_INSPECTOR_TITLE: 相簿檢查器
|
||||
ALBUM_INSPECTOR_DESCRIPTION: 檢視並編輯已選圖片。
|
||||
ALBUM_CLEAR_SELECTION: 取消選取
|
||||
ALBUM_SELECTED_COUNT: 已選 ${count} 項
|
||||
ALBUM_GRID_VIEW: 網格檢視
|
||||
ALBUM_LIST_VIEW: 清單檢視
|
||||
ALBUM_PREVIEW: 全螢幕預覽
|
||||
ALBUM_PREVIEW_EMPTY: 沒有可預覽的圖片
|
||||
ALBUM_PREVIEW_PREV: 上一張
|
||||
ALBUM_PREVIEW_NEXT: 下一張
|
||||
ALBUM_PREVIEW_CLOSE: 關閉預覽
|
||||
ALBUM_PREVIEW_COUNT: ${current} / ${total}
|
||||
ALBUM_QUICK_ACTIONS: 快速操作
|
||||
ALBUM_EXPORT: 下載
|
||||
ALBUM_URL: URL
|
||||
ALBUM_BATCH_REWRITE: 批次重寫
|
||||
ALBUM_COLLECTION: 合輯
|
||||
ALBUM_ADD_TAG: 新增標籤
|
||||
ALBUM_TAG_SUGGESTIONS: 建議標籤
|
||||
ALBUM_COLUMN_NAME: 名稱
|
||||
ALBUM_COLUMN_PROVIDER: 圖床
|
||||
ALBUM_COLUMN_SIZE: 大小
|
||||
ALBUM_COLUMN_DATE: 日期
|
||||
ALBUM_ADD: 新增
|
||||
ALBUM_SOURCE_LOCAL: 本機
|
||||
ALBUM_SOURCE_CLOUD: 雲端
|
||||
ALBUM_CLOUD_LOAD_FAILED: 載入雲端相簿失敗
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: 需要登入
|
||||
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: 登入 PicGo Cloud 付費帳戶以使用雲端相簿
|
||||
ALBUM_CLOUD_LOGIN_BUTTON: 前往登入
|
||||
ALBUM_CLOUD_UPGRADE_TITLE: 需要升級
|
||||
ALBUM_CLOUD_UPGRADE_DESC: 雲端相簿需要付費方案
|
||||
ALBUM_CLOUD_UPGRADE_BUTTON: 升級方案
|
||||
ALBUM_CLOUD_FEATURES_TITLE: 為什麼使用雲端相簿?
|
||||
ALBUM_CLOUD_FEATURE_SYNC: 多裝置存取上傳歷史記錄
|
||||
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 上傳後自動匯入相簿記錄到雲端(需開啟該功能)
|
||||
ALBUM_CLOUD_FEATURE_SECURE: 一鍵匯入本機歷史到雲端
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: 匯入本機記錄
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_DESC: 將本機上傳記錄匯入到雲端相簿。僅匯入記錄,圖片檔案本身不會被上傳。
|
||||
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: 開始匯入
|
||||
ALBUM_CLOUD_EMPTY_TITLE: 暫無內容
|
||||
ALBUM_CLOUD_EMPTY_DESC: 雲端相簿為空,上傳圖片即可開始使用。
|
||||
ALBUM_CLOUD_IMPORTING: 正在匯入到雲端相簿...
|
||||
ALBUM_CLOUD_IMPORT_SUCCESS: 成功匯入 ${num} 條記錄到雲端相簿
|
||||
ALBUM_CLOUD_IMPORT_FAILED: 匯入雲端相簿失敗
|
||||
ALBUM_CLOUD_REFRESH: 重新整理
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 開啟自動匯入
|
||||
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: 此操作將開啟自動匯入功能,後續上傳的圖片記錄會自動同步到雲端相簿。同時會將現有的本地記錄一併匯入。
|
||||
ALBUM_CLOUD_DELETE_FAILED: 從雲端相簿刪除失敗
|
||||
ALBUM_INSPECTOR_DETAILS_TITLE: 詳情
|
||||
ALBUM_INSPECTOR_FILE_NAME: 檔案名稱
|
||||
ALBUM_INSPECTOR_UPLOADER: 上傳器
|
||||
ALBUM_INSPECTOR_CONTENT_TYPE: 內容類型
|
||||
ALBUM_INSPECTOR_CREATED_AT: 建立時間
|
||||
ALBUM_INSPECTOR_UPDATED_AT: 更新時間
|
||||
ALBUM_INSPECTOR_FILE_SIZE: 檔案大小
|
||||
ALBUM_CLOUD_IMPORT_STATUS_TITLE: 雲端相簿
|
||||
ALBUM_CLOUD_IMPORTED: 已匯入雲端
|
||||
ALBUM_CLOUD_IMPORTED_TOOLTIP: 這是本機標記,不會與雲端同步。如果雲端記錄已被刪除,此狀態不會更新。你可以重新匯入。
|
||||
ALBUM_CLOUD_REIMPORT: 重新匯入
|
||||
ALBUM_CLOUD_NATIVE: 透過 PicGo Cloud 上傳
|
||||
ALBUM_CLOUD_IMPORT_BUTTON: 匯入到雲端相簿
|
||||
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: 匯入 ${num} 條記錄到雲端相簿
|
||||
ALBUM_CLOUD_ALL_IMPORTED: 已全部在雲端相簿中
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 需要開啟自動匯入
|
||||
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: 匯入到雲端相簿前需要先開啟自動匯入功能。是否立即開啟並繼續匯入?
|
||||
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 開啟並匯入
|
||||
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: 已成功匯入到雲端相簿
|
||||
PICGO_CLOUD_AUTO_IMPORT_LABEL: 自動匯入雲端相簿
|
||||
PICGO_CLOUD_AUTO_IMPORT_DESC: 自動將其他圖床的上傳記錄匯入雲端相簿(PicGo Cloud 預設已寫入)
|
||||
SIDEBAR_PLUGINS: 插件
|
||||
SIDEBAR_SYSTEM: 系統
|
||||
SIDEBAR_NOTIFICATIONS: 通知
|
||||
SIDEBAR_EXPAND: 點擊展開
|
||||
SIDEBAR_COLLAPSE: 收合側邊欄
|
||||
HISTORY_PANEL_TITLE: 歷史記錄
|
||||
HISTORY_PANEL_FILTER_PLACEHOLDER: 篩選...
|
||||
HISTORY_PANEL_TODAY: 今天
|
||||
HISTORY_PANEL_YESTERDAY: 昨天
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS: 沒有可見圖床
|
||||
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: 目前所有圖床都被隱藏了,請前往設定調整可見圖床。
|
||||
DASHBOARD_OPEN_SETTINGS: 打開設定
|
||||
DASHBOARD_DROP_IMAGES_HERE: 將檔案拖曳到此處
|
||||
DASHBOARD_OR: 或
|
||||
DASHBOARD_CLICK_TO_UPLOAD: 點擊上傳
|
||||
DASHBOARD_PASTE_FROM_CLIPBOARD: 從剪貼簿貼上
|
||||
DASHBOARD_PASTE_FROM_URL: 從 URL 貼上
|
||||
DASHBOARD_CLIPBOARD: 剪貼簿
|
||||
FIELD_IS_REQUIRED: ${field} 為必填項
|
||||
CONFIG_RENAME: 配置改名
|
||||
SETTINGS_SECTION_GENERAL: 一般
|
||||
SETTINGS_SECTION_APPEARANCE: 外觀
|
||||
SETTINGS_SECTION_UPLOAD_WORKFLOW: 上傳流程
|
||||
SETTINGS_SECTION_NETWORK: 網路
|
||||
SETTINGS_SECTION_ADVANCED: 進階
|
||||
SETTINGS_SECTION_ABOUT: 關於
|
||||
SETTINGS_NO_RESULTS_TITLE: 沒有符合的設定項
|
||||
SETTINGS_NO_RESULTS_DESCRIPTION: 請嘗試其他關鍵字,或清空搜尋條件。
|
||||
SETTINGS_OPEN_SHORTCUTS: 快捷鍵
|
||||
SETTINGS_LINK_WEBSITE: 官網
|
||||
SETTINGS_LINK_GITHUB: GitHub
|
||||
SETTINGS_LINK_DOCS: 文件
|
||||
SETTINGS_LINK_PRIVACY: 隱私政策
|
||||
SETTINGS_LINK_TERMS: 服務條款
|
||||
SETTINGS_APPEARANCE_MODE: 主題模式
|
||||
SETTINGS_APPEARANCE_MODE_LIGHT: 淺色
|
||||
SETTINGS_APPEARANCE_MODE_DARK: 深色
|
||||
SETTINGS_APPEARANCE_MODE_AUTO: 跟隨系統
|
||||
COMMON_FOR_EXAMPLE: 例如:${value}
|
||||
SETTINGS_SET_DEFAULT_CONFIG: 設為預設配置
|
||||
PROVIDER_DRAFT_CONFIG: 草稿
|
||||
PROVIDER_ACTIVE_UPLOADER_LABEL: 目前
|
||||
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} 是目前使用的上傳器
|
||||
PROVIDER_DEFAULT_CONFIG_LABEL: 預設
|
||||
PROVIDER_DEFAULT_CONFIG_TOOLTIP: 當 ${uploaderName} 處於目前啟用狀態時使用。
|
||||
PROVIDER_SIDEBAR_EMPTY: 找不到圖床或配置。
|
||||
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} 操作
|
||||
PROVIDER_SIDEBAR_COLLAPSE: 收合
|
||||
PROVIDER_SIDEBAR_EXPAND: 展開
|
||||
PROVIDER_CONFIG_ACTIONS: 配置操作
|
||||
PROVIDER_CREATE_CONFIG: 新增配置
|
||||
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: 該上傳器沒有配置項,無需新增配置
|
||||
PROVIDER_INSTALL_MORE_UPLOADERS: 安裝更多圖床
|
||||
PROVIDER_NO_UPLOADER_SELECTED: 尚未選擇圖床。
|
||||
PROVIDER_NO_CONFIG_YET: 尚無配置
|
||||
PROVIDER_NO_CONFIG_DESCRIPTION: 為 ${uploaderName} 建立一個新配置後即可開始編輯配置項。
|
||||
PROVIDER_CONFIGURATION: 配置項
|
||||
PROVIDER_UPDATED_AT_LABEL: 更新時間
|
||||
PROVIDER_DELETE_CONFIG_HINT: 此操作會永久刪除目前配置。
|
||||
UPLOADER_SWITCHER_SELECT_PROVIDER: 選擇圖床
|
||||
UPLOADER_SWITCHER_CONFIG: 配置
|
||||
UPLOADER_SWITCHER_NO_CONFIG: 暫無配置
|
||||
ALBUM_URL_REWRITE_DESCRIPTION: 批量重寫所選圖片的 URL。
|
||||
ALBUM_URL_REWRITE_CHANGED: 已修改
|
||||
ALBUM_URL_REWRITE_UNCHANGED: 未修改
|
||||
TRAY_ALREADY_UPLOAD_EMPTY: 暫無已上傳圖片
|
||||
PLUGIN_EMPTY: 尚未找到任何插件
|
||||
PLUGIN_EMPTY_DESCRIPTION: 可以先在上方搜尋並安裝一些插件
|
||||
PLUGIN_DETAIL: 詳情
|
||||
PLUGIN_NO_CONFIG_SCHEMA: 該插件未暴露 config 配置項。
|
||||
PLUGIN_NO_TRANSFORMER_SCHEMA: 該插件未暴露 transformer 配置項。
|
||||
PLUGIN_NO_README: 該插件暫無 README 內容。
|
||||
NO_OPTIONS_FOUND: 沒有可選項
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/* eslint-disable @stylistic/indent */
|
||||
const yaml = require('js-yaml')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const languageFileName = 'zh-CN.yml' // use zh-CN for type is OK
|
||||
const i18nFolder = path.join(__dirname, '../public/i18n')
|
||||
const typeFolder = path.join(__dirname, '../src/universal/types')
|
||||
const languageFile = path.join(i18nFolder, languageFileName)
|
||||
|
||||
const langFile = fs.readFileSync(languageFile, 'utf8')
|
||||
|
||||
const obj = yaml.load(langFile)
|
||||
|
||||
const keys = Object.keys(obj)
|
||||
|
||||
const types =
|
||||
`interface ILocales {
|
||||
${keys.map(key => `${key}: string`).join('\n ')}
|
||||
}
|
||||
type ILocalesKey = keyof ILocales
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(typeFolder, 'i18n.d.ts'), types)
|
||||
@@ -0,0 +1,79 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const yaml = require('js-yaml')
|
||||
|
||||
const distDir = path.join(__dirname, '../dist')
|
||||
const yamlPath = path.join(distDir, 'latest.yml')
|
||||
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
console.log('⚠️ latest.yml not found in dist/. Skipping update.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`Reading ${yamlPath}...`)
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8')
|
||||
let doc
|
||||
|
||||
try {
|
||||
doc = yaml.load(yamlContent)
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to parse latest.yml:', e)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get all .exe files in dist directory
|
||||
const files = fs.readdirSync(distDir).filter(f => f.endsWith('.exe'))
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('⚠️ No .exe files found in dist/.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let updated = false
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(distDir, file)
|
||||
|
||||
// 1. Calculate new Hash and Size
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
const hash = crypto.createHash('sha512').update(buffer).digest('base64')
|
||||
const size = fs.statSync(filePath).size
|
||||
|
||||
console.log(`Processing ${file}:`)
|
||||
console.log(` -> New Hash: ${hash}`)
|
||||
console.log(` -> New Size: ${size}`)
|
||||
|
||||
// 2. Update entries in 'files' list
|
||||
if (Array.isArray(doc.files)) {
|
||||
const fileEntry = doc.files.find(f => f.url === file)
|
||||
if (fileEntry) {
|
||||
fileEntry.sha512 = hash
|
||||
fileEntry.size = size
|
||||
updated = true
|
||||
console.log(' -> Updated file entry in yaml object')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update root path entry (if exists)
|
||||
// electron-builder's latest.yml usually has root path, sha512, size
|
||||
// corresponding to the main file of current build (usually x64 or current arch)
|
||||
if (doc.path === file) {
|
||||
doc.sha512 = hash
|
||||
doc.size = size
|
||||
updated = true
|
||||
console.log(' -> Updated root path entry in yaml object')
|
||||
}
|
||||
})
|
||||
|
||||
if (updated) {
|
||||
// 4. Dump back to file
|
||||
// lineWidth: -1 prevents long strings from wrapping, keeping it clean
|
||||
const newYamlContent = yaml.dump(doc, { lineWidth: -1 })
|
||||
fs.writeFileSync(yamlPath, newYamlContent, 'utf8')
|
||||
console.log('✅ latest.yml updated successfully.')
|
||||
console.log('New yaml content:')
|
||||
console.log(newYamlContent)
|
||||
} else {
|
||||
console.log('⚠️ No matching entries found in latest.yml to update.')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const I18N_SOURCE_DIR = 'public/i18n'
|
||||
const SOURCE_RELATIVE_PATH = `${I18N_SOURCE_DIR}/en.yml`
|
||||
const UNIVERSAL_OUTPUT_RELATIVE_PATH = 'src/universal/types/i18n.d.ts'
|
||||
const RENDERER_OUTPUT_RELATIVE_PATH = 'src/renderer/i18n/i18next.d.ts'
|
||||
|
||||
function formatKey (key: string) {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
|
||||
}
|
||||
|
||||
function readTranslationKeys (sourcePath: string) {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(sourcePath, 'utf8')
|
||||
const data = parse(raw)
|
||||
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.keys(data as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function buildUniversalTypeContent (keys: string[]) {
|
||||
const uniqueKeys = Array.from(new Set(keys))
|
||||
const lines = uniqueKeys.map((key) => ` ${formatKey(key)}: string`)
|
||||
|
||||
return `// This file is auto-generated by vite-plugin-i18n-types. Do not edit.
|
||||
interface ILocales {
|
||||
${lines.join('\n')}
|
||||
}
|
||||
|
||||
type ILocalesKey = keyof ILocales
|
||||
`
|
||||
}
|
||||
|
||||
function buildRendererTypeContent () {
|
||||
return `// This file is auto-generated by vite-plugin-i18n-types. Do not edit.
|
||||
import "i18next"
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
resources: {
|
||||
translation: ILocales
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function writeFileIfChanged (outputPath: string, nextContent: string) {
|
||||
const previousContent = fs.existsSync(outputPath)
|
||||
? fs.readFileSync(outputPath, 'utf8')
|
||||
: ''
|
||||
|
||||
if (previousContent === nextContent) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
fs.writeFileSync(outputPath, nextContent, 'utf8')
|
||||
}
|
||||
|
||||
function writeTypesFiles (sourcePath: string, universalOutputPath: string, rendererOutputPath: string) {
|
||||
const keys = readTranslationKeys(sourcePath)
|
||||
writeFileIfChanged(universalOutputPath, buildUniversalTypeContent(keys))
|
||||
writeFileIfChanged(rendererOutputPath, buildRendererTypeContent())
|
||||
}
|
||||
|
||||
export function i18nTypesPlugin (): Plugin {
|
||||
let sourcePath = ''
|
||||
let universalOutputPath = ''
|
||||
let rendererOutputPath = ''
|
||||
let projectRoot = ''
|
||||
|
||||
const generate = () => {
|
||||
try {
|
||||
writeTypesFiles(sourcePath, universalOutputPath, rendererOutputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[vite-plugin-i18n-types] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-i18n-types',
|
||||
enforce: 'pre',
|
||||
configResolved (config) {
|
||||
projectRoot = path.resolve(config.root, '../..')
|
||||
sourcePath = path.resolve(projectRoot, SOURCE_RELATIVE_PATH)
|
||||
universalOutputPath = path.resolve(projectRoot, UNIVERSAL_OUTPUT_RELATIVE_PATH)
|
||||
rendererOutputPath = path.resolve(projectRoot, RENDERER_OUTPUT_RELATIVE_PATH)
|
||||
},
|
||||
buildStart () {
|
||||
generate()
|
||||
},
|
||||
configureServer (server) {
|
||||
server.watcher.add(path.resolve(projectRoot, I18N_SOURCE_DIR))
|
||||
},
|
||||
handleHotUpdate ({ file }) {
|
||||
const normalizedFile = path.resolve(file)
|
||||
const normalizedSourceDir = path.resolve(path.dirname(sourcePath))
|
||||
|
||||
if (normalizedFile.startsWith(normalizedSourceDir)) {
|
||||
generate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import fs from 'fs-extra'
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
import { IPicGoCloudConfigSyncToastType } from '#/types/cloudConfigSync'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => os.tmpdir()),
|
||||
getAppPath: vi.fn(() => process.cwd())
|
||||
}
|
||||
}))
|
||||
|
||||
type OnAskEncryptionSwitch = (context: { from: string; to: string }) => Promise<boolean>
|
||||
|
||||
type SyncImplementation = (askSwitch?: OnAskEncryptionSwitch) => Promise<{ status: string; message?: string }>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RELEASE_URL, RELEASE_URL_BACKUP } from '#/utils/static'
|
||||
import { getLatestVersion } from '~/main/utils/getLatestVersion'
|
||||
|
||||
const { axiosGetMock } = vi.hoisted(() => {
|
||||
return {
|
||||
axiosGetMock: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
get: axiosGetMock
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('main/utils/getLatestVersion', () => {
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns stable release when beta channel is older', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ tag_name: 'v2.5.2', prerelease: false, draft: false },
|
||||
{ tag_name: 'v2.4.2-beta.0', prerelease: true, draft: false }
|
||||
]
|
||||
})
|
||||
|
||||
const version = await getLatestVersion(true)
|
||||
|
||||
expect(version).toBe('2.5.2')
|
||||
expect(axiosGetMock).toHaveBeenCalledWith(RELEASE_URL, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns prerelease when beta channel has a newer version', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ tag_name: 'v2.5.2', prerelease: false, draft: false },
|
||||
{ tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false }
|
||||
]
|
||||
})
|
||||
|
||||
const version = await getLatestVersion(true)
|
||||
|
||||
expect(version).toBe('2.6.0-beta.1')
|
||||
})
|
||||
|
||||
it('ignores prerelease when beta updates are disabled', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false },
|
||||
{ tag_name: 'v2.5.2', prerelease: false, draft: false }
|
||||
]
|
||||
})
|
||||
|
||||
const version = await getLatestVersion(false)
|
||||
|
||||
expect(version).toBe('2.5.2')
|
||||
})
|
||||
|
||||
it('fallback compares stable and beta backup metadata when beta updates are enabled', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network down'))
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: 'version: 2.5.2'
|
||||
})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: 'version: 2.4.2-beta.0'
|
||||
})
|
||||
|
||||
const version = await getLatestVersion(true)
|
||||
|
||||
expect(version).toBe('2.5.2')
|
||||
expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
})
|
||||
expect(axiosGetMock).toHaveBeenNthCalledWith(3, `${RELEASE_URL_BACKUP}/latest.beta.yml`, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('fallback uses only stable backup metadata when beta updates are disabled', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network down'))
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: 'version: 2.5.2'
|
||||
})
|
||||
|
||||
const version = await getLatestVersion(false)
|
||||
|
||||
expect(version).toBe('2.5.2')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(2)
|
||||
expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||
|
||||
type ConfigRecord = Record<string, unknown>
|
||||
|
||||
function isConfigRecord(value: unknown): value is ConfigRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (!isConfigRecord(current)) {
|
||||
return undefined
|
||||
}
|
||||
return current[key]
|
||||
}, value)
|
||||
}
|
||||
|
||||
describe('createSchemaOnlyUploaderContext', () => {
|
||||
it('hides only the target uploader configuration without mutating the source', () => {
|
||||
const config = {
|
||||
picBed: {
|
||||
current: 'tcyun',
|
||||
tcyun: {
|
||||
secretId: 'existing-secret-id',
|
||||
secretKey: 'existing-secret-key'
|
||||
},
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
},
|
||||
uploader: {
|
||||
tcyun: {
|
||||
defaultId: 'config-1'
|
||||
},
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
proxy: 'http://localhost:7890'
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
marker: 'original-context',
|
||||
getConfig<T>(name?: string): T {
|
||||
return (name ? getByPath(config, name) : config) as T
|
||||
}
|
||||
}
|
||||
|
||||
const schemaContext = createSchemaOnlyUploaderContext(context, 'tcyun')
|
||||
|
||||
expect(schemaContext.getConfig('picBed.tcyun')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('picBed.tcyun.secretKey')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('uploader.tcyun')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('settings.proxy')).toBe('http://localhost:7890')
|
||||
expect(schemaContext.marker).toBe('original-context')
|
||||
|
||||
expect(schemaContext.getConfig<ConfigRecord>('picBed')).toEqual({
|
||||
current: 'tcyun',
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
})
|
||||
expect(schemaContext.getConfig<ConfigRecord>('uploader')).toEqual({
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
})
|
||||
expect(schemaContext.getConfig<ConfigRecord>()).toEqual({
|
||||
picBed: {
|
||||
current: 'tcyun',
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
},
|
||||
uploader: {
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
proxy: 'http://localhost:7890'
|
||||
}
|
||||
})
|
||||
|
||||
expect(context.getConfig('picBed.tcyun')).toEqual({
|
||||
secretId: 'existing-secret-id',
|
||||
secretKey: 'existing-secret-key'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ServerConfig = {
|
||||
port: number | string
|
||||
@@ -9,49 +6,18 @@ type ServerConfig = {
|
||||
enable: boolean
|
||||
}
|
||||
|
||||
type HonoContextLike = {
|
||||
req: {
|
||||
raw: Request
|
||||
formData: () => Promise<FormData>
|
||||
}
|
||||
json: (data: unknown, status?: number) => Response
|
||||
type ServerUploadAdapter = {
|
||||
uploadClipboard: () => Promise<ImgInfo[] | Error>
|
||||
uploadPaths: (paths: string[]) => Promise<ImgInfo[] | Error>
|
||||
getTempDir: () => string
|
||||
}
|
||||
|
||||
type UploadHandler = (c: HonoContextLike) => Promise<Response>
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
const createJsonContext = (req: Request): HonoContextLike => {
|
||||
return {
|
||||
req: {
|
||||
raw: req,
|
||||
formData: async () => new FormData()
|
||||
},
|
||||
json: (data: unknown, status: number = 200) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readJson = async (res: Response): Promise<any> => {
|
||||
const text = await res.text()
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
let serverConfig: ServerConfig | undefined
|
||||
let registeredUploadHandler: UploadHandler | undefined
|
||||
let formImageDir: string
|
||||
let installedUploadAdapter: ServerUploadAdapter | undefined
|
||||
|
||||
const getConfigMock = vi.fn((key?: string) => {
|
||||
if (key === 'settings.server') return serverConfig
|
||||
@@ -66,13 +32,10 @@ const saveConfigMock = vi.fn((patch: unknown) => {
|
||||
}
|
||||
})
|
||||
|
||||
const registerPostMock = vi.fn((routePath: string, handler: unknown, isInternal?: boolean) => {
|
||||
if (routePath === '/upload' && typeof handler === 'function') {
|
||||
registeredUploadHandler = handler as unknown as UploadHandler
|
||||
}
|
||||
return { routePath, isInternal }
|
||||
const setUploadAdapterMock = vi.fn((adapter?: ServerUploadAdapter) => {
|
||||
installedUploadAdapter = adapter
|
||||
})
|
||||
|
||||
const registerPostMock = vi.fn()
|
||||
const listenMock = vi.fn()
|
||||
const shutdownMock = vi.fn()
|
||||
|
||||
@@ -83,24 +46,9 @@ const loggerMock = {
|
||||
}
|
||||
|
||||
const getAvailableWindowMock = vi.fn()
|
||||
const uploadClipboardFilesMock = vi.fn()
|
||||
const uploadSelectedFilesMock = vi.fn()
|
||||
|
||||
const dbPathDirMock = vi.fn(() => path.join(os.tmpdir(), 'picgo-gui-store'))
|
||||
const getFormImageFolderPathMock = vi.fn(() => formImageDir)
|
||||
|
||||
const cleanupFormUploaderFilesMock = vi.fn((fileInfoList?: unknown) => {
|
||||
if (!Array.isArray(fileInfoList)) return
|
||||
for (const item of fileInfoList) {
|
||||
if (typeof item === 'string') {
|
||||
try {
|
||||
fs.removeSync(item)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const uploadClipboardFilesWithInfoMock = vi.fn()
|
||||
const uploadSelectedFilesWithInfoMock = vi.fn()
|
||||
const getFormImageFolderPathMock = vi.fn(() => '/tmp/picgo-form-images')
|
||||
|
||||
vi.mock('@core/picgo', () => {
|
||||
return {
|
||||
@@ -108,6 +56,7 @@ vi.mock('@core/picgo', () => {
|
||||
getConfig: getConfigMock,
|
||||
saveConfig: saveConfigMock,
|
||||
server: {
|
||||
setUploadAdapter: setUploadAdapterMock,
|
||||
registerPost: registerPostMock,
|
||||
listen: listenMock,
|
||||
shutdown: shutdownMock
|
||||
@@ -130,38 +79,26 @@ vi.mock('apis/app/window/windowManager', () => {
|
||||
|
||||
vi.mock('apis/app/uploader/apis', () => {
|
||||
return {
|
||||
uploadClipboardFiles: uploadClipboardFilesMock,
|
||||
uploadSelectedFiles: uploadSelectedFilesMock
|
||||
uploadClipboardFilesWithInfo: uploadClipboardFilesWithInfoMock,
|
||||
uploadSelectedFilesWithInfo: uploadSelectedFilesWithInfoMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('apis/core/datastore/dbChecker', () => {
|
||||
return {
|
||||
dbPathDir: dbPathDirMock,
|
||||
getFormImageFolderPath: getFormImageFolderPathMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/main/utils/cleanupFormUploaderFiles', () => {
|
||||
return {
|
||||
cleanupFormUploaderFiles: cleanupFormUploaderFilesMock
|
||||
}
|
||||
})
|
||||
|
||||
describe('main/server (GUI adapter to picgo-core)', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
serverConfig = undefined
|
||||
registeredUploadHandler = undefined
|
||||
formImageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picgo-gui-form-'))
|
||||
installedUploadAdapter = undefined
|
||||
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(formImageDir)
|
||||
})
|
||||
|
||||
it('backfills default settings.server when missing', async () => {
|
||||
serverConfig = undefined
|
||||
|
||||
@@ -181,129 +118,71 @@ describe('main/server (GUI adapter to picgo-core)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not listen when settings.server.enable is false', async () => {
|
||||
it('does not install upload adapter or listen when settings.server.enable is false', async () => {
|
||||
serverConfig = { port: 36677, host: '127.0.0.1', enable: false }
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
|
||||
server.startup()
|
||||
|
||||
expect(setUploadAdapterMock).not.toHaveBeenCalled()
|
||||
expect(registerPostMock).not.toHaveBeenCalled()
|
||||
expect(listenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers internal /upload override and delegates listen/shutdown to picgo.server', async () => {
|
||||
it('installs GUI upload adapter once and delegates listen/shutdown to picgo.server', async () => {
|
||||
serverConfig = { port: '36677', host: '127.0.0.1', enable: true }
|
||||
listenMock.mockResolvedValue(36677)
|
||||
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
|
||||
server.startup()
|
||||
server.startup()
|
||||
|
||||
expect(registerPostMock).toHaveBeenCalledTimes(1)
|
||||
expect(registerPostMock).toHaveBeenCalledWith('/upload', expect.any(Function), true)
|
||||
expect(setUploadAdapterMock).toHaveBeenCalledTimes(1)
|
||||
expect(registerPostMock).not.toHaveBeenCalled()
|
||||
expect(listenMock).toHaveBeenCalledTimes(2)
|
||||
expect(listenMock).toHaveBeenCalledWith(36677, '127.0.0.1')
|
||||
expect(installedUploadAdapter).toBeDefined()
|
||||
|
||||
server.shutdown()
|
||||
expect(shutdownMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('implements GUI-compatible /upload JSON semantics with core-style status codes', async () => {
|
||||
it('GUI upload adapter preserves side-effect upload helpers and returns raw ImgInfo[] for Core responses', async () => {
|
||||
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
|
||||
uploadClipboardFilesMock.mockResolvedValue('https://a.example/clipboard.png')
|
||||
uploadSelectedFilesMock.mockResolvedValue(['https://a.example/a.png'])
|
||||
getAvailableWindowMock.mockReturnValue({ webContents: {} })
|
||||
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
server.startup()
|
||||
|
||||
expect(registeredUploadHandler).toBeDefined()
|
||||
const handler = registeredUploadHandler!
|
||||
|
||||
const resEmpty = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST' })))
|
||||
expect(resEmpty.status).toBe(200)
|
||||
expect(await readJson(resEmpty)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
|
||||
|
||||
const resObj = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST', body: '{}' })))
|
||||
expect(resObj.status).toBe(200)
|
||||
expect(await readJson(resObj)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
|
||||
|
||||
const resEmptyList = await handler(createJsonContext(new Request('http://127.0.0.1/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ list: [] })
|
||||
})))
|
||||
expect(resEmptyList.status).toBe(200)
|
||||
expect(await readJson(resEmptyList)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
|
||||
|
||||
const resList = await handler(createJsonContext(new Request('http://127.0.0.1/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ list: ['/a.png'] })
|
||||
})))
|
||||
expect(resList.status).toBe(200)
|
||||
expect(await readJson(resList)).toEqual({ success: true, result: ['https://a.example/a.png'] })
|
||||
|
||||
const resInvalid = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST', body: '{' })))
|
||||
expect(resInvalid.status).toBe(400)
|
||||
expect(await readJson(resInvalid)).toMatchObject({ success: false })
|
||||
})
|
||||
|
||||
it('writes multipart files to fixed temp folder and always cleans up (success/failure)', async () => {
|
||||
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
|
||||
getAvailableWindowMock.mockReturnValue({ webContents: {} })
|
||||
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
server.startup()
|
||||
|
||||
const handler = registeredUploadHandler!
|
||||
|
||||
const makeMultipartContext = async (): Promise<{ ctx: HonoContextLike; expectedPath: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('files', new Blob([Buffer.from('hello')], { type: 'image/png' }), 'a.png')
|
||||
|
||||
const req = new Request('http://127.0.0.1/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const expectedPath = path.join(formImageDir, 'a.png')
|
||||
return {
|
||||
ctx: {
|
||||
req: {
|
||||
raw: req,
|
||||
formData: async () => fd
|
||||
},
|
||||
json: (data: unknown, status: number = 200) => new Response(JSON.stringify(data), { status })
|
||||
},
|
||||
expectedPath
|
||||
}
|
||||
const webContents = { send: vi.fn() }
|
||||
const clipboardImage: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/clipboard image.png',
|
||||
fileName: 'clipboard image.png',
|
||||
extname: '.png',
|
||||
size: 123
|
||||
}
|
||||
const selectedImages: ImgInfo[] = [{
|
||||
imgUrl: 'https://raw.example/a image.png',
|
||||
fileName: 'a image.png',
|
||||
extname: '.png',
|
||||
origin: '/tmp/a.png',
|
||||
width: 10
|
||||
}]
|
||||
getAvailableWindowMock.mockReturnValue({ webContents })
|
||||
uploadClipboardFilesWithInfoMock.mockResolvedValue([clipboardImage])
|
||||
uploadSelectedFilesWithInfoMock.mockResolvedValue(selectedImages)
|
||||
|
||||
// success case
|
||||
uploadSelectedFilesMock.mockImplementation(async (_webContents: unknown, list: Array<{ path: string }>) => {
|
||||
for (const item of list) {
|
||||
expect(await fs.pathExists(item.path)).toBe(true)
|
||||
}
|
||||
return ['https://a.example/form.png']
|
||||
})
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
server.startup()
|
||||
|
||||
const { ctx: ctxSuccess, expectedPath: pathSuccess } = await makeMultipartContext()
|
||||
const resSuccess = await handler(ctxSuccess)
|
||||
expect(resSuccess.status).toBe(200)
|
||||
expect(await readJson(resSuccess)).toEqual({ success: true, result: ['https://a.example/form.png'] })
|
||||
expect(await fs.pathExists(pathSuccess)).toBe(false)
|
||||
expect(installedUploadAdapter).toBeDefined()
|
||||
const adapter = installedUploadAdapter!
|
||||
|
||||
// failure case still cleans up
|
||||
uploadSelectedFilesMock.mockRejectedValueOnce(new Error('fail'))
|
||||
|
||||
const { ctx: ctxFail, expectedPath: pathFail } = await makeMultipartContext()
|
||||
const resFail = await handler(ctxFail)
|
||||
expect(resFail.status).toBe(500)
|
||||
expect(await readJson(resFail)).toMatchObject({ success: false })
|
||||
expect(await fs.pathExists(pathFail)).toBe(false)
|
||||
await expect(adapter.uploadClipboard()).resolves.toEqual([clipboardImage])
|
||||
await expect(adapter.uploadPaths(['/tmp/a.png'])).resolves.toEqual(selectedImages)
|
||||
expect(uploadClipboardFilesWithInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(getAvailableWindowMock).toHaveBeenCalledTimes(1)
|
||||
expect(uploadSelectedFilesWithInfoMock).toHaveBeenCalledWith(webContents, [{ path: '/tmp/a.png' }])
|
||||
expect(adapter.getTempDir()).toBe('/tmp/picgo-form-images')
|
||||
expect(getFormImageFolderPathMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
|
||||
import {
|
||||
uploadClipboardFiles,
|
||||
uploadClipboardFilesWithInfo,
|
||||
uploadSelectedFiles,
|
||||
uploadSelectedFilesWithInfo
|
||||
} from '../../main/apis/app/uploader/apis'
|
||||
|
||||
type WebContentsStub = {
|
||||
send: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type WindowStub = {
|
||||
webContents: WebContentsStub
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const configValues: Record<string, unknown> = {}
|
||||
return {
|
||||
configValues,
|
||||
getConfigMock: vi.fn(),
|
||||
loggerInfoMock: vi.fn(),
|
||||
setWebContentsMock: vi.fn(),
|
||||
uploadMock: vi.fn(),
|
||||
uploadWithBuildInClipboardMock: vi.fn(),
|
||||
getAvailableWindowMock: vi.fn(),
|
||||
windowManagerGetMock: vi.fn(),
|
||||
windowManagerHasMock: vi.fn(),
|
||||
pasteTemplateMock: vi.fn(),
|
||||
albumInsertMock: vi.fn(),
|
||||
handleCopyUrlMock: vi.fn(),
|
||||
handleUrlEncodeWithSettingMock: vi.fn(),
|
||||
showNotificationMock: vi.fn(),
|
||||
TMock: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@core/picgo', () => ({
|
||||
default: {
|
||||
getConfig: mocks.getConfigMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@core/picgo/logger', () => ({
|
||||
default: {
|
||||
info: mocks.loggerInfoMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../main/apis/app/uploader', () => ({
|
||||
default: {
|
||||
setWebContents: mocks.setWebContentsMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('apis/app/window/windowManager', () => ({
|
||||
default: {
|
||||
getAvailableWindow: mocks.getAvailableWindowMock,
|
||||
get: mocks.windowManagerGetMock,
|
||||
has: mocks.windowManagerHasMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/pasteTemplate', () => ({
|
||||
default: mocks.pasteTemplateMock
|
||||
}))
|
||||
|
||||
vi.mock('~/main/apis/core/datastore', () => ({
|
||||
AlbumDB: {
|
||||
getInstance: () => ({
|
||||
insert: mocks.albumInsertMock
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/common', () => ({
|
||||
handleCopyUrl: mocks.handleCopyUrlMock,
|
||||
handleUrlEncodeWithSetting: mocks.handleUrlEncodeWithSettingMock,
|
||||
showNotification: mocks.showNotificationMock
|
||||
}))
|
||||
|
||||
vi.mock('~/main/i18n/index', () => ({
|
||||
T: mocks.TMock
|
||||
}))
|
||||
|
||||
const createWebContents = (): WebContentsStub => ({
|
||||
send: vi.fn()
|
||||
})
|
||||
|
||||
const asWebContents = (webContents: WebContentsStub): WebContents => {
|
||||
return webContents as unknown as WebContents
|
||||
}
|
||||
|
||||
describe('main uploader API helpers', () => {
|
||||
let availableWindow: WindowStub
|
||||
let trayWindow: WindowStub
|
||||
let settingWindow: WindowStub
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
Object.keys(mocks.configValues).forEach((key) => {
|
||||
delete mocks.configValues[key]
|
||||
})
|
||||
Object.assign(mocks.configValues, {
|
||||
'settings.pasteStyle': IPasteStyle.MARKDOWN,
|
||||
'settings.customLink': '$url',
|
||||
'settings.useBuiltinClipboard': false
|
||||
})
|
||||
|
||||
availableWindow = { webContents: createWebContents() }
|
||||
trayWindow = { webContents: createWebContents() }
|
||||
settingWindow = { webContents: createWebContents() }
|
||||
|
||||
mocks.getConfigMock.mockImplementation(<T>(key: string): T => mocks.configValues[key] as T)
|
||||
mocks.setWebContentsMock.mockReturnValue({
|
||||
upload: mocks.uploadMock,
|
||||
uploadWithBuildInClipboard: mocks.uploadWithBuildInClipboardMock
|
||||
})
|
||||
mocks.getAvailableWindowMock.mockReturnValue(availableWindow)
|
||||
mocks.windowManagerGetMock.mockImplementation((name: IWindowList) => {
|
||||
if (name === IWindowList.TRAY_WINDOW) return trayWindow
|
||||
if (name === IWindowList.SETTING_WINDOW) return settingWindow
|
||||
return undefined
|
||||
})
|
||||
mocks.windowManagerHasMock.mockImplementation((name: IWindowList) => name === IWindowList.SETTING_WINDOW)
|
||||
mocks.pasteTemplateMock.mockImplementation((style: IPasteStyle, item: ImgInfo, customLink?: string) => {
|
||||
return `paste:${style}:${item.imgUrl ?? ''}:${customLink ?? ''}`
|
||||
})
|
||||
mocks.handleUrlEncodeWithSettingMock.mockImplementation((url: string) => `encoded:${url}`)
|
||||
mocks.TMock.mockImplementation((key: string) => `t:${key}`)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves clipboard upload side effects and returns raw image info for server adapters', async () => {
|
||||
const image: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/clipboard image.png',
|
||||
fileName: 'clipboard image.png',
|
||||
extname: '.png',
|
||||
origin: 'clipboard',
|
||||
size: 123
|
||||
}
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
|
||||
const result = await uploadClipboardFilesWithInfo()
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual([image])
|
||||
expect(mocks.loggerInfoMock).toHaveBeenCalledWith('upload clipboard file')
|
||||
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(availableWindow.webContents)
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith()
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
|
||||
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
|
||||
title: 't:UPLOAD_SUCCEED',
|
||||
body: image.imgUrl
|
||||
})
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('clipboardFiles', [])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', [image])
|
||||
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
|
||||
})
|
||||
|
||||
it('keeps legacy clipboard helper return value URL-encoded while preserving raw side effects', async () => {
|
||||
const image: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/clipboard image.png',
|
||||
fileName: 'clipboard image.png',
|
||||
extname: '.png'
|
||||
}
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
|
||||
const result = await uploadClipboardFiles()
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toBe(`encoded:${image.imgUrl}`)
|
||||
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenCalledWith(image.imgUrl)
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', [image])
|
||||
})
|
||||
|
||||
it('uses the builtin clipboard upload path when configured', async () => {
|
||||
const image: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/builtin.png',
|
||||
fileName: 'builtin.png',
|
||||
extname: '.png'
|
||||
}
|
||||
mocks.configValues['settings.useBuiltinClipboard'] = true
|
||||
mocks.uploadWithBuildInClipboardMock.mockResolvedValue([image])
|
||||
|
||||
const result = await uploadClipboardFilesWithInfo()
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual([image])
|
||||
expect(mocks.uploadWithBuildInClipboardMock).toHaveBeenCalledWith()
|
||||
expect(mocks.uploadMock).not.toHaveBeenCalled()
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
})
|
||||
|
||||
it('preserves selected-file upload side effects and returns raw image info for server adapters', async () => {
|
||||
const webContents = createWebContents()
|
||||
const files: IFileWithPath[] = [{ path: '/tmp/a.png' }, { path: '/tmp/b.png' }]
|
||||
const images: ImgInfo[] = [
|
||||
{
|
||||
imgUrl: 'https://raw.example/a image.png',
|
||||
fileName: 'a image.png',
|
||||
extname: '.png',
|
||||
origin: '/tmp/a.png',
|
||||
width: 10
|
||||
},
|
||||
{
|
||||
imgUrl: 'https://raw.example/b image.png',
|
||||
fileName: 'b image.png',
|
||||
extname: '.png',
|
||||
origin: '/tmp/b.png',
|
||||
height: 20
|
||||
}
|
||||
]
|
||||
mocks.uploadMock.mockResolvedValue(images)
|
||||
|
||||
const result = await uploadSelectedFilesWithInfo(asWebContents(webContents), files)
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual(images)
|
||||
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(webContents)
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith(['/tmp/a.png', '/tmp/b.png'])
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith([
|
||||
`paste:${IPasteStyle.MARKDOWN}:${images[0].imgUrl}:$url`,
|
||||
`paste:${IPasteStyle.MARKDOWN}:${images[1].imgUrl}:$url`
|
||||
].join('\n'))
|
||||
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
|
||||
title: 't:UPLOAD_SUCCEED',
|
||||
body: images[0].imgUrl
|
||||
})
|
||||
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
|
||||
title: 't:UPLOAD_SUCCEED',
|
||||
body: images[1].imgUrl
|
||||
})
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(1, images[0])
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(2, images[1])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', images)
|
||||
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
|
||||
})
|
||||
|
||||
it('keeps legacy selected-file helper return values URL-encoded', async () => {
|
||||
const webContents = createWebContents()
|
||||
const files: IFileWithPath[] = [{ path: '/tmp/a.png' }, { path: '/tmp/b.png' }]
|
||||
const images: ImgInfo[] = [
|
||||
{ imgUrl: 'https://raw.example/a image.png', fileName: 'a image.png' },
|
||||
{ imgUrl: 'https://raw.example/b image.png', fileName: 'b image.png' }
|
||||
]
|
||||
mocks.uploadMock.mockResolvedValue(images)
|
||||
|
||||
const result = await uploadSelectedFiles(asWebContents(webContents), files)
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual([
|
||||
`encoded:${images[0].imgUrl}`,
|
||||
`encoded:${images[1].imgUrl}`
|
||||
])
|
||||
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenNthCalledWith(1, images[0].imgUrl)
|
||||
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenNthCalledWith(2, images[1].imgUrl)
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(1, images[0])
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(2, images[1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SHOW_PLUGIN_PAGE_MENU } from '#/events/constants'
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
|
||||
vi.mock('@/utils/dataSender', () => ({
|
||||
getConfig: vi.fn(),
|
||||
invokeRPC: vi.fn(),
|
||||
openURL: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
sendRPC: vi.fn(),
|
||||
sendToMain: vi.fn()
|
||||
}))
|
||||
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
import {
|
||||
getConfig,
|
||||
invokeRPC,
|
||||
openURL,
|
||||
saveConfig,
|
||||
sendRPC,
|
||||
sendToMain
|
||||
} from '@/utils/dataSender'
|
||||
|
||||
describe('renderer/adapters plugins', () => {
|
||||
const getConfigMock = vi.mocked(getConfig)
|
||||
const invokeRPCMock = vi.mocked(invokeRPC)
|
||||
const saveConfigMock = vi.mocked(saveConfig)
|
||||
const sendRPCMock = vi.mocked(sendRPC)
|
||||
const sendToMainMock = vi.mocked(sendToMain)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens plugin page menu through ipc', () => {
|
||||
const plugin = {
|
||||
name: 'demo',
|
||||
fullName: 'picgo-plugin-demo',
|
||||
author: 'author',
|
||||
description: 'desc',
|
||||
logo: '',
|
||||
version: '1.0.0',
|
||||
gui: true,
|
||||
config: {},
|
||||
homepage: '',
|
||||
ing: false
|
||||
} as IPicGoPlugin
|
||||
|
||||
pluginsAdapter.openPluginMenu(plugin)
|
||||
|
||||
expect(sendToMainMock).toHaveBeenCalledWith(SHOW_PLUGIN_PAGE_MENU, plugin)
|
||||
})
|
||||
|
||||
it('installs plugin through install RPC', async () => {
|
||||
invokeRPCMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: 'picgo-plugin-demo'
|
||||
})
|
||||
|
||||
const result = await pluginsAdapter.installPlugin('picgo-plugin-demo')
|
||||
|
||||
expect(invokeRPCMock).toHaveBeenCalledWith(
|
||||
IRPCActionType.INSTALL_PLUGIN,
|
||||
'picgo-plugin-demo'
|
||||
)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
body: 'picgo-plugin-demo',
|
||||
errMsg: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('imports local plugin through import RPC', async () => {
|
||||
invokeRPCMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: 'picgo-plugin-local'
|
||||
})
|
||||
|
||||
const result = await pluginsAdapter.importLocalPlugin()
|
||||
|
||||
expect(invokeRPCMock).toHaveBeenCalledWith(IRPCActionType.IMPORT_LOCAL_PLUGIN)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: 'picgo-plugin-local'
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles plugin enabled through enable and disable RPC', async () => {
|
||||
invokeRPCMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: 'picgo-plugin-demo'
|
||||
})
|
||||
|
||||
await pluginsAdapter.togglePluginEnabled('picgo-plugin-demo', true)
|
||||
await pluginsAdapter.togglePluginEnabled('picgo-plugin-demo', false)
|
||||
|
||||
expect(invokeRPCMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
IRPCActionType.ENABLE_PLUGIN,
|
||||
'picgo-plugin-demo'
|
||||
)
|
||||
expect(invokeRPCMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
IRPCActionType.DISABLE_PLUGIN,
|
||||
'picgo-plugin-demo'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates and uninstalls plugin through RPC', async () => {
|
||||
invokeRPCMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: 'picgo-plugin-demo'
|
||||
})
|
||||
|
||||
await pluginsAdapter.updatePlugin('picgo-plugin-demo')
|
||||
await pluginsAdapter.uninstallPlugin('picgo-plugin-demo')
|
||||
|
||||
expect(invokeRPCMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
IRPCActionType.UPDATE_PLUGIN,
|
||||
'picgo-plugin-demo'
|
||||
)
|
||||
expect(invokeRPCMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
IRPCActionType.UNINSTALL_PLUGIN,
|
||||
'picgo-plugin-demo'
|
||||
)
|
||||
})
|
||||
|
||||
it('persists transformer and needReload via config helpers', async () => {
|
||||
await pluginsAdapter.saveTransformer('path')
|
||||
await pluginsAdapter.setNeedReload(true)
|
||||
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(1, {
|
||||
'picBed.transformer': 'path'
|
||||
})
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(2, {
|
||||
needReload: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads needReload via config helper', async () => {
|
||||
getConfigMock.mockResolvedValue(true)
|
||||
|
||||
const result = await pluginsAdapter.getNeedReload()
|
||||
|
||||
expect(getConfigMock).toHaveBeenCalledWith('needReload')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('reloads app through system RPC', () => {
|
||||
pluginsAdapter.reloadApp()
|
||||
|
||||
expect(sendRPCMock).toHaveBeenCalledWith(IRPCActionType.RELOAD_APP)
|
||||
})
|
||||
|
||||
it('opens homepage and awesome list through openURL', () => {
|
||||
const openURLMock = vi.mocked(openURL)
|
||||
|
||||
pluginsAdapter.openPluginHomepage('https://example.com')
|
||||
pluginsAdapter.openAwesomeList()
|
||||
|
||||
expect(openURLMock).toHaveBeenNthCalledWith(1, 'https://example.com')
|
||||
expect(openURLMock).toHaveBeenNthCalledWith(2, 'https://github.com/PicGo/Awesome-PicGo')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AlbumSource } from '~/universal/types/cloudAlbum'
|
||||
import { NavType } from '@/components/main/album/utils'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/cloud-album', () => ({
|
||||
cloudAlbumAdapter: {
|
||||
getStats: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/album-source-switcher', () => ({
|
||||
AlbumSourceSwitcher: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/cloud-feature-highlights', () => ({
|
||||
CloudFeatureHighlights: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/cloud-refresh-button', () => ({
|
||||
CloudRefreshButton: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/album/cloud-loading', () => ({
|
||||
CloudSidebarSkeleton: () => <div data-testid="cloud-sidebar-skeleton" />
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
use: {
|
||||
picBeds: () => [
|
||||
{ type: 'picgo-cloud', name: 'PicGo Cloud', visible: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { cloudAlbumAdapter } from '@/adapters/cloud-album'
|
||||
import { AlbumSidebar } from '@/components/main/album/album-sidebar'
|
||||
|
||||
const buildWrapper = () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } }
|
||||
})
|
||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
)
|
||||
return { client, Wrapper }
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
images: [],
|
||||
providers: [],
|
||||
navContext: { type: NavType.All, value: 'all' },
|
||||
albumSource: AlbumSource.CLOUD,
|
||||
isCloudAvailable: true,
|
||||
onFilterChange: () => {}
|
||||
}
|
||||
|
||||
describe('AlbumSidebar (cloud mode)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders All Photos count and provider count from stats query', async () => {
|
||||
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
|
||||
success: true,
|
||||
data: { total: 7, types: [{ type: 'picgo-cloud', count: 7 }] }
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
render(<AlbumSidebar {...baseProps} />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ALBUM_ALL_PHOTOS')).toBeTruthy()
|
||||
expect(screen.getAllByText('7').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('PicGo Cloud')).toBeTruthy()
|
||||
})
|
||||
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows "—" for All Photos count when stats query errors out', async () => {
|
||||
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'boom'
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
render(<AlbumSidebar {...baseProps} />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('—')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call stats adapter when isCloudAvailable=false', async () => {
|
||||
const { Wrapper } = buildWrapper()
|
||||
render(
|
||||
<AlbumSidebar {...baseProps} isCloudAvailable={false} />,
|
||||
{ wrapper: Wrapper }
|
||||
)
|
||||
|
||||
// give react-query a microtask
|
||||
await Promise.resolve()
|
||||
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call stats adapter when albumSource is LOCAL', async () => {
|
||||
const { Wrapper } = buildWrapper()
|
||||
render(
|
||||
<AlbumSidebar {...baseProps} albumSource={AlbumSource.LOCAL} />,
|
||||
{ wrapper: Wrapper }
|
||||
)
|
||||
|
||||
await Promise.resolve()
|
||||
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
|
||||
import {
|
||||
usePluginConfigRefresh,
|
||||
type RefreshConfigSchemaTarget,
|
||||
} from '@/components/common/use-plugin-config-refresh'
|
||||
import type { ProviderPluginConfig } from '@/components/main/providers/types'
|
||||
import {
|
||||
REGION_ENDPOINTS,
|
||||
ENDPOINT_BUCKETS,
|
||||
buildCascadeRawSchema,
|
||||
} from '../fixtures/cascade-fixture'
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const DEBOUNCE = 20
|
||||
const flushCascade = () => act(async () => { await wait(DEBOUNCE + 30) })
|
||||
|
||||
/**
|
||||
* Hook-level cascade behavior. The "page-refresh saved-config preservation"
|
||||
* tests previously here were moved to `provider-config-panel.spec.tsx` —
|
||||
* they're now covered by mounting the real component, so the harness
|
||||
* duplicates were removed. The tests below isolate cascade semantics that
|
||||
* don't depend on the panel: schema diffing, default propagation, and the
|
||||
* merger's stale-clearing rules.
|
||||
*/
|
||||
|
||||
const buildInitialSchema = (): ProviderPluginConfig[] => {
|
||||
return evaluatePluginConfig(
|
||||
buildCascadeRawSchema() as Parameters<typeof evaluatePluginConfig>[0]
|
||||
) as unknown as ProviderPluginConfig[]
|
||||
}
|
||||
|
||||
interface HarnessState {
|
||||
schema: ProviderPluginConfig[]
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
schemaRef,
|
||||
setValuesRef,
|
||||
}: {
|
||||
schemaRef: { current: HarnessState | null }
|
||||
setValuesRef: {
|
||||
current: ((values: Record<string, unknown>) => void) | null
|
||||
}
|
||||
}) {
|
||||
const initialSchema = buildInitialSchema()
|
||||
const initialValues: Record<string, unknown> = {}
|
||||
for (const field of initialSchema) {
|
||||
if (field.default !== undefined) {
|
||||
initialValues[field.name] = field.default
|
||||
}
|
||||
}
|
||||
|
||||
const [schema, setSchema] = useState<ProviderPluginConfig[]>(initialSchema)
|
||||
const [values, setValues] = useState<Record<string, unknown>>(initialValues)
|
||||
|
||||
useEffect(() => {
|
||||
schemaRef.current = { schema, values }
|
||||
setValuesRef.current = setValues
|
||||
}, [schema, values, schemaRef, setValuesRef])
|
||||
|
||||
const target: RefreshConfigSchemaTarget = {
|
||||
target: 'uploader',
|
||||
uploaderName: 'picgo-plugin-test',
|
||||
}
|
||||
|
||||
usePluginConfigRefresh({
|
||||
enabled: true,
|
||||
target,
|
||||
currentSchema: schema,
|
||||
currentValues: values,
|
||||
fetchSchema: async (_target, draftValues) =>
|
||||
evaluatePluginConfig(
|
||||
buildCascadeRawSchema() as Parameters<typeof evaluatePluginConfig>[0],
|
||||
draftValues
|
||||
) as unknown as ProviderPluginConfig[],
|
||||
onSchemaUpdate: (nextSchema, nextValues) => {
|
||||
setSchema(nextSchema)
|
||||
setValues(nextValues)
|
||||
},
|
||||
debounceMs: DEBOUNCE,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function setupHarness() {
|
||||
const schemaRef: { current: HarnessState | null } = { current: null }
|
||||
const setValuesRef: {
|
||||
current: ((values: Record<string, unknown>) => void) | null
|
||||
} = { current: null }
|
||||
render(<Harness schemaRef={schemaRef} setValuesRef={setValuesRef} />)
|
||||
return { schemaRef, setValuesRef }
|
||||
}
|
||||
|
||||
describe('cascade hook behavior (region -> endpoint -> bucket)', () => {
|
||||
it('seeds initial form with the first defaults from each cascade level', () => {
|
||||
const { schemaRef } = setupHarness()
|
||||
|
||||
const state = schemaRef.current!
|
||||
expect(state.values.region).toBe('us')
|
||||
expect(state.values.endpoint).toBe('s3.us-east-1')
|
||||
|
||||
const bucketField = state.schema.find((f) => f.name === 'bucket')!
|
||||
expect(bucketField.choices).toEqual(ENDPOINT_BUCKETS['s3.us-east-1'])
|
||||
})
|
||||
|
||||
it('cascades both endpoint and bucket when region changes (untouched values)', async () => {
|
||||
const { schemaRef, setValuesRef } = setupHarness()
|
||||
|
||||
// Pretend the user picked the initial defaults
|
||||
await act(async () => {
|
||||
setValuesRef.current!({
|
||||
region: 'us',
|
||||
endpoint: 's3.us-east-1',
|
||||
bucket: 'us-east-prod',
|
||||
pathPrefix: '',
|
||||
})
|
||||
})
|
||||
await flushCascade()
|
||||
|
||||
// Switch region to 'asia' — endpoint/bucket are stale
|
||||
await act(async () => {
|
||||
setValuesRef.current!({
|
||||
region: 'asia',
|
||||
endpoint: 's3.us-east-1',
|
||||
bucket: 'us-east-prod',
|
||||
pathPrefix: '',
|
||||
})
|
||||
})
|
||||
await flushCascade()
|
||||
|
||||
const state = schemaRef.current!
|
||||
expect(state.values.endpoint).toBe(REGION_ENDPOINTS.asia[0])
|
||||
expect(state.values.bucket).toBeUndefined() // not in new bucket choices
|
||||
|
||||
const endpointField = state.schema.find((f) => f.name === 'endpoint')!
|
||||
expect(endpointField.choices).toEqual(REGION_ENDPOINTS.asia)
|
||||
|
||||
const bucketField = state.schema.find((f) => f.name === 'bucket')!
|
||||
expect(bucketField.choices).toEqual(ENDPOINT_BUCKETS[REGION_ENDPOINTS.asia[0]])
|
||||
})
|
||||
|
||||
it('clears user-edited values when they no longer match new choices', async () => {
|
||||
const { schemaRef, setValuesRef } = setupHarness()
|
||||
|
||||
// User explicitly picks the second us endpoint and matching bucket
|
||||
await act(async () => {
|
||||
setValuesRef.current!({
|
||||
region: 'us',
|
||||
endpoint: 's3.us-west-2',
|
||||
bucket: 'us-west-prod',
|
||||
pathPrefix: '',
|
||||
})
|
||||
})
|
||||
await flushCascade()
|
||||
|
||||
// Switch region to 'asia' — both endpoint and bucket are now invalid
|
||||
await act(async () => {
|
||||
setValuesRef.current!({
|
||||
region: 'asia',
|
||||
endpoint: 's3.us-west-2',
|
||||
bucket: 'us-west-prod',
|
||||
pathPrefix: '',
|
||||
})
|
||||
})
|
||||
await flushCascade()
|
||||
|
||||
const state = schemaRef.current!
|
||||
expect(state.values.endpoint).toBeUndefined()
|
||||
expect(state.values.bucket).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
filterValuesBySchema,
|
||||
mergePluginSchema,
|
||||
} from '@/components/common/merge-plugin-schema'
|
||||
import type { ProviderPluginConfig } from '@/components/main/providers/types'
|
||||
|
||||
const inputField = (name: string, defaultValue?: string): ProviderPluginConfig => ({
|
||||
name,
|
||||
type: 'input',
|
||||
required: false,
|
||||
default: defaultValue,
|
||||
})
|
||||
|
||||
const listField = (
|
||||
name: string,
|
||||
choices: string[],
|
||||
defaultValue?: string
|
||||
): ProviderPluginConfig => ({
|
||||
name,
|
||||
type: 'list',
|
||||
required: false,
|
||||
default: defaultValue,
|
||||
choices,
|
||||
})
|
||||
|
||||
const checkboxField = (
|
||||
name: string,
|
||||
choices: string[]
|
||||
): ProviderPluginConfig => ({
|
||||
name,
|
||||
type: 'checkbox',
|
||||
required: false,
|
||||
choices,
|
||||
})
|
||||
|
||||
describe('mergePluginSchema', () => {
|
||||
it('keeps the old value when the list field is present and the value is still valid', () => {
|
||||
const oldSchema = [listField('uploader', ['github', 'gitee'])]
|
||||
const newSchema = [listField('uploader', ['github', 'gitee', 'qiniu'])]
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { uploader: 'github' })
|
||||
|
||||
expect(result.values.uploader).toBe('github')
|
||||
})
|
||||
|
||||
it('clears the value when the field is present but the value no longer matches new choices', () => {
|
||||
const oldSchema = [listField('repo', ['a', 'b'])]
|
||||
const newSchema = [listField('repo', ['c', 'd'])]
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { repo: 'a' })
|
||||
|
||||
expect(result.values.repo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets to new default when the field type changes', () => {
|
||||
const oldSchema = [listField('mode', ['a', 'b'], 'a')]
|
||||
const newSchema: ProviderPluginConfig[] = [inputField('mode', 'fresh')]
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { mode: 'a' })
|
||||
|
||||
expect(result.values.mode).toBe('fresh')
|
||||
})
|
||||
|
||||
it('keeps the orphan value in mergedValues when the field disappears', () => {
|
||||
const oldSchema = [listField('uploader', ['github']), inputField('branch')]
|
||||
const newSchema = [listField('uploader', ['gitee'])]
|
||||
const result = mergePluginSchema(oldSchema, newSchema, {
|
||||
uploader: 'github',
|
||||
branch: 'dev',
|
||||
})
|
||||
|
||||
expect(result.values.branch).toBe('dev')
|
||||
})
|
||||
|
||||
it('restores the orphan when the field re-appears after a later refresh', () => {
|
||||
const stage1OldSchema = [listField('uploader', ['github']), inputField('branch')]
|
||||
const stage1NewSchema = [listField('uploader', ['gitee'])]
|
||||
const intermediate = mergePluginSchema(stage1OldSchema, stage1NewSchema, {
|
||||
uploader: 'gitee',
|
||||
branch: 'dev',
|
||||
})
|
||||
|
||||
const stage2OldSchema = stage1NewSchema
|
||||
const stage2NewSchema = [listField('uploader', ['github']), inputField('branch')]
|
||||
const final = mergePluginSchema(stage2OldSchema, stage2NewSchema, intermediate.values)
|
||||
|
||||
expect(final.values.branch).toBe('dev')
|
||||
})
|
||||
|
||||
it('replaces an input field value with the new default when the old value equals the old default (untouched)', () => {
|
||||
const oldSchema: ProviderPluginConfig[] = [
|
||||
inputField('apiVersion', 'v1'),
|
||||
]
|
||||
const newSchema: ProviderPluginConfig[] = [
|
||||
inputField('apiVersion', 'v2'),
|
||||
]
|
||||
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { apiVersion: 'v1' })
|
||||
|
||||
expect(result.values.apiVersion).toBe('v2')
|
||||
})
|
||||
|
||||
it('keeps an input field value when the user has clearly typed something different from the default', () => {
|
||||
const oldSchema: ProviderPluginConfig[] = [
|
||||
inputField('apiVersion', 'v1'),
|
||||
]
|
||||
const newSchema: ProviderPluginConfig[] = [
|
||||
inputField('apiVersion', 'v2'),
|
||||
]
|
||||
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { apiVersion: 'custom' })
|
||||
|
||||
expect(result.values.apiVersion).toBe('custom')
|
||||
})
|
||||
|
||||
it('seeds new fields with their default value', () => {
|
||||
const oldSchema = [listField('uploader', ['github'])]
|
||||
const newSchema = [listField('uploader', ['github']), inputField('branch', 'main')]
|
||||
const result = mergePluginSchema(oldSchema, newSchema, { uploader: 'github' })
|
||||
|
||||
expect(result.values.branch).toBe('main')
|
||||
})
|
||||
|
||||
it('handles a mixed scenario that triggers every rule at once', () => {
|
||||
const oldSchema = [
|
||||
// uploader: value 'github' was edited by the user (differs from old default 'gitee')
|
||||
listField('uploader', ['github', 'gitee'], 'gitee'),
|
||||
// repo: value 'a' equals old default — untouched-default rule should apply on refresh
|
||||
listField('repo', ['a', 'b'], 'a'),
|
||||
inputField('branch', 'main'),
|
||||
checkboxField('flags', ['x', 'y', 'z']),
|
||||
]
|
||||
const newSchema = [
|
||||
listField('uploader', ['github'], 'github'),
|
||||
listField('repo', ['c', 'd'], 'c'),
|
||||
// 'branch' field gone, becoming an orphan
|
||||
checkboxField('flags', ['x', 'z']),
|
||||
listField('newField', ['p', 'q'], 'p'),
|
||||
]
|
||||
|
||||
const result = mergePluginSchema(oldSchema, newSchema, {
|
||||
uploader: 'github',
|
||||
repo: 'a',
|
||||
branch: 'dev',
|
||||
flags: ['x', 'y'],
|
||||
})
|
||||
|
||||
// uploader: user-edited value still in new choices -> kept
|
||||
expect(result.values.uploader).toBe('github')
|
||||
// repo: value matched old default, defaults changed -> reset to new default
|
||||
expect(result.values.repo).toBe('c')
|
||||
// branch: orphan, kept in memory
|
||||
expect(result.values.branch).toBe('dev')
|
||||
// flags: 'y' no longer valid -> cleared
|
||||
expect(result.values.flags).toBeUndefined()
|
||||
// newField: brand new, seeded with new default
|
||||
expect(result.values.newField).toBe('p')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterValuesBySchema', () => {
|
||||
it('drops orphan keys that are not in the current schema', () => {
|
||||
const schema = [listField('uploader', ['github']), inputField('branch')]
|
||||
const filtered = filterValuesBySchema(schema, {
|
||||
uploader: 'github',
|
||||
branch: 'main',
|
||||
legacy: 'should-be-dropped',
|
||||
})
|
||||
|
||||
expect(filtered).toEqual({ uploader: 'github', branch: 'main' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureExpanded: vi.fn(),
|
||||
ensureHydrated: vi.fn(async () => {}),
|
||||
navigate: vi.fn(),
|
||||
refreshConfigSchema: vi.fn(),
|
||||
setHydrating: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
useSearch: () => ({
|
||||
uploader: 'tcyun',
|
||||
configId: 'config-1'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
refreshConfigSchema: mocks.refreshConfigSchema
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
appActions: {
|
||||
ensureHydrated: mocks.ensureHydrated
|
||||
},
|
||||
providerStoreActions: {
|
||||
ensureExpanded: mocks.ensureExpanded,
|
||||
setHydrating: mocks.setHydrating
|
||||
},
|
||||
useAppStore: {
|
||||
use: {
|
||||
appConfig: () => ({
|
||||
picBed: {
|
||||
uploader: 'tcyun'
|
||||
},
|
||||
uploader: {
|
||||
tcyun: {
|
||||
defaultId: 'config-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'config-1',
|
||||
_configName: 'Existing Config',
|
||||
_createdAt: 1700000000000,
|
||||
_updatedAt: 1700000000000,
|
||||
secretKey: 'existing-secret-key'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}),
|
||||
providers: () => [
|
||||
{
|
||||
id: 'tcyun',
|
||||
name: 'Tencent Cloud',
|
||||
visible: true,
|
||||
isDefaultUploader: true
|
||||
}
|
||||
],
|
||||
hasHydrated: () => true
|
||||
}
|
||||
},
|
||||
useProviderStore: {
|
||||
use: {
|
||||
isHydrating: () => false
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-sidebar', () => ({
|
||||
ProviderSidebar: ({
|
||||
onCreateIntent
|
||||
}: {
|
||||
onCreateIntent: (uploaderId: string) => void
|
||||
}) => (
|
||||
<button type='button' onClick={() => onCreateIntent('tcyun')}>
|
||||
Open create dialog
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-config-panel', () => ({
|
||||
ProviderConfigPanel: ({
|
||||
draftConfigMap
|
||||
}: {
|
||||
draftConfigMap: Record<string, unknown>
|
||||
}) => (
|
||||
<output data-testid='draft-config'>{JSON.stringify(draftConfigMap)}</output>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-config-name-dialog', () => ({
|
||||
ProviderConfigNameDialog: ({
|
||||
state,
|
||||
onSubmit
|
||||
}: {
|
||||
state: { name: string } | null
|
||||
onSubmit: () => Promise<void>
|
||||
}) => state
|
||||
? (
|
||||
<button type='button' onClick={async () => await onSubmit()}>
|
||||
Submit create dialog
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-delete-config-dialog', () => ({
|
||||
ProviderDeleteConfigDialog: () => null
|
||||
}))
|
||||
|
||||
import { PicGoProviders } from '@/components/main/providers/picgo-providers'
|
||||
|
||||
describe('PicGoProviders create config', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.refreshConfigSchema.mockResolvedValue([
|
||||
{
|
||||
name: 'version',
|
||||
type: 'list',
|
||||
choices: ['v4', 'v5'],
|
||||
default: 'v5',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'secretKey',
|
||||
type: 'password',
|
||||
default: '',
|
||||
required: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('requests schema-only defaults and creates an empty credential draft', async () => {
|
||||
render(<PicGoProviders />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open create dialog' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create dialog' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.refreshConfigSchema).toHaveBeenCalledWith({
|
||||
target: 'uploader',
|
||||
uploaderName: 'tcyun',
|
||||
draftValues: {},
|
||||
schemaOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const draftMap = JSON.parse(
|
||||
screen.getByTestId('draft-config').textContent ?? '{}'
|
||||
) as Record<string, Record<string, unknown>>
|
||||
expect(draftMap.tcyun).toMatchObject({
|
||||
_configName: 'New Config',
|
||||
_isDraft: true,
|
||||
version: 'v5',
|
||||
secretKey: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// Regression spec for the plugin config save→read path consistency.
|
||||
//
|
||||
// picgo-core stores per-plugin configs at the root of the config object via
|
||||
// `picgo.saveConfig({ [fullName]: values })` (same convention the CLI uses).
|
||||
// The renderer's `normalizeAppConfig` rolls those root entries up into
|
||||
// `appConfig.plugins[fullName]` by cross-referencing the `picgoPlugins` install
|
||||
// map, so the panel and CLI read the same place.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { set as lodashSet } from 'lodash'
|
||||
import { normalizeAppConfig } from '@/store/utils'
|
||||
|
||||
describe('plugin config save path', () => {
|
||||
it('rolls up root-level plugin entries into appConfig.plugins via picgoPlugins install map', () => {
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
|
||||
picgoPlugins: { 'picgo-plugin-test': true },
|
||||
settings: {},
|
||||
uploader: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
}
|
||||
// Simulates picgo.saveConfig({ 'picgo-plugin-test': { ... } })
|
||||
lodashSet(rawConfig, 'picgo-plugin-test', {
|
||||
mode: 'advanced',
|
||||
verbosity: 'debug',
|
||||
apiVersion: 'v2'
|
||||
})
|
||||
|
||||
const normalized = normalizeAppConfig(rawConfig as never, [])
|
||||
|
||||
expect(normalized?.plugins['picgo-plugin-test']).toEqual({
|
||||
mode: 'advanced',
|
||||
verbosity: 'debug',
|
||||
apiVersion: 'v2'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores root entries that do not correspond to an installed plugin', () => {
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
|
||||
picgoPlugins: {},
|
||||
settings: {},
|
||||
uploader: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
}
|
||||
lodashSet(rawConfig, 'picgo-plugin-test', { mode: 'advanced' })
|
||||
|
||||
const normalized = normalizeAppConfig(rawConfig as never, [])
|
||||
|
||||
// Not installed → not rolled up.
|
||||
expect(normalized?.plugins['picgo-plugin-test']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to a pre-existing nested plugins entry when no root entry exists', () => {
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
|
||||
picgoPlugins: { 'picgo-plugin-test': true },
|
||||
settings: {},
|
||||
uploader: {},
|
||||
transformer: {},
|
||||
plugins: {
|
||||
'picgo-plugin-test': { mode: 'legacy' }
|
||||
},
|
||||
needReload: false
|
||||
}
|
||||
|
||||
const normalized = normalizeAppConfig(rawConfig as never, [])
|
||||
|
||||
expect(normalized?.plugins['picgo-plugin-test']).toEqual({ mode: 'legacy' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
refreshConfigSchema: vi.fn(async () => []),
|
||||
savePluginConfig: vi.fn(async () => {}),
|
||||
getInstalledPlugins: vi.fn(async () => [])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/app-actions', () => ({
|
||||
appActions: {
|
||||
refreshAppConfig: vi.fn(async () => {}),
|
||||
hydrateAppState: vi.fn(async () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
import { PluginDetailPanel } from '@/components/main/plugins/plugin-detail-panel'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { usePluginStore } from '@/store/plugins/store'
|
||||
import { pluginStoreActions } from '@/store/plugins/actions'
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
import { appActions } from '@/store/app-actions'
|
||||
import { normalizePluginConfigSchema } from '@/components/common/normalize-plugin-schema'
|
||||
import { pluginDetailTab, type PluginInstalledItem } from '@/components/main/plugins/types'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
|
||||
// Raw plugin config schema mirroring the picgo-plugin-test test plugin.
|
||||
// mode → verbosity (default debug/info), apiVersion (default v2/v1).
|
||||
const buildRawPluginSchema = (): unknown[] => [
|
||||
{
|
||||
name: 'mode',
|
||||
type: 'list',
|
||||
required: true,
|
||||
alias: 'Mode',
|
||||
default: 'basic',
|
||||
choices: ['basic', 'advanced']
|
||||
},
|
||||
{
|
||||
name: 'verbosity',
|
||||
type: 'list',
|
||||
required: false,
|
||||
alias: 'Verbosity',
|
||||
dependsOn: ['mode'],
|
||||
default: (answers: Record<string, unknown>) =>
|
||||
(answers.mode === 'advanced' ? 'debug' : 'info'),
|
||||
choices: (answers: Record<string, unknown>) =>
|
||||
(answers.mode === 'advanced'
|
||||
? ['silent', 'info', 'debug', 'trace']
|
||||
: ['silent', 'info'])
|
||||
},
|
||||
{
|
||||
name: 'apiVersion',
|
||||
type: 'list',
|
||||
required: false,
|
||||
alias: 'API version',
|
||||
dependsOn: ['mode'],
|
||||
default: (answers: Record<string, unknown>) =>
|
||||
(answers.mode === 'advanced' ? 'v2' : 'v1'),
|
||||
choices: (answers: Record<string, unknown>) =>
|
||||
(answers.mode === 'advanced' ? ['v2', 'v2-beta'] : ['v1', 'v1-legacy'])
|
||||
}
|
||||
]
|
||||
|
||||
const baseAppConfig = {
|
||||
picBed: {
|
||||
uploader: 'smms',
|
||||
current: 'smms',
|
||||
transformer: 'path',
|
||||
proxy: '',
|
||||
list: []
|
||||
},
|
||||
uploader: {} as Record<string, unknown>,
|
||||
settings: {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: { port: 36677, host: '127.0.0.1', enable: true },
|
||||
startupMode: IStartupMode.HIDE,
|
||||
shortKey: {},
|
||||
urlRewrite: { rules: [] }
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {} as Record<string, Record<string, unknown>>,
|
||||
transformer: {},
|
||||
needReload: false
|
||||
}
|
||||
|
||||
const FULL_NAME = 'picgo-plugin-test'
|
||||
|
||||
function buildInstalledPlugin(): PluginInstalledItem {
|
||||
const schemaEvaluated = evaluatePluginConfig(
|
||||
buildRawPluginSchema() as Parameters<typeof evaluatePluginConfig>[0]
|
||||
) as unknown[]
|
||||
return {
|
||||
name: 'test',
|
||||
fullName: FULL_NAME,
|
||||
author: 'tester',
|
||||
description: 'for test',
|
||||
logo: '',
|
||||
version: '1.0.0',
|
||||
gui: false,
|
||||
homepage: '',
|
||||
enabled: true,
|
||||
hasInstall: true,
|
||||
guiMenu: [],
|
||||
config: {
|
||||
plugin: {
|
||||
name: 'test',
|
||||
fullName: FULL_NAME,
|
||||
config: normalizePluginConfigSchema(schemaEvaluated)
|
||||
},
|
||||
transformer: { name: '', fullName: undefined, config: [] }
|
||||
},
|
||||
uploader: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setupStore(pluginConfigInAppConfig: Record<string, unknown> | undefined) {
|
||||
const installedPlugin = buildInstalledPlugin()
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'smms',
|
||||
appConfig: {
|
||||
...baseAppConfig,
|
||||
plugins: pluginConfigInAppConfig
|
||||
? { [FULL_NAME]: pluginConfigInAppConfig }
|
||||
: {}
|
||||
} as never,
|
||||
picBeds: [],
|
||||
providers: [],
|
||||
providerSchemas: {},
|
||||
pluginsInstalled: [installedPlugin],
|
||||
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
picgoCloud: {
|
||||
loginStatus: 'IDLE',
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
} as never)
|
||||
usePluginStore.setState({
|
||||
searchValue: '',
|
||||
exactMatch: false,
|
||||
rawSearchResults: [],
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
isImportingLocal: false,
|
||||
isMutatingByPlugin: {},
|
||||
readmeByPlugin: {}
|
||||
})
|
||||
return installedPlugin
|
||||
}
|
||||
|
||||
function getFieldSelectTrigger(label: string) {
|
||||
const labelEl = screen.getByText(label)
|
||||
const fieldContainer = labelEl.closest('[data-slot="field"]')
|
||||
if (!fieldContainer) throw new Error(`No field container for "${label}"`)
|
||||
const trigger = fieldContainer.querySelector('[role="combobox"]')
|
||||
if (!trigger) throw new Error(`No combobox trigger inside "${label}" field`)
|
||||
return trigger as HTMLElement
|
||||
}
|
||||
|
||||
function mountPanel(plugin: PluginInstalledItem) {
|
||||
const appConfig = useAppStore.getState().appConfig
|
||||
return render(
|
||||
<PluginDetailPanel
|
||||
appConfig={appConfig}
|
||||
selectedItem={{
|
||||
name: plugin.name,
|
||||
fullName: plugin.fullName,
|
||||
author: plugin.author,
|
||||
description: plugin.description,
|
||||
logo: plugin.logo,
|
||||
version: plugin.version,
|
||||
homepage: plugin.homepage,
|
||||
hasInstall: true,
|
||||
installedPlugin: plugin
|
||||
} as never}
|
||||
plugin={plugin}
|
||||
activeTab={pluginDetailTab.Config}
|
||||
availableTabs={[pluginDetailTab.Readme, pluginDetailTab.Config]}
|
||||
readmeState={null}
|
||||
isMutating={false}
|
||||
onTabChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PluginDetailPanel — config tab persistence (issue diagnostic)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Schema RPC default: re-evaluate with given draftValues (simulates main).
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockImplementation(
|
||||
async (payload) => {
|
||||
const draftValues = (payload as { draftValues: Record<string, unknown> })
|
||||
.draftValues
|
||||
const evaluated = evaluatePluginConfig(
|
||||
buildRawPluginSchema() as Parameters<typeof evaluatePluginConfig>[0],
|
||||
draftValues
|
||||
) as unknown[]
|
||||
return normalizePluginConfigSchema(evaluated)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows saved advanced/debug/v2 when appConfig.plugins[fullName] has those values', async () => {
|
||||
// Simulates the desired post-save state: appConfig.plugins[fullName] has the
|
||||
// values the user just saved.
|
||||
const plugin = setupStore({
|
||||
mode: 'advanced',
|
||||
verbosity: 'debug',
|
||||
apiVersion: 'v2'
|
||||
})
|
||||
|
||||
mountPanel(plugin)
|
||||
|
||||
// No initial sync wired yet in the panel — schema stays basic-state.
|
||||
// But form values come from appConfig.plugins[fullName], so mode should
|
||||
// at least show 'advanced'.
|
||||
await waitFor(() => {
|
||||
const modeTrigger = getFieldSelectTrigger('Mode')
|
||||
expect(modeTrigger.textContent).toContain('advanced')
|
||||
})
|
||||
|
||||
// Without the schema sync, verbosity/apiVersion values are saved but
|
||||
// the basic-state choices don't include them, so they render as
|
||||
// placeholder (field.name). This documents the current broken state
|
||||
// and gives us a failing assertion to fix.
|
||||
const verbosityTrigger = getFieldSelectTrigger('Verbosity')
|
||||
const apiVersionTrigger = getFieldSelectTrigger('API version')
|
||||
// After fix: these should contain 'debug' / 'v2'
|
||||
expect(verbosityTrigger.textContent).toContain('debug')
|
||||
expect(apiVersionTrigger.textContent).toContain('v2')
|
||||
})
|
||||
|
||||
it('shows saved values end-to-end: user toggles mode, clicks Confirm, form retains new values', async () => {
|
||||
// Step 1: empty starting state — no saved plugin config.
|
||||
const plugin = setupStore(undefined)
|
||||
|
||||
const { rerender } = mountPanel(plugin)
|
||||
|
||||
// Step 2: simulate the cascade — user changed mode to 'advanced',
|
||||
// hook fetched new schema, form values updated to advanced/debug/v2.
|
||||
// We can't drive Radix Select interactions in jsdom reliably, so
|
||||
// we simulate the save call directly with the post-cascade values.
|
||||
vi.mocked(pluginsAdapter.savePluginConfig).mockResolvedValueOnce(undefined)
|
||||
vi.mocked(pluginsAdapter.getInstalledPlugins).mockResolvedValueOnce([
|
||||
plugin as never
|
||||
])
|
||||
vi.mocked(appActions.refreshAppConfig).mockImplementationOnce(async () => {
|
||||
// Simulate what refreshAppConfig SHOULD do after picgo-core writes the
|
||||
// plugin config: appConfig.plugins[fullName] becomes the saved values.
|
||||
useAppStore.setState((state) => {
|
||||
if (!state.appConfig) return
|
||||
state.appConfig.plugins[FULL_NAME] = {
|
||||
mode: 'advanced',
|
||||
verbosity: 'debug',
|
||||
apiVersion: 'v2'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await pluginStoreActions.savePluginConfig(FULL_NAME, 'config', {
|
||||
mode: 'advanced',
|
||||
verbosity: 'debug',
|
||||
apiVersion: 'v2'
|
||||
})
|
||||
})
|
||||
|
||||
// Re-render so the panel picks up new appConfig reference.
|
||||
const nextAppConfig = useAppStore.getState().appConfig
|
||||
rerender(
|
||||
<PluginDetailPanel
|
||||
appConfig={nextAppConfig}
|
||||
selectedItem={{
|
||||
name: plugin.name,
|
||||
fullName: plugin.fullName,
|
||||
author: plugin.author,
|
||||
description: plugin.description,
|
||||
logo: plugin.logo,
|
||||
version: plugin.version,
|
||||
homepage: plugin.homepage,
|
||||
hasInstall: true,
|
||||
installedPlugin: plugin
|
||||
} as never}
|
||||
plugin={plugin}
|
||||
activeTab={pluginDetailTab.Config}
|
||||
availableTabs={[pluginDetailTab.Readme, pluginDetailTab.Config]}
|
||||
readmeState={null}
|
||||
isMutating={false}
|
||||
onTabChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Verify saved values are visible after save.
|
||||
await waitFor(() => {
|
||||
const modeTrigger = getFieldSelectTrigger('Mode')
|
||||
expect(modeTrigger.textContent).toContain('advanced')
|
||||
})
|
||||
|
||||
expect(getFieldSelectTrigger('Verbosity').textContent).toContain('debug')
|
||||
expect(getFieldSelectTrigger('API version').textContent).toContain('v2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen , fireEvent } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
reloadApp: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { AppReloadBar } from '@/components/common/app-reload-bar'
|
||||
import { PluginSidebar, type PluginSidebarListItem } from '@/components/main/plugins/plugin-sidebar'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { usePluginStore } from '@/store/plugins/store'
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
|
||||
function resetStores () {
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'smms',
|
||||
appConfig: {
|
||||
picBed: {
|
||||
uploader: 'smms',
|
||||
current: 'smms',
|
||||
transformer: 'path',
|
||||
proxy: '',
|
||||
list: []
|
||||
},
|
||||
uploader: {},
|
||||
settings: {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: {
|
||||
port: 36677,
|
||||
host: '127.0.0.1',
|
||||
enable: true
|
||||
},
|
||||
startupMode: IStartupMode.HIDE,
|
||||
shortKey: {},
|
||||
urlRewrite: {
|
||||
rules: []
|
||||
}
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
},
|
||||
picBeds: [],
|
||||
providers: [],
|
||||
providerSchemas: {},
|
||||
pluginsInstalled: [],
|
||||
settingsVersion: {
|
||||
currentVersion: '2.5.3',
|
||||
latestVersion: null
|
||||
},
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
picgoCloud: {
|
||||
loginStatus: 'IDLE',
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
})
|
||||
|
||||
usePluginStore.setState({
|
||||
searchValue: '',
|
||||
exactMatch: false,
|
||||
rawSearchResults: [],
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
isImportingLocal: false,
|
||||
isMutatingByPlugin: {},
|
||||
readmeByPlugin: {}
|
||||
})
|
||||
}
|
||||
|
||||
function createSidebarItems (): PluginSidebarListItem[] {
|
||||
return [
|
||||
{
|
||||
fullName: 'picgo-plugin-cloudflare-r2-xqv',
|
||||
name: 'cloudflare-r2-xqv',
|
||||
description: 'picgo for cloudflare-r2 storage',
|
||||
author: 'xiaoqinvar',
|
||||
version: '1.0.4',
|
||||
logo: '',
|
||||
homepage: '',
|
||||
hasInstall: true,
|
||||
installedPlugin: {
|
||||
name: 'cloudflare-r2-xqv',
|
||||
fullName: 'picgo-plugin-cloudflare-r2-xqv',
|
||||
author: 'xiaoqinvar',
|
||||
description: 'picgo for cloudflare-r2 storage',
|
||||
logo: '',
|
||||
version: '1.0.4',
|
||||
gui: true,
|
||||
homepage: '',
|
||||
enabled: true,
|
||||
hasInstall: true,
|
||||
guiMenu: [],
|
||||
config: {
|
||||
plugin: { name: 'x', fullName: 'x', config: [] },
|
||||
transformer: { name: 'path', fullName: 'path', config: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('renderer/plugins components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStores()
|
||||
})
|
||||
|
||||
it('shows reload bar only when needReload is true and triggers reload action', async () => {
|
||||
const { rerender } = render(<AppReloadBar />)
|
||||
|
||||
expect(screen.queryByText('TIPS_NEED_RELOAD')).not.toBeInTheDocument()
|
||||
|
||||
useAppStore.setState((state) => {
|
||||
if (state.appConfig) {
|
||||
state.appConfig.needReload = true
|
||||
}
|
||||
})
|
||||
|
||||
rerender(<AppReloadBar />)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'TIPS_NEED_RELOAD' })
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(vi.mocked(pluginsAdapter.reloadApp)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows import loading state and clears search input through store actions', async () => {
|
||||
const onImportLocalPlugin = vi.fn()
|
||||
|
||||
usePluginStore.setState({
|
||||
searchValue: 'cloudflare',
|
||||
isImportingLocal: true
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginSidebar
|
||||
items={createSidebarItems()}
|
||||
selectedPluginFullName={null}
|
||||
onSelectPlugin={vi.fn()}
|
||||
onInstallPlugin={vi.fn()}
|
||||
onOpenAwesomeList={vi.fn()}
|
||||
onImportLocalPlugin={onImportLocalPlugin}
|
||||
onOpenPluginMenu={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'SEARCH' })).toHaveValue('cloudflare')
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'ALBUM_CLEAR_SELECTION' })
|
||||
fireEvent.click(clearButton)
|
||||
expect(usePluginStore.getState().searchValue).toBe('')
|
||||
|
||||
const importButtons = screen.getAllByRole('button')
|
||||
const disabledImportButton = importButtons.find((button) => button.hasAttribute('disabled'))
|
||||
expect(disabledImportButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,497 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
search: {
|
||||
uploader: 'picgo-plugin-test',
|
||||
configId: 'config-1'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tanstack/react-router')>(
|
||||
'@tanstack/react-router'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => routerMocks.search
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
refreshConfigSchema: vi.fn(async () => [])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/providers/actions', () => ({
|
||||
providerStoreActions: {
|
||||
ensureSchema: vi.fn(async () => {}),
|
||||
setDefaultConfig: vi.fn(),
|
||||
setLoadingByProvider: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
createConfig: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
|
||||
import type { ProviderDraftConfigItem } from '@/components/main/providers/types'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
import { normalizePluginConfigSchema } from '@/components/common/normalize-plugin-schema'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
|
||||
|
||||
beforeEach(() => {
|
||||
routerMocks.search.uploader = 'picgo-plugin-test'
|
||||
routerMocks.search.configId = 'config-1'
|
||||
})
|
||||
|
||||
const baseAppConfig = {
|
||||
picBed: {
|
||||
uploader: 'picgo-plugin-test',
|
||||
current: 'picgo-plugin-test',
|
||||
transformer: 'path',
|
||||
proxy: '',
|
||||
list: []
|
||||
},
|
||||
uploader: {} as Record<string, unknown>,
|
||||
settings: {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: { port: 36677, host: '127.0.0.1', enable: true },
|
||||
startupMode: IStartupMode.HIDE,
|
||||
shortKey: {},
|
||||
urlRewrite: { rules: [] }
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
}
|
||||
|
||||
function setupStoreWithSavedConfig(
|
||||
savedConfig: Record<string, unknown>,
|
||||
schemaOverride?: unknown[]
|
||||
) {
|
||||
// Mimics what the main process sends at startup: schema evaluated with
|
||||
// empty answers, so the plugin's `default(answers)` for downstream fields
|
||||
// computes against synthAnswers defaults — NOT the user's saved values.
|
||||
// Provider-config-panel then has to issue an initial sync to bring the
|
||||
// schema in line with the saved values.
|
||||
const initialSchema = schemaOverride ??
|
||||
evaluatePluginConfig(
|
||||
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
|
||||
typeof evaluatePluginConfig
|
||||
>[0]
|
||||
) as unknown[]
|
||||
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'picgo-plugin-test',
|
||||
appConfig: {
|
||||
...baseAppConfig,
|
||||
uploader: {
|
||||
'picgo-plugin-test': {
|
||||
defaultId: 'config-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'config-1',
|
||||
_configName: 'New Config',
|
||||
_createdAt: 1700000000000,
|
||||
_updatedAt: 1700000000000,
|
||||
...savedConfig
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
} as never,
|
||||
picBeds: [],
|
||||
providers: [
|
||||
{
|
||||
id: 'picgo-plugin-test',
|
||||
name: 'PicGo Test',
|
||||
visible: true,
|
||||
isDefaultUploader: true
|
||||
}
|
||||
],
|
||||
providerSchemas: {
|
||||
'picgo-plugin-test': {
|
||||
id: 'picgo-plugin-test',
|
||||
name: 'PicGo Test',
|
||||
config: normalizePluginConfigSchema(initialSchema)
|
||||
}
|
||||
},
|
||||
pluginsInstalled: [],
|
||||
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
picgoCloud: {
|
||||
loginStatus: 'IDLE',
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
} as never)
|
||||
|
||||
useProviderStore.setState({
|
||||
isHydrating: false,
|
||||
isLoadingByProvider: {},
|
||||
expandedProviderIds: [],
|
||||
searchValue: ''
|
||||
})
|
||||
}
|
||||
|
||||
// Mock fetchSchema to mirror what the main side would compute when given
|
||||
// `draftValues` — i.e. re-evaluate the raw schema with those answers so
|
||||
// downstream choices/defaults reflect them.
|
||||
function mockMainSideEvaluator() {
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockImplementation(
|
||||
async (payload) => {
|
||||
const draftValues = (payload as { draftValues: Record<string, unknown> })
|
||||
.draftValues
|
||||
const evaluated = evaluatePluginConfig(
|
||||
buildCascadeRawSchema(draftValues.region as string | undefined) as Parameters<
|
||||
typeof evaluatePluginConfig
|
||||
>[0],
|
||||
draftValues
|
||||
) as unknown[]
|
||||
return normalizePluginConfigSchema(evaluated)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function getFieldSelectTrigger(label: string) {
|
||||
const labelEl = screen.getByText(label)
|
||||
const fieldContainer = labelEl.closest('[data-slot="field"]')
|
||||
if (!fieldContainer) throw new Error(`No field container for "${label}"`)
|
||||
const trigger = fieldContainer.querySelector('[role="combobox"]')
|
||||
if (!trigger) throw new Error(`No combobox trigger inside "${label}" field`)
|
||||
return trigger as HTMLElement
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
draftConfigMap: Record<string, ProviderDraftConfigItem | undefined> = {}
|
||||
) {
|
||||
return render(
|
||||
<ProviderConfigPanel
|
||||
draftConfigMap={draftConfigMap}
|
||||
setDraftConfigMap={vi.fn()}
|
||||
onCreateConfigIntent={vi.fn()}
|
||||
onDeleteConfigIntent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProviderConfigPanel — page-refresh saved config rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMainSideEvaluator()
|
||||
})
|
||||
|
||||
it('renders the saved bucket value in the Select trigger after page refresh', async () => {
|
||||
setupStoreWithSavedConfig({
|
||||
region: 'eu',
|
||||
endpoint: 's3.eu-central-1',
|
||||
bucket: 'eu-central-prod',
|
||||
pathPrefix: ''
|
||||
})
|
||||
|
||||
renderPanel()
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const bucketTrigger = getFieldSelectTrigger('Bucket')
|
||||
expect(bucketTrigger.textContent).toContain('eu-central-prod')
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
|
||||
expect(getFieldSelectTrigger('Region').textContent).toContain('eu')
|
||||
expect(getFieldSelectTrigger('Endpoint').textContent).toContain(
|
||||
's3.eu-central-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('triggers an initial schema sync with the saved values when mounting', async () => {
|
||||
setupStoreWithSavedConfig({
|
||||
region: 'eu',
|
||||
endpoint: 's3.eu-central-1',
|
||||
bucket: 'eu-central-prod',
|
||||
pathPrefix: ''
|
||||
})
|
||||
|
||||
renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pluginsAdapter.refreshConfigSchema).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const firstCall = vi.mocked(pluginsAdapter.refreshConfigSchema).mock
|
||||
.calls[0]?.[0] as {
|
||||
target: string
|
||||
uploaderName: string
|
||||
draftValues: Record<string, unknown>
|
||||
}
|
||||
expect(firstCall?.target).toBe('uploader')
|
||||
expect(firstCall?.uploaderName).toBe('picgo-plugin-test')
|
||||
expect(firstCall?.draftValues).toMatchObject({
|
||||
region: 'eu',
|
||||
endpoint: 's3.eu-central-1',
|
||||
bucket: 'eu-central-prod'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear saved values when the initial schema sync resolves', async () => {
|
||||
setupStoreWithSavedConfig({
|
||||
region: 'eu',
|
||||
endpoint: 's3.eu-central-1',
|
||||
bucket: 'eu-central-prod',
|
||||
pathPrefix: ''
|
||||
})
|
||||
|
||||
renderPanel()
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
})
|
||||
|
||||
expect(getFieldSelectTrigger('Region').textContent).toContain('eu')
|
||||
expect(getFieldSelectTrigger('Endpoint').textContent).toContain(
|
||||
's3.eu-central-1'
|
||||
)
|
||||
expect(getFieldSelectTrigger('Bucket').textContent).toContain(
|
||||
'eu-central-prod'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// User-driven cascade (region change → endpoint/bucket update) is verified
|
||||
// at the hook level in `cascade-end-to-end.spec.tsx` — Radix Select in jsdom
|
||||
// requires `@testing-library/user-event` (not installed) to reliably open
|
||||
// its portal dropdown, and the panel here is a thin wrapper around the
|
||||
// same hook. Adding a panel-level cascade test would duplicate coverage
|
||||
// without adding signal.
|
||||
|
||||
describe('ProviderConfigPanel — editor field rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMainSideEvaluator()
|
||||
})
|
||||
|
||||
it('renders a textarea for a type: editor field and binds its saved value', async () => {
|
||||
// Re-setup the store with a schema that includes a type: editor field.
|
||||
// We bypass the cascade fixture here because editor type is orthogonal
|
||||
// to the region/endpoint/bucket cascade and we just want to verify the
|
||||
// SchemaFormFields editor branch surfaces through the panel render path.
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'picgo-plugin-test',
|
||||
appConfig: {
|
||||
...baseAppConfig,
|
||||
uploader: {
|
||||
'picgo-plugin-test': {
|
||||
defaultId: 'config-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'config-1',
|
||||
_configName: 'New Config',
|
||||
_createdAt: 1700000000000,
|
||||
_updatedAt: 1700000000000,
|
||||
script: 'line-one\nline-two\nline-three'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
} as never,
|
||||
picBeds: [],
|
||||
providers: [
|
||||
{
|
||||
id: 'picgo-plugin-test',
|
||||
name: 'PicGo Test',
|
||||
visible: true,
|
||||
isDefaultUploader: true
|
||||
}
|
||||
],
|
||||
providerSchemas: {
|
||||
'picgo-plugin-test': {
|
||||
id: 'picgo-plugin-test',
|
||||
name: 'PicGo Test',
|
||||
config: normalizePluginConfigSchema([
|
||||
{
|
||||
name: 'script',
|
||||
type: 'editor',
|
||||
alias: 'Compression script',
|
||||
required: true,
|
||||
message: 'Enter your compression script'
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
pluginsInstalled: [],
|
||||
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
picgoCloud: {
|
||||
loginStatus: 'IDLE',
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
} as never)
|
||||
useProviderStore.setState({
|
||||
isHydrating: false,
|
||||
isLoadingByProvider: {},
|
||||
expandedProviderIds: [],
|
||||
searchValue: ''
|
||||
})
|
||||
|
||||
renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.tagName).toBe('TEXTAREA')
|
||||
expect(textarea.value).toBe('line-one\nline-two\nline-three')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderConfigPanel — saved password rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps saved password fields masked without a reveal button', async () => {
|
||||
const passwordSchema = [
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
alias: 'Secret',
|
||||
required: true,
|
||||
message: 'Enter secret'
|
||||
}
|
||||
]
|
||||
setupStoreWithSavedConfig(
|
||||
{ secret: 'saved-secret' },
|
||||
passwordSchema
|
||||
)
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
|
||||
normalizePluginConfigSchema(passwordSchema)
|
||||
)
|
||||
|
||||
renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText('Secret')
|
||||
const field = label.closest('[data-slot="field"]')
|
||||
if (!field) throw new Error('No field container for password field')
|
||||
|
||||
const input = field.querySelector('input')
|
||||
expect(input?.value).toBe('saved-secret')
|
||||
expect(input?.type).toBe('password')
|
||||
expect(field.querySelector('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderConfigPanel — draft schema rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps schema refreshes isolated from persisted uploader defaults', async () => {
|
||||
const draftId = 'draft:picgo-plugin-test'
|
||||
const cachedSchema = [
|
||||
{
|
||||
name: 'version',
|
||||
type: 'list',
|
||||
choices: ['v4', 'v5'],
|
||||
default: 'v5',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
alias: 'Secret',
|
||||
default: 'persisted-secret',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
const cleanSchema = [
|
||||
cachedSchema[0],
|
||||
{
|
||||
...cachedSchema[1],
|
||||
default: ''
|
||||
}
|
||||
]
|
||||
|
||||
setupStoreWithSavedConfig(
|
||||
{
|
||||
version: 'v5',
|
||||
secret: 'persisted-secret'
|
||||
},
|
||||
cachedSchema
|
||||
)
|
||||
routerMocks.search.configId = draftId
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
|
||||
normalizePluginConfigSchema(cleanSchema)
|
||||
)
|
||||
|
||||
renderPanel({
|
||||
'picgo-plugin-test': {
|
||||
_id: draftId,
|
||||
_configName: 'New Config',
|
||||
_createdAt: 1700000000001,
|
||||
_updatedAt: 1700000000001,
|
||||
_isDraft: true,
|
||||
version: 'v5',
|
||||
secret: ''
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pluginsAdapter.refreshConfigSchema).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: 'uploader',
|
||||
uploaderName: 'picgo-plugin-test',
|
||||
schemaOnly: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText('Secret')
|
||||
const field = label.closest('[data-slot="field"]')
|
||||
if (!field) throw new Error('No field container for draft password field')
|
||||
|
||||
const input = field.querySelector('input')
|
||||
expect(input?.value).toBe('')
|
||||
expect(input?.type).toBe('password')
|
||||
expect(field.querySelector('button')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
import { SchemaFormFields } from '@/components/common/schema-form-fields'
|
||||
import type { ProviderPluginConfig } from '@/components/main/providers/types'
|
||||
|
||||
const renderSchema = (
|
||||
schema: ProviderPluginConfig[],
|
||||
values: Record<string, unknown> = {},
|
||||
fieldErrors: Record<string, string | undefined> = {}
|
||||
) => {
|
||||
const onValueChange = vi.fn()
|
||||
const utils = render(
|
||||
<SchemaFormFields
|
||||
schema={schema}
|
||||
values={values}
|
||||
fieldErrors={fieldErrors}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
return { ...utils, onValueChange }
|
||||
}
|
||||
|
||||
describe('SchemaFormFields editor field', () => {
|
||||
it('renders a textarea for type: editor', () => {
|
||||
renderSchema([
|
||||
{ name: 'script', type: 'editor', required: false }
|
||||
])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeTruthy()
|
||||
expect(textarea.tagName).toBe('TEXTAREA')
|
||||
expect(textarea.getAttribute('data-slot')).toBe('textarea')
|
||||
})
|
||||
|
||||
it('binds the textarea value from values map', () => {
|
||||
renderSchema(
|
||||
[{ name: 'script', type: 'editor', required: false }],
|
||||
{ script: 'line1\nline2\nline3' }
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('line1\nline2\nline3')
|
||||
})
|
||||
|
||||
it('fires onValueChange with the full multi-line value on input', () => {
|
||||
const { onValueChange } = renderSchema([
|
||||
{ name: 'script', type: 'editor', required: false }
|
||||
])
|
||||
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'a\nb\nc' } })
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('script', 'a\nb\nc')
|
||||
})
|
||||
|
||||
it('uses field.message as placeholder, falling back to field.name', () => {
|
||||
const { rerender } = renderSchema([
|
||||
{
|
||||
name: 'script',
|
||||
type: 'editor',
|
||||
required: false,
|
||||
message: 'Enter multi-line script'
|
||||
}
|
||||
])
|
||||
|
||||
let textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.placeholder).toBe('Enter multi-line script')
|
||||
|
||||
rerender(
|
||||
<SchemaFormFields
|
||||
schema={[{ name: 'script', type: 'editor', required: false }]}
|
||||
values={{}}
|
||||
fieldErrors={{}}
|
||||
onValueChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.placeholder).toBe('script')
|
||||
})
|
||||
|
||||
it('marks textarea as invalid when fieldErrors contains the field', () => {
|
||||
renderSchema(
|
||||
[{ name: 'script', type: 'editor', required: true }],
|
||||
{},
|
||||
{ script: 'required' }
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea.getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders a tooltip trigger when field.tips is provided', () => {
|
||||
renderSchema([
|
||||
{
|
||||
name: 'script',
|
||||
type: 'editor',
|
||||
required: false,
|
||||
alias: 'Compression script',
|
||||
tips: 'Supports **markdown** in the tooltip.'
|
||||
}
|
||||
])
|
||||
|
||||
const tooltipTrigger = screen.getByRole('button', {
|
||||
name: /Compression script tip/i
|
||||
})
|
||||
expect(tooltipTrigger).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('SchemaFormFields password field', () => {
|
||||
const passwordSchema: ProviderPluginConfig[] = [
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
required: true,
|
||||
alias: 'Secret'
|
||||
}
|
||||
]
|
||||
|
||||
it('allows password reveal by default', () => {
|
||||
renderSchema(passwordSchema, { secret: 'saved-secret' })
|
||||
|
||||
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
|
||||
expect(input.type).toBe('password')
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(input.type).toBe('text')
|
||||
})
|
||||
|
||||
it('keeps the password masked and removes the reveal button when disabled', () => {
|
||||
const onValueChange = vi.fn()
|
||||
render(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal={false}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
|
||||
expect(input.type).toBe('password')
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'replacement-secret' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('secret', 'replacement-secret')
|
||||
})
|
||||
|
||||
it('clears visible password state when reveal becomes disabled', () => {
|
||||
const onValueChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('text')
|
||||
|
||||
rerender(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal={false}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('password')
|
||||
|
||||
rerender(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SchemaFormFields unknown field type', () => {
|
||||
it('does not render any input control for an unrecognized type, label only', () => {
|
||||
renderSchema([
|
||||
{
|
||||
name: 'mystery',
|
||||
type: 'unknown-widget' as never,
|
||||
required: false,
|
||||
alias: 'Mystery field'
|
||||
}
|
||||
])
|
||||
|
||||
// The label is still rendered via FieldLabel
|
||||
expect(screen.getByText('Mystery field')).toBeTruthy()
|
||||
// None of the input controls render: no textbox, no combobox, no switch
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
usePluginConfigRefresh,
|
||||
type RefreshConfigSchemaTarget,
|
||||
} from '@/components/common/use-plugin-config-refresh'
|
||||
import type { ProviderPluginConfig } from '@/components/main/providers/types'
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const inputField = (name: string): ProviderPluginConfig => ({
|
||||
name,
|
||||
type: 'input',
|
||||
required: false,
|
||||
})
|
||||
|
||||
const listField = (
|
||||
name: string,
|
||||
choices: string[],
|
||||
dependsOn?: string[]
|
||||
): ProviderPluginConfig => ({
|
||||
name,
|
||||
type: 'list',
|
||||
required: false,
|
||||
choices,
|
||||
dependsOn,
|
||||
})
|
||||
|
||||
interface HarnessProps {
|
||||
initialSchema: ProviderPluginConfig[]
|
||||
initialValues: Record<string, unknown>
|
||||
target: RefreshConfigSchemaTarget
|
||||
fetchSchema: (
|
||||
target: RefreshConfigSchemaTarget,
|
||||
draftValues: Record<string, unknown>
|
||||
) => Promise<ProviderPluginConfig[]>
|
||||
schemaRef: { current: ProviderPluginConfig[] }
|
||||
valuesRef: { current: Record<string, unknown> }
|
||||
setValuesRef: { current: ((values: Record<string, unknown>) => void) | null }
|
||||
setSchemaRef: { current: ((schema: ProviderPluginConfig[]) => void) | null }
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialSchema,
|
||||
initialValues,
|
||||
target,
|
||||
fetchSchema,
|
||||
schemaRef,
|
||||
valuesRef,
|
||||
setValuesRef,
|
||||
setSchemaRef,
|
||||
debounceMs,
|
||||
}: HarnessProps) {
|
||||
const [schema, setSchema] = useState<ProviderPluginConfig[]>(initialSchema)
|
||||
const [values, setValues] = useState<Record<string, unknown>>(initialValues)
|
||||
|
||||
useEffect(() => {
|
||||
schemaRef.current = schema
|
||||
valuesRef.current = values
|
||||
setSchemaRef.current = setSchema
|
||||
setValuesRef.current = setValues
|
||||
}, [schema, values, schemaRef, valuesRef, setSchemaRef, setValuesRef])
|
||||
|
||||
usePluginConfigRefresh({
|
||||
enabled: true,
|
||||
target,
|
||||
currentSchema: schema,
|
||||
currentValues: values,
|
||||
fetchSchema,
|
||||
onSchemaUpdate: (nextSchema, nextValues) => {
|
||||
setSchema(nextSchema)
|
||||
setValues(nextValues)
|
||||
},
|
||||
debounceMs,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const DEBOUNCE_TICK = 20
|
||||
|
||||
describe('usePluginConfigRefresh', () => {
|
||||
const makeRefs = () => ({
|
||||
schemaRef: { current: [] as ProviderPluginConfig[] },
|
||||
valuesRef: { current: {} as Record<string, unknown> },
|
||||
setSchemaRef: {
|
||||
current: null as ((schema: ProviderPluginConfig[]) => void) | null,
|
||||
},
|
||||
setValuesRef: {
|
||||
current: null as ((values: Record<string, unknown>) => void) | null,
|
||||
},
|
||||
})
|
||||
|
||||
it('does not call fetchSchema when the change does not hit any dependsOn', async () => {
|
||||
const fetchSchema = vi.fn(async () => [] as ProviderPluginConfig[])
|
||||
const refs = makeRefs()
|
||||
|
||||
render(
|
||||
<Harness
|
||||
initialSchema={[
|
||||
listField('uploader', ['github']),
|
||||
inputField('proxy'), // proxy is not referenced by anyone's dependsOn
|
||||
]}
|
||||
initialValues={{ uploader: 'github', proxy: '' }}
|
||||
target={{ target: 'uploader', uploaderName: 'imgur' }}
|
||||
fetchSchema={fetchSchema}
|
||||
debounceMs={DEBOUNCE_TICK}
|
||||
{...refs}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'github', proxy: 'http://proxy' })
|
||||
})
|
||||
await act(async () => {
|
||||
await wait(DEBOUNCE_TICK + 30)
|
||||
})
|
||||
|
||||
expect(fetchSchema).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls fetchSchema after debounce when a dependsOn target changes', async () => {
|
||||
const fetchSchema = vi.fn(async () => [
|
||||
listField('uploader', ['github', 'gitee']),
|
||||
listField('repo', ['x', 'y'], ['uploader']),
|
||||
])
|
||||
const refs = makeRefs()
|
||||
|
||||
render(
|
||||
<Harness
|
||||
initialSchema={[
|
||||
listField('uploader', ['github', 'gitee']),
|
||||
listField('repo', ['a', 'b'], ['uploader']),
|
||||
]}
|
||||
initialValues={{ uploader: 'github', repo: 'a' }}
|
||||
target={{ target: 'uploader', uploaderName: 'imgur' }}
|
||||
fetchSchema={fetchSchema}
|
||||
debounceMs={DEBOUNCE_TICK}
|
||||
{...refs}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
|
||||
})
|
||||
await act(async () => {
|
||||
await wait(DEBOUNCE_TICK + 30)
|
||||
})
|
||||
|
||||
expect(fetchSchema).toHaveBeenCalledTimes(1)
|
||||
// 'repo' is a transitive dependent of the changed 'uploader' field, so it is
|
||||
// stripped from the payload to let main re-cascade its default.
|
||||
expect(fetchSchema).toHaveBeenCalledWith(
|
||||
{ target: 'uploader', uploaderName: 'imgur' },
|
||||
{ uploader: 'gitee' }
|
||||
)
|
||||
})
|
||||
|
||||
it('coalesces multiple changes inside the debounce window into one call', async () => {
|
||||
const fetchSchema = vi.fn(async () => [] as ProviderPluginConfig[])
|
||||
const refs = makeRefs()
|
||||
|
||||
render(
|
||||
<Harness
|
||||
initialSchema={[
|
||||
listField('uploader', ['github', 'gitee']),
|
||||
listField('repo', ['a'], ['uploader']),
|
||||
]}
|
||||
initialValues={{ uploader: 'github', repo: 'a' }}
|
||||
target={{ target: 'uploader', uploaderName: 'imgur' }}
|
||||
fetchSchema={fetchSchema}
|
||||
debounceMs={DEBOUNCE_TICK}
|
||||
{...refs}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
|
||||
})
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'github', repo: 'a' })
|
||||
})
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
|
||||
})
|
||||
await act(async () => {
|
||||
await wait(DEBOUNCE_TICK + 30)
|
||||
})
|
||||
|
||||
expect(fetchSchema).toHaveBeenCalledTimes(1)
|
||||
expect(fetchSchema).toHaveBeenLastCalledWith(
|
||||
{ target: 'uploader', uploaderName: 'imgur' },
|
||||
{ uploader: 'gitee' }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the appropriate payload shape for each refresh target', async () => {
|
||||
const initialSchema = [
|
||||
listField('uploader', ['github', 'gitee']),
|
||||
listField('repo', ['a'], ['uploader']),
|
||||
]
|
||||
|
||||
// Return a schema with the same dependsOn structure so subsequent edits still fire the hook.
|
||||
const fetchSchema = vi.fn(async () => initialSchema)
|
||||
|
||||
const runWith = async (target: RefreshConfigSchemaTarget) => {
|
||||
const refs = makeRefs()
|
||||
|
||||
render(
|
||||
<Harness
|
||||
initialSchema={initialSchema}
|
||||
initialValues={{ uploader: 'github', repo: 'a' }}
|
||||
target={target}
|
||||
fetchSchema={fetchSchema}
|
||||
debounceMs={DEBOUNCE_TICK}
|
||||
{...refs}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
|
||||
})
|
||||
await act(async () => {
|
||||
await wait(DEBOUNCE_TICK + 30)
|
||||
})
|
||||
}
|
||||
|
||||
await runWith({ target: 'plugin', pluginFullName: 'picgo-plugin-x' })
|
||||
expect(fetchSchema).toHaveBeenLastCalledWith(
|
||||
{ target: 'plugin', pluginFullName: 'picgo-plugin-x' },
|
||||
{ uploader: 'gitee' }
|
||||
)
|
||||
|
||||
await runWith({ target: 'transformer', pluginFullName: 'picgo-plugin-x' })
|
||||
expect(fetchSchema).toHaveBeenLastCalledWith(
|
||||
{ target: 'transformer', pluginFullName: 'picgo-plugin-x' },
|
||||
{ uploader: 'gitee' }
|
||||
)
|
||||
|
||||
await runWith({ target: 'uploader', uploaderName: 'imgur' })
|
||||
expect(fetchSchema).toHaveBeenLastCalledWith(
|
||||
{ target: 'uploader', uploaderName: 'imgur' },
|
||||
{ uploader: 'gitee' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared cascade-test fixture: a region → endpoint → bucket schema that
|
||||
* mirrors the picgo-plugin-test reactive plugin. Used by both the hook-level
|
||||
* cascade tests and the panel-level real-component tests.
|
||||
*/
|
||||
|
||||
export const REGION_ENDPOINTS: Record<string, string[]> = {
|
||||
us: ['s3.us-east-1', 's3.us-west-2'],
|
||||
eu: ['s3.eu-west-1', 's3.eu-central-1'],
|
||||
asia: ['s3.ap-southeast-1', 's3.ap-northeast-1'],
|
||||
}
|
||||
|
||||
export const ENDPOINT_BUCKETS: Record<string, string[]> = {
|
||||
's3.us-east-1': ['us-east-prod', 'us-east-staging'],
|
||||
's3.us-west-2': ['us-west-prod'],
|
||||
's3.eu-west-1': ['eu-west-prod', 'eu-west-archive'],
|
||||
's3.eu-central-1': ['eu-central-prod'],
|
||||
's3.ap-southeast-1': ['asia-sg-prod', 'asia-sg-staging'],
|
||||
's3.ap-northeast-1': ['asia-tk-prod'],
|
||||
}
|
||||
|
||||
export const buildCascadeRawSchema = (storedRegion?: string): unknown[] => [
|
||||
{
|
||||
name: 'region',
|
||||
type: 'list',
|
||||
required: true,
|
||||
alias: 'Region',
|
||||
default: storedRegion || 'us',
|
||||
choices: ['us', 'eu', 'asia'],
|
||||
},
|
||||
{
|
||||
name: 'endpoint',
|
||||
type: 'list',
|
||||
required: true,
|
||||
alias: 'Endpoint',
|
||||
dependsOn: ['region'],
|
||||
default: (answers: Record<string, unknown>) => {
|
||||
const region = (answers.region as string) || 'us'
|
||||
return REGION_ENDPOINTS[region]?.[0]
|
||||
},
|
||||
choices: (answers: Record<string, unknown>) => {
|
||||
const region = (answers.region as string) || 'us'
|
||||
return REGION_ENDPOINTS[region] || []
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bucket',
|
||||
type: 'list',
|
||||
required: true,
|
||||
alias: 'Bucket',
|
||||
dependsOn: ['endpoint'],
|
||||
choices: (answers: Record<string, unknown>) => {
|
||||
const endpoint = answers.endpoint as string
|
||||
return ENDPOINT_BUCKETS[endpoint] || []
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pathPrefix',
|
||||
type: 'input',
|
||||
required: false,
|
||||
alias: 'Path prefix',
|
||||
default: '',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CloudAlbumStatsResponse } from '~/universal/types/cloudAlbum'
|
||||
|
||||
vi.mock('@/adapters/cloud-album', () => ({
|
||||
cloudAlbumAdapter: {
|
||||
getStats: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/queries/query-client', () => ({
|
||||
rendererQueryClient: {
|
||||
invalidateQueries: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { cloudAlbumAdapter } from '@/adapters/cloud-album'
|
||||
import { rendererQueryClient } from '@/queries/query-client'
|
||||
import {
|
||||
invalidateCloudAlbumStatsQuery,
|
||||
PicGoCloudAlbumStatsQueryKeys,
|
||||
useCloudAlbumStatsQuery
|
||||
} from '@/queries/picgo-cloud-album-stats'
|
||||
|
||||
const SAMPLE_STATS: CloudAlbumStatsResponse = {
|
||||
total: 12,
|
||||
types: [
|
||||
{ type: 'picgo-cloud', count: 8 },
|
||||
{ type: 'github', count: 4 }
|
||||
]
|
||||
}
|
||||
|
||||
const buildWrapper = () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { client, Wrapper }
|
||||
}
|
||||
|
||||
describe('useCloudAlbumStatsQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed stats when adapter resolves with success', async () => {
|
||||
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
|
||||
success: true,
|
||||
data: SAMPLE_STATS
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudAlbumStatsQuery(), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
|
||||
expect(result.current.data).toEqual(SAMPLE_STATS)
|
||||
})
|
||||
|
||||
it('surfaces error state when adapter resolves with success: false', async () => {
|
||||
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'stats-fetch-failed'
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudAlbumStatsQuery(), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true)
|
||||
})
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
expect((result.current.error as Error).message).toBe('stats-fetch-failed')
|
||||
})
|
||||
|
||||
it('does not call adapter when enabled is false', async () => {
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudAlbumStatsQuery({ enabled: false }), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
// give react-query a microtask to schedule (it shouldn't)
|
||||
await Promise.resolve()
|
||||
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
|
||||
expect(result.current.fetchStatus).toBe('idle')
|
||||
})
|
||||
|
||||
it('calls adapter when enabled is omitted (default true)', async () => {
|
||||
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
|
||||
success: true,
|
||||
data: SAMPLE_STATS
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
renderHook(() => useCloudAlbumStatsQuery(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateCloudAlbumStatsQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('invalidates the canonical stats query key', async () => {
|
||||
vi.mocked(rendererQueryClient.invalidateQueries).mockResolvedValue(undefined)
|
||||
|
||||
await invalidateCloudAlbumStatsQuery()
|
||||
|
||||
expect(rendererQueryClient.invalidateQueries).toHaveBeenCalledOnce()
|
||||
expect(rendererQueryClient.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: PicGoCloudAlbumStatsQueryKeys.stats
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
IPicGoCloudConfigSyncSessionStatus,
|
||||
IPicGoCloudEncryptionMethod,
|
||||
type IPicGoCloudConfigSyncState
|
||||
} from '~/universal/types/cloudConfigSync'
|
||||
|
||||
vi.mock('@/adapters/cloud', () => ({
|
||||
cloudAdapter: {
|
||||
getConfigSyncState: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/queries/picgo-cloud', () => ({
|
||||
usePicGoCloudUserInfo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/queries/query-client', () => ({
|
||||
rendererQueryClient: {
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { cloudAdapter } from '@/adapters/cloud'
|
||||
import { usePicGoCloudUserInfo } from '@/queries/picgo-cloud'
|
||||
import { rendererQueryClient } from '@/queries/query-client'
|
||||
import {
|
||||
PicGoCloudConfigSyncQueryKeys,
|
||||
setCloudConfigSyncStateQueryData,
|
||||
updateCloudConfigSyncStateQueryData,
|
||||
useCloudConfigSyncStateQuery
|
||||
} from '@/queries/picgo-cloud-config-sync'
|
||||
|
||||
const SAMPLE_STATE: IPicGoCloudConfigSyncState = {
|
||||
sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE,
|
||||
encryptionMethod: IPicGoCloudEncryptionMethod.AUTO,
|
||||
lastSyncedAt: '2026-05-05T10:35:09.000Z'
|
||||
}
|
||||
|
||||
const buildWrapper = () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { client, Wrapper }
|
||||
}
|
||||
|
||||
describe('picgo-cloud-config-sync helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('setCloudConfigSyncStateQueryData', () => {
|
||||
it('writes state to query cache under the canonical key', () => {
|
||||
setCloudConfigSyncStateQueryData(SAMPLE_STATE)
|
||||
|
||||
expect(rendererQueryClient.setQueryData).toHaveBeenCalledOnce()
|
||||
expect(rendererQueryClient.setQueryData).toHaveBeenCalledWith(
|
||||
PicGoCloudConfigSyncQueryKeys.state,
|
||||
SAMPLE_STATE
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCloudConfigSyncStateQueryData', () => {
|
||||
it('passes the updater function to setQueryData', () => {
|
||||
const updater = vi.fn(
|
||||
(prev: IPicGoCloudConfigSyncState | undefined): IPicGoCloudConfigSyncState => ({
|
||||
...(prev ?? { sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE }),
|
||||
sessionStatus: IPicGoCloudConfigSyncSessionStatus.SYNCING
|
||||
})
|
||||
)
|
||||
|
||||
updateCloudConfigSyncStateQueryData(updater)
|
||||
|
||||
expect(rendererQueryClient.setQueryData).toHaveBeenCalledOnce()
|
||||
const callArgs = vi.mocked(rendererQueryClient.setQueryData).mock.calls[0]
|
||||
expect(callArgs[0]).toEqual(PicGoCloudConfigSyncQueryKeys.state)
|
||||
expect(callArgs[1]).toBe(updater)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCloudConfigSyncStateQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not call adapter when user is not logged in', async () => {
|
||||
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
|
||||
userInfo: undefined,
|
||||
isPaid: false,
|
||||
isFetched: true
|
||||
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
// 等一个 microtask 让 react-query 有机会 schedule fetch
|
||||
await Promise.resolve()
|
||||
expect(cloudAdapter.getConfigSyncState).not.toHaveBeenCalled()
|
||||
expect(result.current.fetchStatus).toBe('idle')
|
||||
})
|
||||
|
||||
it('returns parsed state when adapter resolves with success', async () => {
|
||||
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
|
||||
userInfo: { user: 'tester', plan: 1 },
|
||||
isPaid: true,
|
||||
isFetched: true
|
||||
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
|
||||
|
||||
vi.mocked(cloudAdapter.getConfigSyncState).mockResolvedValue({
|
||||
success: true,
|
||||
data: SAMPLE_STATE
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAdapter.getConfigSyncState>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
expect(cloudAdapter.getConfigSyncState).toHaveBeenCalledOnce()
|
||||
expect(result.current.data).toEqual(SAMPLE_STATE)
|
||||
})
|
||||
|
||||
it('surfaces error state when adapter resolves with success: false', async () => {
|
||||
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
|
||||
userInfo: { user: 'tester', plan: 0 },
|
||||
isPaid: false,
|
||||
isFetched: true
|
||||
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
|
||||
|
||||
vi.mocked(cloudAdapter.getConfigSyncState).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'boom'
|
||||
} as unknown as Awaited<ReturnType<typeof cloudAdapter.getConfigSyncState>>)
|
||||
|
||||
const { Wrapper } = buildWrapper()
|
||||
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
|
||||
wrapper: Wrapper
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true)
|
||||
})
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
expect((result.current.error as Error).message).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createMemoryHistory } from '@tanstack/history'
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
redirect,
|
||||
useMatchRoute
|
||||
} from '@tanstack/react-router'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
function AppShellMock () {
|
||||
const matchRoute = useMatchRoute()
|
||||
const isActive = (to: string) => {
|
||||
return Boolean(matchRoute({ to, fuzzy: true, pending: false }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button aria-current={isActive('/main/dashboard') ? 'page' : undefined}>SIDEBAR_DASHBOARD</button>
|
||||
<button aria-current={isActive('/main/providers') ? 'page' : undefined}>ALBUM_PROVIDERS</button>
|
||||
<button aria-current={isActive('/main/plugins') ? 'page' : undefined}>SIDEBAR_PLUGINS</button>
|
||||
<button aria-current={isActive('/main/settings/settings') ? 'page' : undefined}>SETTINGS</button>
|
||||
<button>MORE</button>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function createTestRouter (initialEntry: string) {
|
||||
const rootRoute = createRootRoute({
|
||||
component: Outlet
|
||||
})
|
||||
|
||||
const mainRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/main',
|
||||
component: AppShellMock
|
||||
})
|
||||
|
||||
const mainIndexRoute = createRoute({
|
||||
getParentRoute: () => mainRoute,
|
||||
path: '/',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/main/dashboard', replace: true })
|
||||
}
|
||||
})
|
||||
|
||||
const dashboardRoute = createRoute({
|
||||
getParentRoute: () => mainRoute,
|
||||
path: '/dashboard',
|
||||
component: () => <div title='DASHBOARD_HISTORY_PANEL_TITLE'>dashboard</div>
|
||||
})
|
||||
|
||||
const providersRoute = createRoute({
|
||||
getParentRoute: () => mainRoute,
|
||||
path: '/providers',
|
||||
component: () => <div>providers</div>
|
||||
})
|
||||
|
||||
const pluginsRoute = createRoute({
|
||||
getParentRoute: () => mainRoute,
|
||||
path: '/plugins',
|
||||
component: () => <div>plugins</div>
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => mainRoute,
|
||||
path: '/settings/settings',
|
||||
component: () => <div>settings</div>
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
mainRoute.addChildren([
|
||||
mainIndexRoute,
|
||||
dashboardRoute,
|
||||
providersRoute,
|
||||
pluginsRoute,
|
||||
settingsRoute
|
||||
])
|
||||
])
|
||||
|
||||
return createRouter({
|
||||
routeTree,
|
||||
history: createMemoryHistory({
|
||||
initialEntries: [initialEntry]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function renderRoute (initialEntry: string) {
|
||||
const router = createTestRouter(initialEntry)
|
||||
|
||||
render(<RouterProvider router={router} />)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
describe('renderer app shell routing', () => {
|
||||
test('redirects /main/ to dashboard and renders dashboard-only history panel', async () => {
|
||||
renderRoute('/main/')
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'SIDEBAR_DASHBOARD' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByTitle('DASHBOARD_HISTORY_PANEL_TITLE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('drives active sidebar state from the current URL', async () => {
|
||||
renderRoute('/main/providers')
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'ALBUM_PROVIDERS' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByRole('button', { name: 'SIDEBAR_DASHBOARD' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
test('renders the v3-style shell navigation groups on desktop routes', async () => {
|
||||
renderRoute('/main/dashboard')
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'SIDEBAR_PLUGINS' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'SETTINGS' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'MORE' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import type { IConfig } from 'picgo'
|
||||
import { getConfig, getPicBeds } from '@/utils/dataSender'
|
||||
import { store, storeKey, type IStore } from '@/store'
|
||||
import {
|
||||
appActions,
|
||||
PicGoCloudLoginStatusValues,
|
||||
useStore
|
||||
} from '@/store'
|
||||
|
||||
vi.mock('@/utils/dataSender', () => {
|
||||
return {
|
||||
@@ -12,10 +15,17 @@ vi.mock('@/utils/dataSender', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const buildStore = (): IStore => {
|
||||
const app = createApp({})
|
||||
store.install(app)
|
||||
return app._context.provides[storeKey as symbol] as IStore
|
||||
const resetStore = () => {
|
||||
useStore.setState({
|
||||
defaultPicBed: 'smms',
|
||||
appConfig: null,
|
||||
picBeds: [],
|
||||
picgoCloud: {
|
||||
loginStatus: PicGoCloudLoginStatusValues.Idle,
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('renderer/store appConfig', () => {
|
||||
@@ -24,6 +34,7 @@ describe('renderer/store appConfig', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStore()
|
||||
})
|
||||
|
||||
it('refreshAppConfig updates appConfig and defaultPicBed', async () => {
|
||||
@@ -36,11 +47,13 @@ describe('renderer/store appConfig', () => {
|
||||
}
|
||||
getConfigMock.mockResolvedValue(config)
|
||||
|
||||
const storeInstance = buildStore()
|
||||
await storeInstance.refreshAppConfig()
|
||||
await appActions.refreshAppConfig()
|
||||
|
||||
expect(storeInstance.state.appConfig).toStrictEqual(config)
|
||||
expect(storeInstance.state.defaultPicBed).toBe('github')
|
||||
const nextState = useStore.getState()
|
||||
expect(nextState.appConfig?.picBed.uploader).toBe('github')
|
||||
expect(nextState.appConfig?.picBed.current).toBe('smms')
|
||||
expect(nextState.appConfig?.settings.autoCopyUrl).toBe(true)
|
||||
expect(nextState.defaultPicBed).toBe('github')
|
||||
})
|
||||
|
||||
it('refreshPicBeds updates picBeds', async () => {
|
||||
@@ -50,9 +63,8 @@ describe('renderer/store appConfig', () => {
|
||||
]
|
||||
getPicBedsMock.mockResolvedValue(picBeds)
|
||||
|
||||
const storeInstance = buildStore()
|
||||
await storeInstance.refreshPicBeds()
|
||||
await appActions.refreshPicBeds()
|
||||
|
||||
expect(storeInstance.state.picBeds).toEqual(picBeds)
|
||||
expect(useStore.getState().picBeds).toEqual(picBeds)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
installPlugin: vi.fn(),
|
||||
importLocalPlugin: vi.fn(),
|
||||
uninstallPlugin: vi.fn(),
|
||||
updatePlugin: vi.fn(),
|
||||
togglePluginEnabled: vi.fn(),
|
||||
saveTransformer: vi.fn(),
|
||||
setNeedReload: vi.fn(),
|
||||
getInstalledPlugins: vi.fn(),
|
||||
savePluginConfig: vi.fn(),
|
||||
fetchPluginReadme: vi.fn(),
|
||||
searchPlugins: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/app-actions', () => ({
|
||||
appActions: {
|
||||
hydrateAppState: vi.fn(),
|
||||
refreshAppConfig: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
import { pluginReadmeStatus, type PluginInstalledItem } from '@/components/main/plugins/types'
|
||||
import { appActions } from '@/store/app-actions'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { pluginStoreActions } from '@/store/plugins/actions'
|
||||
import { usePluginStore } from '@/store/plugins/store'
|
||||
import { toast } from 'sonner'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
|
||||
function createInstalledPlugin (overrides?: Partial<PluginInstalledItem>): PluginInstalledItem {
|
||||
return {
|
||||
name: 'cloudflare-r2-xqv',
|
||||
fullName: 'picgo-plugin-cloudflare-r2-xqv',
|
||||
author: 'xiaoqinvar',
|
||||
description: 'picgo for cloudflare-r2 storage',
|
||||
logo: 'https://example.com/logo.png',
|
||||
version: '1.0.4',
|
||||
gui: true,
|
||||
homepage: 'https://example.com',
|
||||
enabled: true,
|
||||
hasInstall: true,
|
||||
guiMenu: [],
|
||||
config: {
|
||||
plugin: {
|
||||
name: 'cloudflare-r2-xqv',
|
||||
fullName: 'picgo-plugin-cloudflare-r2-xqv',
|
||||
config: []
|
||||
},
|
||||
transformer: {
|
||||
name: 'path',
|
||||
fullName: 'path',
|
||||
config: []
|
||||
}
|
||||
},
|
||||
uploader: {
|
||||
id: 'cloudflare',
|
||||
name: 'cloudflare',
|
||||
schema: [],
|
||||
configState: {
|
||||
configList: [],
|
||||
defaultId: ''
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function resetStores () {
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'smms',
|
||||
appConfig: {
|
||||
picBed: {
|
||||
uploader: 'smms',
|
||||
current: 'smms',
|
||||
transformer: 'path',
|
||||
proxy: '',
|
||||
list: []
|
||||
},
|
||||
uploader: {},
|
||||
settings: {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: {
|
||||
port: 36677,
|
||||
host: '127.0.0.1',
|
||||
enable: true
|
||||
},
|
||||
startupMode: IStartupMode.HIDE,
|
||||
shortKey: {},
|
||||
urlRewrite: {
|
||||
rules: []
|
||||
}
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
},
|
||||
picBeds: [],
|
||||
providers: [],
|
||||
providerSchemas: {},
|
||||
pluginsInstalled: [],
|
||||
settingsVersion: {
|
||||
currentVersion: '2.5.3',
|
||||
latestVersion: null
|
||||
},
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
picgoCloud: {
|
||||
loginStatus: 'IDLE',
|
||||
loginError: null,
|
||||
hasAgreedToTermsAndPrivacy: false
|
||||
}
|
||||
})
|
||||
|
||||
usePluginStore.setState({
|
||||
searchValue: '',
|
||||
exactMatch: false,
|
||||
rawSearchResults: [],
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
isImportingLocal: false,
|
||||
isMutatingByPlugin: {},
|
||||
readmeByPlugin: {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('renderer/store plugins', () => {
|
||||
const installPluginMock = vi.mocked(pluginsAdapter.installPlugin)
|
||||
const importLocalPluginMock = vi.mocked(pluginsAdapter.importLocalPlugin)
|
||||
const uninstallPluginMock = vi.mocked(pluginsAdapter.uninstallPlugin)
|
||||
const updatePluginMock = vi.mocked(pluginsAdapter.updatePlugin)
|
||||
const togglePluginEnabledMock = vi.mocked(pluginsAdapter.togglePluginEnabled)
|
||||
const getInstalledPluginsMock = vi.mocked(pluginsAdapter.getInstalledPlugins)
|
||||
const fetchPluginReadmeMock = vi.mocked(pluginsAdapter.fetchPluginReadme)
|
||||
const hydrateAppStateMock = vi.mocked(appActions.hydrateAppState)
|
||||
const refreshAppConfigMock = vi.mocked(appActions.refreshAppConfig)
|
||||
const toastSuccessMock = vi.mocked(toast.success)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStores()
|
||||
})
|
||||
|
||||
it('installs plugin and shows success toast', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
installPluginMock.mockResolvedValue({
|
||||
success: true,
|
||||
body: installedPlugin.fullName,
|
||||
errMsg: ''
|
||||
})
|
||||
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
|
||||
|
||||
await pluginStoreActions.installPlugin(installedPlugin.fullName)
|
||||
|
||||
expect(installPluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
|
||||
expect(hydrateAppStateMock).toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pluginsInstalled).toHaveLength(1)
|
||||
expect(toastSuccessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles plugin enabled state and shows success toast', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
|
||||
togglePluginEnabledMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: installedPlugin.fullName
|
||||
})
|
||||
getInstalledPluginsMock.mockResolvedValue([
|
||||
{ ...installedPlugin, enabled: false } as unknown as IPicGoPlugin
|
||||
])
|
||||
|
||||
await pluginStoreActions.setPluginEnabled(installedPlugin.fullName, false)
|
||||
|
||||
expect(togglePluginEnabledMock).toHaveBeenCalledWith(installedPlugin.fullName, false)
|
||||
expect(useAppStore.getState().pluginsInstalled[0]?.enabled).toBe(false)
|
||||
expect(toastSuccessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates plugin and marks needReload', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
|
||||
updatePluginMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: installedPlugin.fullName
|
||||
})
|
||||
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
|
||||
|
||||
await pluginStoreActions.updatePlugin(installedPlugin.fullName)
|
||||
|
||||
expect(updatePluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
|
||||
expect(useAppStore.getState().appConfig?.needReload).toBe(true)
|
||||
expect(toastSuccessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uninstalls plugin and marks needReload', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
|
||||
uninstallPluginMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: installedPlugin.fullName
|
||||
})
|
||||
getInstalledPluginsMock.mockResolvedValue([])
|
||||
|
||||
await pluginStoreActions.uninstallPlugin(installedPlugin.fullName)
|
||||
|
||||
expect(uninstallPluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
|
||||
expect(useAppStore.getState().pluginsInstalled).toEqual([])
|
||||
expect(useAppStore.getState().appConfig?.needReload).toBe(true)
|
||||
expect(toastSuccessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports local plugin via RPC result and selects installed plugin data', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
importLocalPluginMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: installedPlugin.fullName
|
||||
})
|
||||
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
|
||||
|
||||
const result = await pluginStoreActions.importLocalPlugin()
|
||||
|
||||
expect(importLocalPluginMock).toHaveBeenCalled()
|
||||
expect(result?.installedPlugin.fullName).toBe(installedPlugin.fullName)
|
||||
expect(usePluginStore.getState().isImportingLocal).toBe(false)
|
||||
expect(toastSuccessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when local import is cancelled', async () => {
|
||||
importLocalPluginMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: null
|
||||
})
|
||||
|
||||
const result = await pluginStoreActions.importLocalPlugin()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(getInstalledPluginsMock).not.toHaveBeenCalled()
|
||||
expect(usePluginStore.getState().isImportingLocal).toBe(false)
|
||||
})
|
||||
|
||||
it('filters exact-match search results from cached raw results', () => {
|
||||
usePluginStore.setState({
|
||||
searchValue: 'cloudflare',
|
||||
exactMatch: false,
|
||||
rawSearchResults: [
|
||||
{
|
||||
name: 'cloudflare-r2-xqv',
|
||||
fullName: 'picgo-plugin-cloudflare-r2-xqv',
|
||||
author: 'foo',
|
||||
description: 'foo',
|
||||
logo: '',
|
||||
version: '1.0.0',
|
||||
homepage: '',
|
||||
gui: true,
|
||||
hasInstall: false
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
fullName: 'picgo-plugin-other',
|
||||
author: 'bar',
|
||||
description: 'bar',
|
||||
logo: '',
|
||||
version: '1.0.0',
|
||||
homepage: '',
|
||||
gui: true,
|
||||
hasInstall: false
|
||||
}
|
||||
],
|
||||
searchResults: []
|
||||
})
|
||||
|
||||
pluginStoreActions.toggleExactMatch()
|
||||
|
||||
expect(usePluginStore.getState().exactMatch).toBe(true)
|
||||
expect(usePluginStore.getState().searchResults).toHaveLength(1)
|
||||
expect(usePluginStore.getState().searchResults[0]?.fullName).toBe(
|
||||
'picgo-plugin-cloudflare-r2-xqv'
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches README into ready state', async () => {
|
||||
fetchPluginReadmeMock.mockResolvedValue('# hello')
|
||||
|
||||
await pluginStoreActions.fetchPluginReadme('picgo-plugin-cloudflare-r2-xqv')
|
||||
|
||||
expect(usePluginStore.getState().readmeByPlugin['picgo-plugin-cloudflare-r2-xqv']).toEqual({
|
||||
status: pluginReadmeStatus.Ready,
|
||||
content: '# hello',
|
||||
errorMessage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes app config after saving plugin config', async () => {
|
||||
const installedPlugin = createInstalledPlugin()
|
||||
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
|
||||
vi.mocked(pluginsAdapter.savePluginConfig).mockResolvedValue(undefined)
|
||||
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
|
||||
|
||||
await pluginStoreActions.savePluginConfig(installedPlugin.fullName, 'config', {
|
||||
token: 'abc'
|
||||
})
|
||||
|
||||
expect(vi.mocked(pluginsAdapter.savePluginConfig)).toHaveBeenCalledWith(
|
||||
'plugin',
|
||||
installedPlugin.config.plugin.fullName,
|
||||
{ token: 'abc' }
|
||||
)
|
||||
expect(refreshAppConfigMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppConfig } from '@/components/main/providers/types'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
|
||||
vi.mock('@/adapters/providers', () => ({
|
||||
providersAdapter: {
|
||||
getProviderSchema: vi.fn(),
|
||||
getProviderConfigList: vi.fn(),
|
||||
selectProviderConfig: vi.fn(),
|
||||
changeCurrentUploader: vi.fn(),
|
||||
deleteProviderConfig: vi.fn(),
|
||||
copyProviderConfig: vi.fn(),
|
||||
saveProviderConfig: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/app-actions', () => ({
|
||||
appActions: {
|
||||
hydrateAppState: vi.fn(),
|
||||
ensureHydrated: vi.fn(),
|
||||
ensureSettingsHydrated: vi.fn(),
|
||||
setDefaultPicBed: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { providersAdapter } from '@/adapters/providers'
|
||||
import { appActions } from '@/store/app-actions'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { useProviderStore } from '@/store/providers/store'
|
||||
import { providerStoreActions } from '@/store/providers/actions'
|
||||
|
||||
function createAppConfig (): AppConfig {
|
||||
return {
|
||||
picBed: {
|
||||
uploader: 'github',
|
||||
current: 'github',
|
||||
transformer: 'path',
|
||||
proxy: '',
|
||||
list: [
|
||||
{ type: 'github', name: 'GitHub', visible: true }
|
||||
]
|
||||
},
|
||||
uploader: {
|
||||
github: {
|
||||
defaultId: 'cfg-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'cfg-1',
|
||||
_configName: 'Default',
|
||||
_createdAt: 1,
|
||||
_updatedAt: 1,
|
||||
token: 'old-token'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: {
|
||||
port: 36677,
|
||||
host: '127.0.0.1',
|
||||
enable: true
|
||||
},
|
||||
startupMode: IStartupMode.SHOW_MAIN_WINDOW,
|
||||
shortKey: {
|
||||
'picgo:upload': {
|
||||
name: 'upload',
|
||||
label: 'Quick Upload',
|
||||
key: 'CommandOrControl+Shift+U',
|
||||
enable: true
|
||||
}
|
||||
},
|
||||
urlRewrite: {
|
||||
rules: []
|
||||
}
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {},
|
||||
transformer: {},
|
||||
needReload: false
|
||||
}
|
||||
}
|
||||
|
||||
function resetStores () {
|
||||
useAppStore.setState((state) => {
|
||||
state.appConfig = createAppConfig()
|
||||
state.providers = [
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
visible: true,
|
||||
isDefaultUploader: true
|
||||
}
|
||||
]
|
||||
state.providerSchemas = {}
|
||||
state.hasHydrated = true
|
||||
})
|
||||
|
||||
useProviderStore.setState((state) => {
|
||||
state.isHydrating = false
|
||||
state.isLoadingByProvider = {}
|
||||
state.expandedProviderIds = []
|
||||
state.searchValue = ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('renderer/store providers', () => {
|
||||
const getProviderSchemaMock = vi.mocked(providersAdapter.getProviderSchema)
|
||||
const selectProviderConfigMock = vi.mocked(providersAdapter.selectProviderConfig)
|
||||
const changeCurrentUploaderMock = vi.mocked(providersAdapter.changeCurrentUploader)
|
||||
const deleteProviderConfigMock = vi.mocked(providersAdapter.deleteProviderConfig)
|
||||
const copyProviderConfigMock = vi.mocked(providersAdapter.copyProviderConfig)
|
||||
const saveProviderConfigMock = vi.mocked(providersAdapter.saveProviderConfig)
|
||||
const hydrateAppStateMock = vi.mocked(appActions.hydrateAppState)
|
||||
const setDefaultPicBedMock = vi.mocked(appActions.setDefaultPicBed)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStores()
|
||||
})
|
||||
|
||||
it('loads provider schema into global store', async () => {
|
||||
getProviderSchemaMock.mockResolvedValue({
|
||||
name: 'GitHub',
|
||||
config: [
|
||||
{
|
||||
name: 'token',
|
||||
type: 'password',
|
||||
required: true,
|
||||
alias: 'Token'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const schema = await providerStoreActions.ensureSchema('github')
|
||||
|
||||
expect(schema).toEqual({
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
config: [
|
||||
{
|
||||
name: 'token',
|
||||
type: 'password',
|
||||
required: true,
|
||||
alias: 'Token',
|
||||
default: undefined,
|
||||
message: undefined,
|
||||
prefix: undefined,
|
||||
tips: undefined,
|
||||
confirmText: undefined,
|
||||
cancelText: undefined,
|
||||
choices: undefined
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(useAppStore.getState().providerSchemas.github).toEqual(schema)
|
||||
expect(useProviderStore.getState().isLoadingByProvider.github).toBe(false)
|
||||
})
|
||||
|
||||
it('maps config id to name for default/select dashboard actions', async () => {
|
||||
selectProviderConfigMock.mockResolvedValue('cfg-1')
|
||||
|
||||
const selectedId = await providerStoreActions.setDefaultConfig('github', 'cfg-1')
|
||||
|
||||
expect(selectProviderConfigMock).toHaveBeenCalledWith('github', 'Default')
|
||||
expect(hydrateAppStateMock).toHaveBeenCalled()
|
||||
expect(selectedId).toBe('cfg-1')
|
||||
|
||||
await providerStoreActions.selectDashboardProviderConfig('github', 'cfg-1')
|
||||
|
||||
expect(changeCurrentUploaderMock).toHaveBeenCalledWith('github', 'Default')
|
||||
})
|
||||
|
||||
it('creates, copies, deletes and saves provider configs with real adapter calls', async () => {
|
||||
saveProviderConfigMock.mockImplementation(async (_type, configId, values) => {
|
||||
if (!configId && values._configName === 'New Config') {
|
||||
useAppStore.setState((state) => {
|
||||
if (!state.appConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
state.appConfig.uploader.github.configList.push({
|
||||
_id: 'cfg-2',
|
||||
_configName: 'New Config',
|
||||
_createdAt: 2,
|
||||
_updatedAt: 2
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createdId = await providerStoreActions.createConfig('github', 'New Config')
|
||||
|
||||
expect(saveProviderConfigMock).toHaveBeenCalledWith('github', '', {
|
||||
_configName: 'New Config'
|
||||
})
|
||||
expect(createdId).toBe('cfg-2')
|
||||
|
||||
await providerStoreActions.saveConfig('github', 'cfg-2', {
|
||||
token: 'new-token'
|
||||
})
|
||||
|
||||
expect(saveProviderConfigMock).toHaveBeenLastCalledWith('github', 'cfg-2', {
|
||||
token: 'new-token',
|
||||
_configName: 'New Config'
|
||||
})
|
||||
|
||||
copyProviderConfigMock.mockResolvedValue({
|
||||
defaultId: 'cfg-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'cfg-1',
|
||||
_configName: 'Default',
|
||||
_createdAt: 1,
|
||||
_updatedAt: 1
|
||||
},
|
||||
{
|
||||
_id: 'cfg-3',
|
||||
_configName: 'Default - Copy',
|
||||
_createdAt: 3,
|
||||
_updatedAt: 3
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const copiedId = await providerStoreActions.copyConfig(
|
||||
'github',
|
||||
'cfg-1',
|
||||
'Default - Copy'
|
||||
)
|
||||
|
||||
expect(copyProviderConfigMock).toHaveBeenCalledWith(
|
||||
'github',
|
||||
'Default',
|
||||
'Default - Copy'
|
||||
)
|
||||
expect(copiedId).toBe('cfg-3')
|
||||
|
||||
deleteProviderConfigMock.mockResolvedValue({
|
||||
defaultId: 'cfg-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'cfg-1',
|
||||
_configName: 'Default',
|
||||
_createdAt: 1,
|
||||
_updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const fallbackId = await providerStoreActions.deleteConfig('github', 'cfg-2')
|
||||
|
||||
expect(deleteProviderConfigMock).toHaveBeenCalledWith('github', 'New Config')
|
||||
expect(fallbackId).toBe('cfg-1')
|
||||
})
|
||||
|
||||
it('forwards default provider changes to app actions', async () => {
|
||||
await providerStoreActions.setDefaultProvider('github')
|
||||
|
||||
expect(setDefaultPicBedMock).toHaveBeenCalledWith('github')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IConfig } from 'picgo'
|
||||
import type { AppConfig } from '@/components/main/providers/types'
|
||||
import type { SettingsUrlRewriteRule } from '@/components/main/settings/utils'
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
|
||||
vi.mock('@/adapters/app-config', () => ({
|
||||
appConfigAdapter: {
|
||||
getAppConfig: vi.fn(),
|
||||
getPicBeds: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
subscribeToUpdates: vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/settings', () => ({
|
||||
settingsAdapter: {
|
||||
savePatch: vi.fn(),
|
||||
saveSingle: vi.fn(),
|
||||
setAutoStart: vi.fn(),
|
||||
showDockIcon: vi.fn(),
|
||||
showMenubarIcon: vi.fn(),
|
||||
openConfigFile: vi.fn(),
|
||||
openLogFile: vi.fn(),
|
||||
openExternalUrl: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
updateCustomLink: vi.fn(),
|
||||
checkLatestVersion: vi.fn(),
|
||||
loadShortcuts: vi.fn(),
|
||||
toggleShortcutModifiedMode: vi.fn(),
|
||||
toggleShortcutEnabled: vi.fn(),
|
||||
updateShortcut: vi.fn(),
|
||||
loadUrlRewriteRules: vi.fn(),
|
||||
saveUrlRewriteRules: vi.fn(),
|
||||
updateVisiblePicBeds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
rendererStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { appConfigAdapter } from '@/adapters/app-config'
|
||||
import { settingsAdapter } from '@/adapters/settings'
|
||||
import { appActions, settingsStoreActions, useStore } from '@/store'
|
||||
|
||||
function createAppConfig (
|
||||
overrides?: Partial<AppConfig>,
|
||||
settingsOverrides?: Partial<AppConfig['settings']>
|
||||
): AppConfig {
|
||||
const {
|
||||
settings: overriddenSettings,
|
||||
...configOverrides
|
||||
} = overrides ?? {}
|
||||
const baseSettings: AppConfig['settings'] = {
|
||||
appearance: 'auto',
|
||||
pasteStyle: IPasteStyle.MARKDOWN,
|
||||
showUpdateTip: false,
|
||||
autoStart: false,
|
||||
rename: false,
|
||||
autoRename: false,
|
||||
uploadNotification: false,
|
||||
notificationSound: true,
|
||||
miniWindowOnTop: false,
|
||||
logLevel: ['all'],
|
||||
autoCopyUrl: true,
|
||||
checkBetaUpdate: true,
|
||||
useBuiltinClipboard: false,
|
||||
language: 'en',
|
||||
logFileSizeLimit: 10,
|
||||
encodeOutputURL: false,
|
||||
showDockIcon: true,
|
||||
showMenubarIcon: true,
|
||||
customLink: '$url',
|
||||
npmProxy: '',
|
||||
npmRegistry: '',
|
||||
server: {
|
||||
port: 36677,
|
||||
host: '127.0.0.1',
|
||||
enable: true
|
||||
},
|
||||
startupMode: IStartupMode.HIDE,
|
||||
shortKey: {
|
||||
'picgo:upload': {
|
||||
name: 'upload',
|
||||
label: 'Quick Upload',
|
||||
key: 'CommandOrControl+Shift+U',
|
||||
enable: true
|
||||
}
|
||||
},
|
||||
urlRewrite: {
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
picBed: {
|
||||
uploader: 'smms',
|
||||
current: 'smms',
|
||||
transformer: '',
|
||||
proxy: '',
|
||||
list: [
|
||||
{ type: 'smms', name: 'SM.MS', visible: true },
|
||||
{ type: 'github', name: 'GitHub', visible: false }
|
||||
]
|
||||
},
|
||||
uploader: {},
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...overriddenSettings,
|
||||
...settingsOverrides
|
||||
},
|
||||
picgoPlugins: {},
|
||||
plugins: {},
|
||||
transformer: {},
|
||||
needReload: false,
|
||||
...configOverrides
|
||||
}
|
||||
}
|
||||
|
||||
function resetStore (overrides?: Partial<AppConfig>) {
|
||||
useStore.setState({
|
||||
hasHydrated: true,
|
||||
hasSettingsHydrated: true,
|
||||
appConfig: createAppConfig(overrides),
|
||||
picBeds: [
|
||||
{ type: 'smms', name: 'SM.MS', visible: true },
|
||||
{ type: 'github', name: 'GitHub', visible: false }
|
||||
],
|
||||
settingsVersion: {
|
||||
currentVersion: '3.0.0',
|
||||
latestVersion: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('renderer/store settings', () => {
|
||||
const getAppConfigMock = vi.mocked(appConfigAdapter.getAppConfig)
|
||||
const getPicBedsMock = vi.mocked(appConfigAdapter.getPicBeds)
|
||||
const saveAppConfigMock = vi.mocked(appConfigAdapter.saveConfig)
|
||||
const savePatchMock = vi.mocked(settingsAdapter.savePatch)
|
||||
const setAutoStartMock = vi.mocked(settingsAdapter.setAutoStart)
|
||||
const updateVisiblePicBedsMock = vi.mocked(settingsAdapter.updateVisiblePicBeds)
|
||||
const updateShortcutMock = vi.mocked(settingsAdapter.updateShortcut)
|
||||
const toggleShortcutEnabledMock = vi.mocked(settingsAdapter.toggleShortcutEnabled)
|
||||
const saveUrlRewriteRulesMock = vi.mocked(settingsAdapter.saveUrlRewriteRules)
|
||||
const checkLatestVersionMock = vi.mocked(settingsAdapter.checkLatestVersion)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStore()
|
||||
})
|
||||
|
||||
it('hydrates settings from real config fields', async () => {
|
||||
const config: IConfig = {
|
||||
picBed: {
|
||||
uploader: 'smms',
|
||||
current: 'smms',
|
||||
proxy: 'http://127.0.0.1:7890'
|
||||
},
|
||||
settings: {
|
||||
language: 'zh-CN',
|
||||
autoStart: true,
|
||||
shortKey: {
|
||||
'picgo:upload': {
|
||||
key: 'CommandOrControl+Shift+P',
|
||||
name: 'upload',
|
||||
label: 'Quick Upload',
|
||||
enable: true
|
||||
}
|
||||
},
|
||||
urlRewrite: {
|
||||
rules: [
|
||||
{
|
||||
match: 'foo',
|
||||
replace: 'bar',
|
||||
enable: false,
|
||||
global: true,
|
||||
ignoreCase: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
picgoPlugins: {}
|
||||
}
|
||||
|
||||
getAppConfigMock.mockResolvedValue(config)
|
||||
getPicBedsMock.mockResolvedValue([
|
||||
{ type: 'smms', name: 'SM.MS', visible: true },
|
||||
{ type: 'github', name: 'GitHub', visible: false }
|
||||
])
|
||||
|
||||
useStore.setState({
|
||||
hasHydrated: false,
|
||||
hasSettingsHydrated: false,
|
||||
appConfig: null
|
||||
})
|
||||
|
||||
await appActions.hydrateAppState()
|
||||
|
||||
const state = useStore.getState().appConfig
|
||||
expect(state?.settings.language).toBe('zh-CN')
|
||||
expect(state?.settings.autoStart).toBe(true)
|
||||
expect(state?.picBed.proxy).toBe('http://127.0.0.1:7890')
|
||||
expect(state?.settings.shortKey).toEqual({
|
||||
'picgo:upload': {
|
||||
name: 'upload',
|
||||
label: 'Quick Upload',
|
||||
key: 'CommandOrControl+Shift+P',
|
||||
enable: true
|
||||
}
|
||||
})
|
||||
expect(state?.settings.urlRewrite).toEqual({
|
||||
rules: [
|
||||
{
|
||||
match: 'foo',
|
||||
replace: 'bar',
|
||||
enable: false,
|
||||
global: true,
|
||||
ignoreCase: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('persists prefixed settings keys without duplicating the settings prefix', async () => {
|
||||
await settingsStoreActions.saveSettingsConfig('settings.language', 'zh-CN')
|
||||
|
||||
expect(savePatchMock).toHaveBeenCalledWith({
|
||||
'settings.language': 'zh-CN'
|
||||
})
|
||||
expect(useStore.getState().appConfig?.settings.language).toBe('zh-CN')
|
||||
})
|
||||
|
||||
it('persists proxy and visible pic beds with production config paths', async () => {
|
||||
const nextPicBedList = [
|
||||
{ type: 'smms', name: 'SM.MS', visible: true },
|
||||
{ type: 'github', name: 'GitHub', visible: true }
|
||||
]
|
||||
|
||||
updateVisiblePicBedsMock.mockResolvedValue(nextPicBedList)
|
||||
|
||||
await settingsStoreActions.saveSettingsConfig({
|
||||
npmProxy: 'http://127.0.0.1:7891',
|
||||
npmRegistry: 'https://registry.npmmirror.com'
|
||||
})
|
||||
|
||||
expect(savePatchMock).toHaveBeenCalledWith({
|
||||
'settings.npmProxy': 'http://127.0.0.1:7891',
|
||||
'settings.npmRegistry': 'https://registry.npmmirror.com'
|
||||
})
|
||||
expect(useStore.getState().appConfig?.settings.npmProxy).toBe(
|
||||
'http://127.0.0.1:7891'
|
||||
)
|
||||
|
||||
await settingsStoreActions.savePicBedProxy('http://127.0.0.1:7890')
|
||||
|
||||
expect(saveAppConfigMock).toHaveBeenCalledWith({
|
||||
'picBed.proxy': 'http://127.0.0.1:7890'
|
||||
})
|
||||
expect(useStore.getState().appConfig?.picBed.proxy).toBe('http://127.0.0.1:7890')
|
||||
|
||||
await settingsStoreActions.saveVisiblePicBedNames(['SM.MS', 'GitHub'])
|
||||
|
||||
expect(updateVisiblePicBedsMock).toHaveBeenCalledWith(['SM.MS', 'GitHub'])
|
||||
expect(useStore.getState().appConfig?.picBed.list).toEqual(nextPicBedList)
|
||||
})
|
||||
|
||||
it('updates shortcuts through main-process handlers and syncs local state', async () => {
|
||||
updateShortcutMock.mockResolvedValue(true)
|
||||
|
||||
await settingsStoreActions.updateShortcutKeys('picgo:upload', [
|
||||
'CommandOrControl',
|
||||
'Shift',
|
||||
'P'
|
||||
])
|
||||
|
||||
expect(updateShortcutMock).toHaveBeenCalledWith({
|
||||
enable: true,
|
||||
key: 'CommandOrControl+Shift+P',
|
||||
label: 'Quick Upload',
|
||||
name: 'upload',
|
||||
from: 'picgo'
|
||||
}, 'CommandOrControl+Shift+U')
|
||||
expect(useStore.getState().appConfig?.settings.shortKey['picgo:upload']?.key).toBe(
|
||||
'CommandOrControl+Shift+P'
|
||||
)
|
||||
|
||||
await settingsStoreActions.setShortcutEnabled('picgo:upload', false)
|
||||
|
||||
expect(toggleShortcutEnabledMock).toHaveBeenCalledWith({
|
||||
enable: false,
|
||||
key: 'CommandOrControl+Shift+P',
|
||||
label: 'Quick Upload',
|
||||
name: 'upload',
|
||||
from: 'picgo'
|
||||
})
|
||||
expect(useStore.getState().appConfig?.settings.shortKey['picgo:upload']?.enable).toBe(false)
|
||||
})
|
||||
|
||||
it('persists url rewrite rules and update checks via real adapters', async () => {
|
||||
const nextRules: SettingsUrlRewriteRule[] = [
|
||||
{
|
||||
match: 'foo',
|
||||
replace: 'bar',
|
||||
enable: false,
|
||||
global: true,
|
||||
ignoreCase: true
|
||||
}
|
||||
]
|
||||
|
||||
await settingsStoreActions.saveUrlRewriteRules(nextRules)
|
||||
|
||||
expect(saveUrlRewriteRulesMock).toHaveBeenCalledWith([
|
||||
{
|
||||
match: 'foo',
|
||||
replace: 'bar',
|
||||
enable: false,
|
||||
global: true,
|
||||
ignoreCase: true
|
||||
}
|
||||
])
|
||||
expect(useStore.getState().appConfig?.settings.urlRewrite.rules).toEqual(nextRules)
|
||||
|
||||
checkLatestVersionMock.mockResolvedValue('9.9.9')
|
||||
|
||||
const result = await settingsStoreActions.checkUpdates()
|
||||
|
||||
expect(result).toEqual({
|
||||
currentVersion: '3.0.0',
|
||||
latestVersion: '9.9.9',
|
||||
hasUpdate: true
|
||||
})
|
||||
expect(useStore.getState().settingsVersion.latestVersion).toBe('9.9.9')
|
||||
})
|
||||
|
||||
it('runs side effects after saving linked settings items', async () => {
|
||||
await settingsStoreActions.saveSettingsConfig('settings.autoStart', true)
|
||||
|
||||
expect(setAutoStartMock).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type CleanupFn = ReturnType<typeof vi.fn>
|
||||
|
||||
describe('renderer/utils/bridge', () => {
|
||||
let onCleanup: CleanupFn
|
||||
let onceCleanup: CleanupFn
|
||||
|
||||
const bridgeApiMock = {
|
||||
ipc: {
|
||||
invoke: vi.fn(),
|
||||
send: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
on: vi.fn((_channel: string, _listener: BridgeIpcListener) => {
|
||||
onCleanup = vi.fn()
|
||||
return onCleanup
|
||||
}),
|
||||
once: vi.fn((_channel: string, _listener: BridgeIpcListener) => {
|
||||
onceCleanup = vi.fn()
|
||||
return onceCleanup
|
||||
})
|
||||
},
|
||||
clipboard: {
|
||||
writeText: vi.fn()
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: vi.fn()
|
||||
},
|
||||
webFrame: {
|
||||
setVisualZoomLevelLimits: vi.fn()
|
||||
},
|
||||
env: {
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
isDev: false
|
||||
},
|
||||
i18n: {
|
||||
ObjectAdapter: {
|
||||
create: vi.fn()
|
||||
},
|
||||
I18n: {
|
||||
createFromLocales: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
onCleanup = vi.fn()
|
||||
onceCleanup = vi.fn()
|
||||
vi.stubGlobal('window', { bridgeApi: bridgeApiMock })
|
||||
vi.stubGlobal('bridgeApi', bridgeApiMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns preload cleanup for on', async () => {
|
||||
const { ipc } = await import('@/utils/bridge')
|
||||
const listener = vi.fn()
|
||||
|
||||
const cleanup = ipc.on('demo', listener)
|
||||
cleanup()
|
||||
|
||||
expect(bridgeApiMock.ipc.on).toHaveBeenCalledWith('demo', listener)
|
||||
expect(onCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns preload cleanup for once', async () => {
|
||||
const { ipc } = await import('@/utils/bridge')
|
||||
const listener = vi.fn()
|
||||
|
||||
const cleanup = ipc.once('demo', listener)
|
||||
cleanup()
|
||||
|
||||
expect(bridgeApiMock.ipc.once).toHaveBeenCalledTimes(1)
|
||||
expect(bridgeApiMock.ipc.once).toHaveBeenCalledWith('demo', listener)
|
||||
expect(onceCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('delegates removeAllListeners to preload', async () => {
|
||||
const { ipc } = await import('@/utils/bridge')
|
||||
ipc.removeAllListeners('demo')
|
||||
|
||||
expect(bridgeApiMock.ipc.removeAllListeners).toHaveBeenCalledWith('demo')
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { saveConfig } from '@/utils/dataSender'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PICGO_SAVE_CONFIG } from '#/events/constants'
|
||||
import { ipcRenderer } from 'electron'
|
||||
|
||||
vi.mock('electron', () => {
|
||||
return {
|
||||
ipcRenderer: {
|
||||
describe('renderer/utils/dataSender', () => {
|
||||
const bridgeApiMock = {
|
||||
ipc: {
|
||||
invoke: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
send: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
removeAllListeners: vi.fn()
|
||||
},
|
||||
clipboard: {
|
||||
writeText: vi.fn()
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: vi.fn()
|
||||
},
|
||||
webFrame: {
|
||||
setVisualZoomLevelLimits: vi.fn()
|
||||
},
|
||||
env: {
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
isDev: false
|
||||
},
|
||||
i18n: {
|
||||
ObjectAdapter: {
|
||||
create: vi.fn()
|
||||
},
|
||||
I18n: {
|
||||
createFromLocales: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('renderer/utils/dataSender', () => {
|
||||
const ipcRendererMock = vi.mocked(ipcRenderer)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
ipcRendererMock.invoke.mockResolvedValue(true)
|
||||
vi.stubGlobal('window', { bridgeApi: bridgeApiMock })
|
||||
vi.stubGlobal('bridgeApi', bridgeApiMock)
|
||||
bridgeApiMock.ipc.invoke.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('invokes save config IPC after saveConfig', async () => {
|
||||
const { saveConfig } = await import('@/utils/dataSender')
|
||||
|
||||
await saveConfig('settings.language', 'en')
|
||||
|
||||
expect(ipcRendererMock.invoke).toHaveBeenCalledWith(PICGO_SAVE_CONFIG, {
|
||||
expect(bridgeApiMock.ipc.invoke).toHaveBeenCalledWith(PICGO_SAVE_CONFIG, {
|
||||
'settings.language': 'en'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach } from 'vitest'
|
||||
import {
|
||||
GET_CURRENT_LANGUAGE,
|
||||
GET_LANGUAGE_LIST,
|
||||
GET_PICBEDS,
|
||||
PICGO_GET_CONFIG,
|
||||
RPC_ACTIONS
|
||||
} from '#/events/constants'
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
|
||||
type TestBridgeListener = (...args: unknown[]) => void
|
||||
|
||||
const bridgeListeners = new Map<string, Set<TestBridgeListener>>()
|
||||
|
||||
const addBridgeListener = (channel: string, listener: TestBridgeListener) => {
|
||||
const listeners = bridgeListeners.get(channel) || new Set<TestBridgeListener>()
|
||||
listeners.add(listener)
|
||||
bridgeListeners.set(channel, listeners)
|
||||
|
||||
return () => {
|
||||
const currentListeners = bridgeListeners.get(channel)
|
||||
currentListeners?.delete(listener)
|
||||
if (currentListeners?.size === 0) {
|
||||
bridgeListeners.delete(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emitBridgeEvent = (channel: string, ...args: unknown[]) => {
|
||||
const listeners = bridgeListeners.get(channel)
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
|
||||
Array.from(listeners).forEach((listener) => {
|
||||
listener(...args)
|
||||
})
|
||||
}
|
||||
|
||||
const createDefaultBridgeApi = (): BridgeApi => {
|
||||
return {
|
||||
ipc: {
|
||||
send: (channel: string, ...args: unknown[]) => {
|
||||
if (channel === PICGO_GET_CONFIG) {
|
||||
emitBridgeEvent(PICGO_GET_CONFIG, undefined, args[1])
|
||||
return
|
||||
}
|
||||
|
||||
if (channel === GET_PICBEDS) {
|
||||
emitBridgeEvent(GET_PICBEDS, [])
|
||||
return
|
||||
}
|
||||
|
||||
if (channel === GET_CURRENT_LANGUAGE) {
|
||||
emitBridgeEvent(GET_CURRENT_LANGUAGE, 'en')
|
||||
return
|
||||
}
|
||||
|
||||
if (channel === GET_LANGUAGE_LIST) {
|
||||
emitBridgeEvent(GET_LANGUAGE_LIST, [])
|
||||
}
|
||||
},
|
||||
invoke: async <T>(channel: string, ...args: unknown[]) => {
|
||||
if (channel === RPC_ACTIONS) {
|
||||
const action = args[0]
|
||||
|
||||
if (action === IRPCActionType.GET_WINDOW_STATE) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isMaximized: false
|
||||
},
|
||||
error: ''
|
||||
} as T
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
error: ''
|
||||
} as T
|
||||
}
|
||||
|
||||
return undefined as T
|
||||
},
|
||||
on: (channel: string, listener: BridgeIpcListener) => addBridgeListener(channel, listener),
|
||||
once: (channel: string, listener: BridgeIpcListener) => {
|
||||
let cleanup = () => {}
|
||||
const wrappedListener: TestBridgeListener = (...args: unknown[]) => {
|
||||
cleanup()
|
||||
listener(...args)
|
||||
}
|
||||
cleanup = addBridgeListener(channel, wrappedListener)
|
||||
return cleanup
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
bridgeListeners.delete(channel)
|
||||
}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: () => '/tmp/mock-file.png'
|
||||
},
|
||||
webFrame: {
|
||||
setVisualZoomLevelLimits: () => {}
|
||||
},
|
||||
env: {
|
||||
platform: 'darwin',
|
||||
isDev: false
|
||||
},
|
||||
i18n: {
|
||||
ObjectAdapter: {
|
||||
create: (locales: Record<string, ILocales>) => ({
|
||||
getLocale: (language: string) => locales[language],
|
||||
setLocales: () => {},
|
||||
setLocale: () => {}
|
||||
})
|
||||
},
|
||||
I18n: {
|
||||
createFromLocales: (locales: Record<string, ILocales>, defaultLanguage: string) => {
|
||||
let language = defaultLanguage
|
||||
|
||||
return {
|
||||
getLanguage: () => language,
|
||||
setLanguage: (nextLanguage: string) => {
|
||||
language = nextLanguage
|
||||
},
|
||||
setDefaultLanguage: (nextLanguage: string) => {
|
||||
language = nextLanguage
|
||||
},
|
||||
translate: (key: ILocalesKey, args: IStringKeyMap = {}) => {
|
||||
const locale = locales[language]
|
||||
const template = locale?.[key] || key
|
||||
|
||||
return Object.keys(args).reduce((result, token) => {
|
||||
return result.replaceAll(`\${${token}}`, String(args[token]))
|
||||
}, template)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installDefaultBridgeApi = () => {
|
||||
const bridgeApi = createDefaultBridgeApi()
|
||||
;(globalThis as typeof globalThis & { bridgeApi?: BridgeApi }).bridgeApi = bridgeApi
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.bridgeApi = bridgeApi
|
||||
}
|
||||
}
|
||||
|
||||
installDefaultBridgeApi()
|
||||
|
||||
beforeEach(() => {
|
||||
bridgeListeners.clear()
|
||||
installDefaultBridgeApi()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bridgeListeners.clear()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined' && !window.matchMedia) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'electron'
|
||||
import uploader from 'apis/app/uploader'
|
||||
import picgo from '@core/picgo'
|
||||
import { GalleryDB } from '~/main/apis/core/datastore'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { IPasteStyle, IWindowList } from '#/types/enum'
|
||||
import pasteTemplate from '~/main/utils/pasteTemplate'
|
||||
@@ -235,7 +235,7 @@ export function createTray () {
|
||||
// icon: files[i]
|
||||
})
|
||||
}, i * 100)
|
||||
await GalleryDB.getInstance().insert(imgs[i])
|
||||
await AlbumDB.getInstance().insert(imgs[i])
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
trayWindow.webContents.send('dragFiles', imgs)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
WebContents
|
||||
} from 'electron'
|
||||
import type { WebContents } from 'electron'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
|
||||
import uploader from '.'
|
||||
import pasteTemplate from '~/main/utils/pasteTemplate'
|
||||
import { GalleryDB } from '~/main/apis/core/datastore'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import { handleCopyUrl, handleUrlEncodeWithSetting, showNotification } from '~/main/utils/common'
|
||||
import { T } from '~/main/i18n/index'
|
||||
import logger from '@core/picgo/logger'
|
||||
@@ -21,7 +19,7 @@ const handleClipboardUploading = async (): Promise<false | ImgInfo[]> => {
|
||||
return await uploader.setWebContents(win!.webContents).upload()
|
||||
}
|
||||
|
||||
export const uploadClipboardFiles = async (): Promise<string> => {
|
||||
export const uploadClipboardFilesWithInfo = async (): Promise<ImgInfo[]> => {
|
||||
logger.info('upload clipboard file')
|
||||
const img = await handleClipboardUploading()
|
||||
if (img !== false) {
|
||||
@@ -36,30 +34,34 @@ export const uploadClipboardFiles = async (): Promise<string> => {
|
||||
// icon: img[0].imgUrl
|
||||
})
|
||||
}, 100)
|
||||
await GalleryDB.getInstance().insert(img[0])
|
||||
await AlbumDB.getInstance().insert(img[0])
|
||||
// trayWindow just be created in mac/windows, not in linux
|
||||
trayWindow?.webContents?.send('clipboardFiles', [])
|
||||
trayWindow?.webContents?.send('uploadFiles', img)
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_GALLERY)
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
return handleUrlEncodeWithSetting(img[0].imgUrl as string)
|
||||
return img
|
||||
} else {
|
||||
showNotification({
|
||||
title: T('UPLOAD_FAILED'),
|
||||
body: T('TIPS_UPLOAD_NOT_PICTURES')
|
||||
})
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[]): Promise<string[]> => {
|
||||
export const uploadClipboardFiles = async (): Promise<string> => {
|
||||
const img = await uploadClipboardFilesWithInfo()
|
||||
return img[0]?.imgUrl ? handleUrlEncodeWithSetting(img[0].imgUrl) : ''
|
||||
}
|
||||
|
||||
export const uploadSelectedFilesWithInfo = async (webContents: WebContents, files: IFileWithPath[]): Promise<ImgInfo[]> => {
|
||||
const input = files.map(item => item.path)
|
||||
const imgs = await uploader.setWebContents(webContents).upload(input)
|
||||
const result = []
|
||||
if (imgs !== false) {
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
const pasteText: string[] = []
|
||||
@@ -72,17 +74,24 @@ export const uploadSelectedFiles = async (webContents: WebContents, files: IFile
|
||||
// icon: files[i].path
|
||||
})
|
||||
}, i * 100)
|
||||
await GalleryDB.getInstance().insert(imgs[i])
|
||||
result.push(handleUrlEncodeWithSetting(imgs[i].imgUrl!))
|
||||
await AlbumDB.getInstance().insert(imgs[i])
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
// trayWindow just be created in mac/windows, not in linux
|
||||
windowManager.get(IWindowList.TRAY_WINDOW)?.webContents?.send('uploadFiles', imgs)
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_GALLERY)
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
return result
|
||||
return imgs
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[]): Promise<string[]> => {
|
||||
const imgs = await uploadSelectedFilesWithInfo(webContents, files)
|
||||
return imgs
|
||||
.map(item => item.imgUrl)
|
||||
.filter((url): url is string => typeof url === 'string' && url !== '')
|
||||
.map(url => handleUrlEncodeWithSetting(url))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { buildRendererUrl } from '~/main/utils/env'
|
||||
|
||||
export const TRAY_WINDOW_URL = buildRendererUrl()
|
||||
export const TRAY_WINDOW_URL = buildRendererUrl('tray')
|
||||
|
||||
export const SETTING_WINDOW_URL = buildRendererUrl('main-page/upload')
|
||||
export const SETTING_WINDOW_URL = buildRendererUrl('main/dashboard')
|
||||
|
||||
export const MINI_WINDOW_URL = buildRendererUrl('mini-page')
|
||||
export const MINI_WINDOW_URL = buildRendererUrl('mini')
|
||||
|
||||
export const RENAME_WINDOW_URL = buildRendererUrl('rename-page')
|
||||
export const RENAME_WINDOW_URL = buildRendererUrl('rename')
|
||||
|
||||
export const TOOLBOX_WINDOW_URL = buildRendererUrl('toolbox-page')
|
||||
export const TOOLBOX_WINDOW_URL = buildRendererUrl('toolbox')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// import path from 'path'
|
||||
import path from 'path'
|
||||
import {
|
||||
SETTING_WINDOW_URL,
|
||||
TRAY_WINDOW_URL,
|
||||
@@ -9,22 +9,23 @@ import {
|
||||
import { IStartupMode, IWindowList } from '#/types/enum'
|
||||
import bus from '@core/bus'
|
||||
import { CREATE_APP_MENU } from '@core/bus/constants'
|
||||
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '#/events/constants'
|
||||
import { TOGGLE_SHORTKEY_MODIFIED_MODE, WINDOW_STATE_CHANGED } from '#/events/constants'
|
||||
import { app } from 'electron'
|
||||
import { T } from '~/main/i18n'
|
||||
import { isLinux, isWindows } from '~/universal/utils/common'
|
||||
import { getStaticPath } from '#/utils/staticPath'
|
||||
import picgo from '@core/picgo'
|
||||
import { getMainWindowState, saveMainWindowState } from './windowState'
|
||||
import { isDev } from '~/main/utils/env'
|
||||
// import { URLSearchParams } from 'url'
|
||||
|
||||
const windowList = new Map<IWindowList, IWindowListItem>()
|
||||
|
||||
const defaultWebPreferences = {
|
||||
// preload: path.join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
nodeIntegrationInWorker: true,
|
||||
backgroundThrottling: false
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegrationInWorker: false
|
||||
}
|
||||
|
||||
const handleWindowParams = (windowURL: string) => {
|
||||
@@ -62,8 +63,8 @@ windowList.set(IWindowList.TRAY_WINDOW, {
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
resizable: false,
|
||||
transparent: true,
|
||||
vibrancy: 'ultra-dark',
|
||||
transparent: false,
|
||||
backgroundColor: '#111827',
|
||||
webPreferences: {
|
||||
...defaultWebPreferences,
|
||||
webSecurity: false
|
||||
@@ -83,18 +84,21 @@ windowList.set(IWindowList.SETTING_WINDOW, {
|
||||
multiple: false,
|
||||
options () {
|
||||
const showDockIcon = picgo.getConfig<boolean>('settings.showDockIcon') !== false
|
||||
const mainWindowState = getMainWindowState()
|
||||
const options: IBrowserWindowOptions = {
|
||||
height: 450,
|
||||
width: 800,
|
||||
height: mainWindowState.height,
|
||||
width: mainWindowState.width,
|
||||
minHeight: 450,
|
||||
minWidth: 800,
|
||||
show: false,
|
||||
frame: true,
|
||||
frame: false,
|
||||
center: true,
|
||||
fullscreenable: false,
|
||||
resizable: false,
|
||||
resizable: true,
|
||||
title: 'PicGo',
|
||||
transparent: true,
|
||||
transparent: false,
|
||||
backgroundColor: '#0f172a',
|
||||
skipTaskbar: !showDockIcon,
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
...defaultWebPreferences,
|
||||
webSecurity: false
|
||||
@@ -110,6 +114,30 @@ windowList.set(IWindowList.SETTING_WINDOW, {
|
||||
},
|
||||
callback (window, windowManager) {
|
||||
window.loadURL(handleWindowParams(SETTING_WINDOW_URL))
|
||||
if (isDev) {
|
||||
window.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
window.on('maximize', () => {
|
||||
window.webContents.send(WINDOW_STATE_CHANGED, {
|
||||
isMaximized: true
|
||||
})
|
||||
})
|
||||
window.on('unmaximize', () => {
|
||||
window.webContents.send(WINDOW_STATE_CHANGED, {
|
||||
isMaximized: false
|
||||
})
|
||||
})
|
||||
window.on('close', () => {
|
||||
const nextBounds = window.isMaximized()
|
||||
? window.getNormalBounds()
|
||||
: window.getBounds()
|
||||
|
||||
saveMainWindowState({
|
||||
width: nextBounds.width,
|
||||
height: nextBounds.height,
|
||||
isMaximized: window.isMaximized()
|
||||
})
|
||||
})
|
||||
window.on('closed', () => {
|
||||
bus.emit(TOGGLE_SHORTKEY_MODIFIED_MODE, false)
|
||||
if (process.platform === 'linux') {
|
||||
@@ -118,6 +146,9 @@ windowList.set(IWindowList.SETTING_WINDOW, {
|
||||
})
|
||||
}
|
||||
})
|
||||
if (getMainWindowState().isMaximized) {
|
||||
window.maximize()
|
||||
}
|
||||
bus.emit(CREATE_APP_MENU)
|
||||
windowManager.create(IWindowList.MINI_WINDOW)
|
||||
}
|
||||
@@ -130,12 +161,12 @@ windowList.set(IWindowList.MINI_WINDOW, {
|
||||
const obj: IBrowserWindowOptions = {
|
||||
height: 64,
|
||||
width: 64,
|
||||
show: isLinux,
|
||||
show: false,
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
transparent: process.platform !== 'linux',
|
||||
transparent: true,
|
||||
icon: getStaticPath('logo.png'),
|
||||
webPreferences: {
|
||||
...defaultWebPreferences
|
||||
@@ -162,7 +193,7 @@ windowList.set(IWindowList.RENAME_WINDOW, {
|
||||
show: true,
|
||||
fullscreenable: false,
|
||||
resizable: false,
|
||||
backgroundColor: 'rgba(26,40,42,0.9)',
|
||||
backgroundColor: '#0f172a',
|
||||
webPreferences: {
|
||||
...defaultWebPreferences
|
||||
}
|
||||
@@ -197,14 +228,14 @@ windowList.set(IWindowList.TOOLBOX_WINDOW, {
|
||||
multiple: false,
|
||||
options () {
|
||||
const options: IBrowserWindowOptions = {
|
||||
height: 450,
|
||||
height: 480,
|
||||
width: 800,
|
||||
show: false,
|
||||
frame: true,
|
||||
center: true,
|
||||
fullscreenable: false,
|
||||
resizable: false,
|
||||
backgroundColor: 'rgba(26,40,42,0.9)',
|
||||
backgroundColor: '#0f172a',
|
||||
title: `PicGo-${T('TOOLBOX')}`,
|
||||
icon: getStaticPath('logo.png'),
|
||||
webPreferences: {
|
||||
@@ -223,13 +254,13 @@ windowList.set(IWindowList.TOOLBOX_WINDOW, {
|
||||
if (currentWindow && currentWindow.isVisible()) {
|
||||
// bounds: { x: 821, y: 75, width: 800, height: 450 }
|
||||
const bounds = currentWindow.getBounds()
|
||||
const positionX = bounds.x + bounds.width / 2 - 400
|
||||
const positionX = Math.round(bounds.x + bounds.width / 2 - 400)
|
||||
let positionY
|
||||
// if is the settingWindow
|
||||
if (bounds.height > 400) {
|
||||
positionY = bounds.y + bounds.height / 2 - 225
|
||||
positionY = Math.round(bounds.y + bounds.height / 2 - 240)
|
||||
} else { // if is the miniWindow
|
||||
positionY = bounds.y + bounds.height / 2
|
||||
positionY = Math.round(bounds.y + bounds.height / 2)
|
||||
}
|
||||
window.setPosition(positionX, positionY, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { STORE_PATH } from '~/main/utils/env'
|
||||
|
||||
interface IMainWindowState {
|
||||
width: number
|
||||
height: number
|
||||
isMaximized: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN_WINDOW_STATE: IMainWindowState = {
|
||||
width: 800,
|
||||
height: 450,
|
||||
isMaximized: false
|
||||
}
|
||||
|
||||
interface IWindowStateStorage {
|
||||
mainWindow: IMainWindowState
|
||||
}
|
||||
|
||||
const WINDOW_STATE_PATH = path.join(STORE_PATH, 'window-state.json')
|
||||
|
||||
function normalizeMainWindowState (value: unknown): IMainWindowState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return DEFAULT_MAIN_WINDOW_STATE
|
||||
}
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
isMaximized
|
||||
} = value as Partial<IMainWindowState>
|
||||
|
||||
return {
|
||||
width: typeof width === 'number' && width >= DEFAULT_MAIN_WINDOW_STATE.width
|
||||
? width
|
||||
: DEFAULT_MAIN_WINDOW_STATE.width,
|
||||
height: typeof height === 'number' && height >= DEFAULT_MAIN_WINDOW_STATE.height
|
||||
? height
|
||||
: DEFAULT_MAIN_WINDOW_STATE.height,
|
||||
isMaximized: isMaximized === true
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowStateStorage (value: unknown): IWindowStateStorage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
mainWindow: DEFAULT_MAIN_WINDOW_STATE
|
||||
}
|
||||
}
|
||||
|
||||
const mainWindow = 'mainWindow' in value
|
||||
? (value as { mainWindow?: unknown }).mainWindow
|
||||
: undefined
|
||||
|
||||
return {
|
||||
mainWindow: normalizeMainWindowState(mainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowStateStorage (): IWindowStateStorage {
|
||||
try {
|
||||
if (fs.existsSync(WINDOW_STATE_PATH)) {
|
||||
const raw = fs.readFileSync(WINDOW_STATE_PATH, 'utf8')
|
||||
return normalizeWindowStateStorage(JSON.parse(raw))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
return {
|
||||
mainWindow: DEFAULT_MAIN_WINDOW_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export function getMainWindowState (): IMainWindowState {
|
||||
return readWindowStateStorage().mainWindow
|
||||
}
|
||||
|
||||
export function saveMainWindowState (state: IMainWindowState): void {
|
||||
try {
|
||||
const currentState = readWindowStateStorage()
|
||||
|
||||
fs.writeJsonSync(WINDOW_STATE_PATH, {
|
||||
...currentState,
|
||||
mainWindow: normalizeMainWindowState(state)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function dbChecker () {
|
||||
if (process.type !== 'renderer') {
|
||||
// db save bak
|
||||
try {
|
||||
const { dbPath, dbBackupPath } = getGalleryDBPath()
|
||||
const { dbPath, dbBackupPath } = getAlbumDBPath()
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.copyFileSync(dbPath, dbBackupPath)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function dbPathDir () {
|
||||
return path.dirname(dbPathChecker())
|
||||
}
|
||||
|
||||
function getGalleryDBPath (): {
|
||||
function getAlbumDBPath (): {
|
||||
dbPath: string
|
||||
dbBackupPath: string
|
||||
} {
|
||||
@@ -144,6 +144,6 @@ export {
|
||||
dbChecker,
|
||||
dbPathChecker,
|
||||
dbPathDir,
|
||||
getGalleryDBPath,
|
||||
getAlbumDBPath,
|
||||
getFormImageFolderPath
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import { DBStore } from '@picgo/store'
|
||||
import { getGalleryDBPath } from './dbChecker'
|
||||
import { getAlbumDBPath } from './dbChecker'
|
||||
|
||||
const DB_PATH: string = getGalleryDBPath().dbPath
|
||||
const DB_PATH: string = getAlbumDBPath().dbPath
|
||||
fs.ensureDirSync(path.dirname(DB_PATH))
|
||||
|
||||
class GalleryDB {
|
||||
class AlbumDB {
|
||||
private static instance: DBStore
|
||||
private constructor () {}
|
||||
|
||||
public static getInstance (): DBStore {
|
||||
if (!GalleryDB.instance) {
|
||||
GalleryDB.instance = new DBStore(DB_PATH, 'gallery')
|
||||
if (!AlbumDB.instance) {
|
||||
// Keep the namespace 'gallery' for @picgo/store to preserve existing user data on disk.
|
||||
AlbumDB.instance = new DBStore(DB_PATH, 'gallery')
|
||||
}
|
||||
return GalleryDB.instance
|
||||
return AlbumDB.instance
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
GalleryDB
|
||||
AlbumDB
|
||||
}
|
||||
|
||||
+19
-12
@@ -4,8 +4,8 @@ import {
|
||||
ipcMain
|
||||
} from 'electron'
|
||||
import picgo from '@core/picgo'
|
||||
import { GalleryDB } from 'apis/core/datastore'
|
||||
import { dbPathChecker, defaultConfigPath, getGalleryDBPath } from 'apis/core/datastore/dbChecker'
|
||||
import { AlbumDB } from 'apis/core/datastore'
|
||||
import { dbPathChecker, defaultConfigPath, getAlbumDBPath } from 'apis/core/datastore/dbChecker'
|
||||
import uploader from 'apis/app/uploader'
|
||||
import pasteTemplate from '~/main/utils/pasteTemplate'
|
||||
import { handleCopyUrl, showNotification as showMainNotification } from '~/main/utils/common'
|
||||
@@ -88,11 +88,11 @@ class GuiApi implements IGuiApi {
|
||||
// icon: imgs[i].imgUrl
|
||||
})
|
||||
}, i * 100)
|
||||
await GalleryDB.getInstance().insert(imgs[i])
|
||||
await AlbumDB.getInstance().insert(imgs[i])
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
webContents?.send('uploadFiles', imgs)
|
||||
webContents?.send(IRPCActionType.UPDATE_GALLERY)
|
||||
webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
return imgs
|
||||
}
|
||||
return []
|
||||
@@ -144,25 +144,27 @@ class GuiApi implements IGuiApi {
|
||||
*/
|
||||
async getConfigPath () {
|
||||
const currentConfigPath = dbPathChecker()
|
||||
const galleryDBPath = getGalleryDBPath().dbPath
|
||||
const albumDBPath = getAlbumDBPath().dbPath
|
||||
return {
|
||||
defaultConfigPath,
|
||||
currentConfigPath,
|
||||
galleryDBPath
|
||||
albumDBPath,
|
||||
/** @deprecated Use `albumDBPath` instead. */
|
||||
galleryDBPath: albumDBPath
|
||||
}
|
||||
}
|
||||
|
||||
get galleryDB (): DBStore {
|
||||
return new Proxy<DBStore>(GalleryDB.getInstance(), {
|
||||
get albumDB (): DBStore {
|
||||
return new Proxy<DBStore>(AlbumDB.getInstance(), {
|
||||
get (target, prop: keyof DBStore) {
|
||||
if (prop === 'overwrite') {
|
||||
return new Proxy(GalleryDB.getInstance().overwrite, {
|
||||
return new Proxy(AlbumDB.getInstance().overwrite, {
|
||||
apply (target, ctx, args) {
|
||||
return new Promise((resolve) => {
|
||||
const guiApi = GuiApi.getInstance()
|
||||
guiApi.showMessageBox({
|
||||
title: T('TIPS_WARNING'),
|
||||
message: T('TIPS_PLUGIN_REMOVE_GALLERY_ITEM'),
|
||||
message: T('TIPS_PLUGIN_REMOVE_ALBUM_ITEM'),
|
||||
type: 'info',
|
||||
buttons: ['Yes', 'No']
|
||||
}).then(res => {
|
||||
@@ -177,13 +179,13 @@ class GuiApi implements IGuiApi {
|
||||
})
|
||||
}
|
||||
if (prop === 'removeById') {
|
||||
return new Proxy(GalleryDB.getInstance().removeById, {
|
||||
return new Proxy(AlbumDB.getInstance().removeById, {
|
||||
apply (target, ctx, args) {
|
||||
return new Promise((resolve) => {
|
||||
const guiApi = GuiApi.getInstance()
|
||||
guiApi.showMessageBox({
|
||||
title: T('TIPS_WARNING'),
|
||||
message: T('TIPS_PLUGIN_REMOVE_GALLERY_ITEM'),
|
||||
message: T('TIPS_PLUGIN_REMOVE_ALBUM_ITEM'),
|
||||
type: 'info',
|
||||
buttons: ['Yes', 'No']
|
||||
}).then(res => {
|
||||
@@ -201,6 +203,11 @@ class GuiApi implements IGuiApi {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated Use `albumDB` instead. */
|
||||
get galleryDB (): DBStore {
|
||||
return this.albumDB
|
||||
}
|
||||
}
|
||||
|
||||
export default GuiApi
|
||||
|
||||
@@ -11,7 +11,7 @@ import uploader from 'apis/app/uploader'
|
||||
import pasteTemplate from '~/main/utils/pasteTemplate'
|
||||
import picgo from '@core/picgo'
|
||||
import logger from '@core/picgo/logger'
|
||||
import { GalleryDB } from '~/main/apis/core/datastore'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import server from '~/main/server'
|
||||
import getPicBeds from '~/main/utils/getPicBeds'
|
||||
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
|
||||
@@ -21,9 +21,12 @@ import {
|
||||
OPEN_DEVTOOLS,
|
||||
SHOW_MINI_PAGE_MENU,
|
||||
MINIMIZE_WINDOW,
|
||||
MAXIMIZE_WINDOW,
|
||||
CLOSE_WINDOW,
|
||||
WINDOW_STATE_CHANGED,
|
||||
SHOW_MAIN_PAGE_MENU,
|
||||
SHOW_UPLOAD_PAGE_MENU,
|
||||
SHOW_PRIVACY_MESSAGE,
|
||||
OPEN_USER_STORE_FILE,
|
||||
OPEN_URL,
|
||||
SHOW_PLUGIN_PAGE_MENU,
|
||||
@@ -41,6 +44,7 @@ import { buildMainPageMenu, buildMiniPageMenu, buildPluginPageMenu, buildPicBedL
|
||||
import path from 'path'
|
||||
import { T } from '~/main/i18n'
|
||||
import { STORE_PATH } from '~/main/utils/env'
|
||||
import { privacyManager } from '~/main/utils/privacyManager'
|
||||
|
||||
export default {
|
||||
listen () {
|
||||
@@ -59,10 +63,10 @@ export default {
|
||||
// icon: file[0]
|
||||
// icon: img[0].imgUrl
|
||||
})
|
||||
await GalleryDB.getInstance().insert(img[0])
|
||||
await AlbumDB.getInstance().insert(img[0])
|
||||
trayWindow.webContents.send('clipboardFiles', [])
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(IRPCActionType.UPDATE_GALLERY)
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
}
|
||||
trayWindow.webContents.send('uploadFiles')
|
||||
@@ -181,6 +185,9 @@ export default {
|
||||
window
|
||||
})
|
||||
})
|
||||
ipcMain.on(SHOW_PRIVACY_MESSAGE, () => {
|
||||
privacyManager.show(false)
|
||||
})
|
||||
ipcMain.on(SHOW_UPLOAD_PAGE_MENU, () => {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const menu = buildPicBedListMenu()
|
||||
@@ -199,6 +206,18 @@ export default {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
window?.minimize()
|
||||
})
|
||||
ipcMain.on(MAXIMIZE_WINDOW, () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
if (!window) return
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize()
|
||||
} else {
|
||||
window.maximize()
|
||||
}
|
||||
window.webContents.send(WINDOW_STATE_CHANGED, {
|
||||
isMaximized: window.isMaximized()
|
||||
})
|
||||
})
|
||||
ipcMain.on(CLOSE_WINDOW, () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
if (process.platform === 'linux') {
|
||||
|
||||
+67
-157
@@ -1,21 +1,20 @@
|
||||
import path from 'path'
|
||||
import GuiApi from 'apis/gui'
|
||||
import {
|
||||
dialog,
|
||||
shell,
|
||||
IpcMainEvent,
|
||||
ipcMain,
|
||||
clipboard
|
||||
} from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import { IPasteStyle, IPicGoHelperType, IWindowList } from '#/types/enum'
|
||||
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
|
||||
import { IPasteStyle, IPicGoHelperType, IRPCActionType, IWindowList } from '#/types/enum'
|
||||
import picgo from '@core/picgo'
|
||||
import { handleStreamlinePluginName, simpleClone } from '~/universal/utils/common'
|
||||
import { IGuiMenuItem, PicGo as PicGoCore } from 'picgo'
|
||||
import { IBuildInEvent, IGuiMenuItem, PicGo as PicGoCore, evaluatePluginConfig } from 'picgo'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { showNotification } from '~/main/utils/common'
|
||||
import { dbPathChecker } from 'apis/core/datastore/dbChecker'
|
||||
import logger from 'apis/core/picgo/logger'
|
||||
import {
|
||||
PICGO_SAVE_CONFIG,
|
||||
PICGO_GET_CONFIG,
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
GET_PICBED_CONFIG
|
||||
} from '#/events/constants'
|
||||
|
||||
import { GalleryDB } from 'apis/core/datastore'
|
||||
import { AlbumDB } from 'apis/core/datastore'
|
||||
import { IObject, IFilter } from '@picgo/store/dist/types'
|
||||
import pasteTemplate from '../utils/pasteTemplate'
|
||||
import { i18nManager, T } from '~/main/i18n'
|
||||
@@ -43,11 +42,6 @@ import { rpcServer } from './rpc'
|
||||
|
||||
const STORE_PATH = path.dirname(dbPathChecker())
|
||||
|
||||
interface GuiMenuItem {
|
||||
label: string
|
||||
handle: (arg0: PicGoCore, arg1: GuiApi) => Promise<void>
|
||||
}
|
||||
|
||||
// get uploader or transformer config
|
||||
const getConfig = (name: string, type: IPicGoHelperType, ctx: PicGoCore) => {
|
||||
let config: any[] = []
|
||||
@@ -65,15 +59,12 @@ const getConfig = (name: string, type: IPicGoHelperType, ctx: PicGoCore) => {
|
||||
}
|
||||
|
||||
const handleConfigWithFunction = (config: any[]) => {
|
||||
for (const i in config) {
|
||||
if (typeof config[i].default === 'function') {
|
||||
config[i].default = config[i].default()
|
||||
return evaluatePluginConfig(config, {}, {
|
||||
onError: (fieldName, kind, error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
picgo.log.warn(`[plugin-config] ${fieldName}.${kind} threw: ${message}`)
|
||||
}
|
||||
if (typeof config[i].choices === 'function') {
|
||||
config[i].choices = config[i].choices()
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
const getPluginList = (): IPicGoPlugin[] => {
|
||||
@@ -148,79 +139,6 @@ const handleGetPluginList = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handlePluginInstall = () => {
|
||||
ipcMain.on('installPlugin', async (event: IpcMainEvent, fullName: string) => {
|
||||
const dispose = handleNPMError()
|
||||
const res = await picgo.pluginHandler.install([fullName])
|
||||
event.sender.send('installPlugin', {
|
||||
success: res.success,
|
||||
body: fullName,
|
||||
errMsg: res.success ? '' : res.body
|
||||
})
|
||||
if (res.success) {
|
||||
shortKeyHandler.registerPluginShortKey(res.body[0])
|
||||
} else {
|
||||
showNotification({
|
||||
title: T('PLUGIN_INSTALL_FAILED'),
|
||||
body: res.body as string
|
||||
})
|
||||
}
|
||||
event.sender.send('hideLoading')
|
||||
dispose()
|
||||
})
|
||||
}
|
||||
|
||||
const handlePluginUninstall = async (fullName: string) => {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const dispose = handleNPMError()
|
||||
const res = await picgo.pluginHandler.uninstall([fullName])
|
||||
if (res.success) {
|
||||
window.webContents.send('uninstallSuccess', res.body[0])
|
||||
shortKeyHandler.unregisterPluginShortKey(res.body[0])
|
||||
} else {
|
||||
showNotification({
|
||||
title: T('PLUGIN_UNINSTALL_FAILED'),
|
||||
body: res.body as string
|
||||
})
|
||||
}
|
||||
window.webContents.send('hideLoading')
|
||||
dispose()
|
||||
}
|
||||
|
||||
const handlePluginUpdate = async (fullName: string) => {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const dispose = handleNPMError()
|
||||
const res = await picgo.pluginHandler.update([fullName])
|
||||
if (res.success) {
|
||||
window.webContents.send('updateSuccess', res.body[0])
|
||||
} else {
|
||||
showNotification({
|
||||
title: T('PLUGIN_UPDATE_FAILED'),
|
||||
body: res.body as string
|
||||
})
|
||||
}
|
||||
window.webContents.send('hideLoading')
|
||||
dispose()
|
||||
}
|
||||
|
||||
const handleNPMError = (): IDispose => {
|
||||
const handler = (msg: string) => {
|
||||
if (msg === 'NPM is not installed') {
|
||||
dialog.showMessageBox({
|
||||
title: T('TIPS_ERROR'),
|
||||
message: T('TIPS_INSTALL_NODE_AND_RELOAD_PICGO'),
|
||||
buttons: ['Yes']
|
||||
}).then((res) => {
|
||||
if (res.response === 0) {
|
||||
shell.openExternal('https://nodejs.org/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
picgo.once('failed', handler)
|
||||
return () => picgo.off('failed', handler)
|
||||
}
|
||||
|
||||
const handleGetPicBedConfig = () => {
|
||||
ipcMain.on(GET_PICBED_CONFIG, (event: IpcMainEvent, type: string) => {
|
||||
const name = picgo.helper.uploader.get(type)?.name || type
|
||||
@@ -234,21 +152,6 @@ const handleGetPicBedConfig = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: remove it
|
||||
const handlePluginActions = () => {
|
||||
ipcMain.on('pluginActions', (event: IpcMainEvent, name: string, label: string) => {
|
||||
const plugin = picgo.pluginLoader.getPlugin(name)
|
||||
if (plugin?.guiMenu?.(picgo)?.length) {
|
||||
const menu: GuiMenuItem[] = plugin.guiMenu(picgo)
|
||||
menu.forEach(item => {
|
||||
if (item.label === label) {
|
||||
item.handle(picgo, GuiApi.getInstance())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveFiles = () => {
|
||||
ipcMain.on('removeFiles', (event: IpcMainEvent, files: ImgInfo[]) => {
|
||||
setTimeout(() => {
|
||||
@@ -272,74 +175,39 @@ const handlePicGoGetConfig = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleImportLocalPlugin = () => {
|
||||
ipcMain.on('importLocalPlugin', async (event: IpcMainEvent) => {
|
||||
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const res = await dialog.showOpenDialog(settingWindow, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
const filePaths = res.filePaths
|
||||
if (filePaths.length > 0) {
|
||||
const res = await picgo.pluginHandler.install(filePaths)
|
||||
if (res.success) {
|
||||
try {
|
||||
const list = simpleClone(getPluginList())
|
||||
event.sender.send('pluginList', list)
|
||||
} catch (e: any) {
|
||||
event.sender.send('pluginList', [])
|
||||
showNotification({
|
||||
title: T('TIPS_GET_PLUGIN_LIST_FAILED'),
|
||||
body: e.message
|
||||
})
|
||||
}
|
||||
showNotification({
|
||||
title: T('PLUGIN_IMPORT_SUCCEED'),
|
||||
body: ''
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
title: T('PLUGIN_IMPORT_FAILED'),
|
||||
body: res.body as string
|
||||
})
|
||||
}
|
||||
}
|
||||
event.sender.send('hideLoading')
|
||||
})
|
||||
}
|
||||
|
||||
const handlePicGoGalleryDB = () => {
|
||||
const handlePicGoAlbumDB = () => {
|
||||
ipcMain.on(PICGO_GET_DB, async (event: IpcMainEvent, filter: IFilter, callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.get(filter)
|
||||
event.sender.send(PICGO_GET_DB, res, callbackId)
|
||||
})
|
||||
|
||||
ipcMain.on(PICGO_INSERT_DB, async (event: IpcMainEvent, value: IObject, callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.insert(value)
|
||||
event.sender.send(PICGO_INSERT_DB, res, callbackId)
|
||||
})
|
||||
|
||||
ipcMain.on(PICGO_INSERT_MANY_DB, async (event: IpcMainEvent, value: IObject[], callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.insertMany(value)
|
||||
event.sender.send(PICGO_INSERT_MANY_DB, res, callbackId)
|
||||
})
|
||||
|
||||
ipcMain.on(PICGO_UPDATE_BY_ID_DB, async (event: IpcMainEvent, id: string, value: IObject[], callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.updateById(id, value)
|
||||
event.sender.send(PICGO_UPDATE_BY_ID_DB, res, callbackId)
|
||||
})
|
||||
|
||||
ipcMain.on(PICGO_GET_BY_ID_DB, async (event: IpcMainEvent, id: string, callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.getById(id)
|
||||
event.sender.send(PICGO_GET_BY_ID_DB, res, callbackId)
|
||||
})
|
||||
|
||||
ipcMain.on(PICGO_REMOVE_BY_ID_DB, async (event: IpcMainEvent, id: string, callbackId: string) => {
|
||||
const dbStore = GalleryDB.getInstance()
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const res = await dbStore.removeById(id)
|
||||
event.sender.send(PICGO_REMOVE_BY_ID_DB, res, callbackId)
|
||||
})
|
||||
@@ -389,7 +257,7 @@ const handleI18n = () => {
|
||||
const miniWindow = windowManager.get(IWindowList.MINI_WINDOW)
|
||||
miniWindow?.webContents.send(SET_CURRENT_LANGUAGE, lang, locales)
|
||||
}
|
||||
// event.sender.send(SET_CURRENT_LANGUAGE, lang, locales)
|
||||
notifyAppConfigUpdated()
|
||||
})
|
||||
ipcMain.on(GET_CURRENT_LANGUAGE, (event: IpcMainEvent) => {
|
||||
const { lang, locales } = i18nManager.getCurrentLocales()
|
||||
@@ -397,6 +265,53 @@ const handleI18n = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const PICGO_CLOUD_TYPE = 'picgo-cloud'
|
||||
|
||||
const markImportedItemsInAlbumDB = async (items: Array<{ id?: string, type?: string }>): Promise<void> => {
|
||||
const dbStore = AlbumDB.getInstance()
|
||||
const itemsToMark = items.filter(
|
||||
(item) => typeof item.id === 'string' && item.id.trim() !== '' && item.type !== PICGO_CLOUD_TYPE
|
||||
)
|
||||
|
||||
for (const item of itemsToMark) {
|
||||
try {
|
||||
await dbStore.updateById(item.id!, { _importToPicGoCloud: true })
|
||||
} catch {
|
||||
// Silently skip items that don't exist in local DB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloudAlbumEvents = () => {
|
||||
picgo.on(IBuildInEvent.CLOUD_ALBUM_UPDATED, (payload: { items?: Array<{ id?: string, type?: string }> } | undefined) => {
|
||||
// Broadcast to both settings and tray windows
|
||||
for (const windowType of [IWindowList.SETTING_WINDOW, IWindowList.TRAY_WINDOW]) {
|
||||
if (windowManager.has(windowType)) {
|
||||
windowManager.get(windowType)!.webContents.send(
|
||||
IRPCActionType.UPDATE_CLOUD_ALBUM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Write back _importToPicGoCloud flag to local gallery DB for non-picgo-cloud items
|
||||
const items = payload?.items
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
markImportedItemsInAlbumDB(items).catch((error) => {
|
||||
logger.error('[PicGo Cloud][album] failed to mark imported items in album DB', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
picgo.on(IBuildInEvent.CLOUD_IMPORT_PROGRESS, (progress: unknown) => {
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(
|
||||
'CLOUD_IMPORT_PROGRESS',
|
||||
progress
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRPCActions = () => {
|
||||
rpcServer.start()
|
||||
}
|
||||
@@ -404,20 +319,15 @@ const handleRPCActions = () => {
|
||||
export default {
|
||||
listen () {
|
||||
handleGetPluginList()
|
||||
handlePluginInstall()
|
||||
handleGetPicBedConfig()
|
||||
handlePluginActions()
|
||||
handleRemoveFiles()
|
||||
handlePicGoSaveConfig()
|
||||
handlePicGoGetConfig()
|
||||
handlePicGoGalleryDB()
|
||||
handleImportLocalPlugin()
|
||||
handlePicGoAlbumDB()
|
||||
handleOpenFile()
|
||||
handleOpenWindow()
|
||||
handleI18n()
|
||||
handleCloudAlbumEvents()
|
||||
handleRPCActions()
|
||||
},
|
||||
// TODO: separate to single file
|
||||
handlePluginUninstall,
|
||||
handlePluginUpdate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { IWindowList } from '#/types/enum'
|
||||
import { IPluginMenuAction, IWindowList } from '#/types/enum'
|
||||
import { Menu, BrowserWindow, app, dialog } from 'electron'
|
||||
import picgo from '@core/picgo'
|
||||
import {
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
import { privacyManager } from '~/main/utils/privacyManager'
|
||||
import pkg from 'root/package.json'
|
||||
import GuiApi from 'apis/gui'
|
||||
import { PICGO_CONFIG_PLUGIN, PICGO_HANDLE_PLUGIN_DONE, PICGO_HANDLE_PLUGIN_ING, PICGO_TOGGLE_PLUGIN, SHOW_MAIN_PAGE_DONATION, SHOW_MAIN_PAGE_QRCODE } from '~/universal/events/constants'
|
||||
import picgoCoreIPC from '~/main/events/picgoCoreIPC'
|
||||
import { PICGO_CONFIG_PLUGIN, PICGO_PLUGIN_MENU_ACTION, SHOW_MAIN_PAGE_DONATION, SHOW_MAIN_PAGE_QRCODE } from '~/universal/events/constants'
|
||||
import { PicGo as PicGoCore } from 'picgo'
|
||||
import { T } from '~/main/i18n'
|
||||
import { buildPicBedListMenu } from './picBedListMenu'
|
||||
@@ -122,71 +121,38 @@ const buildMainPageMenu = (win: BrowserWindow) => {
|
||||
|
||||
// TODO: separate to single file
|
||||
|
||||
const handleRestoreState = (item: string, name: string): void => {
|
||||
if (item === 'uploader') {
|
||||
const current = picgo.getConfig('picBed.current')
|
||||
if (current === name) {
|
||||
picgo.saveConfig({
|
||||
'picBed.current': 'smms',
|
||||
'picBed.uploader': 'smms'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (item === 'transformer') {
|
||||
const current = picgo.getConfig('picBed.transformer')
|
||||
if (current === name) {
|
||||
picgo.saveConfig({
|
||||
'picBed.transformer': 'path'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
|
||||
const menu = [{
|
||||
label: T('ENABLE_PLUGIN'),
|
||||
enabled: !plugin.enabled,
|
||||
click () {
|
||||
picgo.saveConfig({
|
||||
[`picgoPlugins.${plugin.fullName}`]: true
|
||||
})
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
window.webContents.send(PICGO_TOGGLE_PLUGIN, plugin.fullName, true)
|
||||
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.ENABLE)
|
||||
}
|
||||
}, {
|
||||
label: T('DISABLE_PLUGIN'),
|
||||
enabled: plugin.enabled,
|
||||
click () {
|
||||
picgo.saveConfig({
|
||||
[`picgoPlugins.${plugin.fullName}`]: false
|
||||
})
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
|
||||
window.webContents.send(PICGO_TOGGLE_PLUGIN, plugin.fullName, false)
|
||||
window.webContents.send(PICGO_HANDLE_PLUGIN_DONE, plugin.fullName)
|
||||
if (plugin.config.transformer.name) {
|
||||
handleRestoreState('transformer', plugin.config.transformer.name)
|
||||
}
|
||||
if (plugin.config.uploader.name) {
|
||||
handleRestoreState('uploader', plugin.config.uploader.name)
|
||||
}
|
||||
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.DISABLE)
|
||||
}
|
||||
}, {
|
||||
label: T('UNINSTALL_PLUGIN'),
|
||||
click () {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
|
||||
picgoCoreIPC.handlePluginUninstall(plugin.fullName)
|
||||
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.UNINSTALL)
|
||||
}
|
||||
}, {
|
||||
label: T('UPDATE_PLUGIN'),
|
||||
click () {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
|
||||
picgoCoreIPC.handlePluginUpdate(plugin.fullName)
|
||||
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.UPDATE)
|
||||
}
|
||||
}]
|
||||
for (const i in plugin.config) {
|
||||
if (i === 'uploader') {
|
||||
continue
|
||||
}
|
||||
if (plugin.config[i].config.length > 0) {
|
||||
const obj = {
|
||||
label: T('CONFIG_THING', {
|
||||
@@ -209,7 +175,7 @@ const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
|
||||
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
|
||||
const pluginTransformer = plugin.config.transformer.name
|
||||
const obj = {
|
||||
label: `${currentTransformer === pluginTransformer ? T('DISABLE') : T('ENABLE')}transformer - ${plugin.config.transformer.name}`,
|
||||
label: `${currentTransformer === pluginTransformer ? T('DISABLE') : T('ENABLE')} transformer - ${plugin.config.transformer.name}`,
|
||||
click () {
|
||||
const transformer = plugin.config.transformer.name
|
||||
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
|
||||
@@ -237,10 +203,9 @@ const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
|
||||
menu.push({
|
||||
label: i.label,
|
||||
click () {
|
||||
// ipcRenderer.send('pluginActions', plugin.fullName, i.label)
|
||||
const picgPlugin = picgo.pluginLoader.getPlugin(plugin.fullName)
|
||||
if (picgPlugin?.guiMenu?.(picgo)?.length) {
|
||||
const menu: GuiMenuItem[] = picgPlugin.guiMenu(picgo)
|
||||
const picgoPlugin = picgo.pluginLoader.getPlugin(plugin.fullName)
|
||||
if (picgoPlugin?.guiMenu?.(picgo)?.length) {
|
||||
const menu: GuiMenuItem[] = picgoPlugin.guiMenu(picgo)
|
||||
menu.forEach(item => {
|
||||
if (item.label === i.label) {
|
||||
item.handle(picgo, GuiApi.getInstance())
|
||||
|
||||
@@ -16,7 +16,7 @@ export const buildPicBedListMenu = () => {
|
||||
}, {
|
||||
type: 'separator'
|
||||
}]
|
||||
let submenu = picBeds.filter(item => item.visible).map(item => {
|
||||
let submenu = picBeds.filter(item => item.visible !== false).map(item => {
|
||||
const configList = picBedConfigList?.[item.type]?.configList
|
||||
const defaultId = picBedConfigList?.[item.type]?.defaultId
|
||||
const hasSubmenu = !!configList
|
||||
|
||||
@@ -5,8 +5,9 @@ import { configRouter } from './routes/config'
|
||||
import { versionRouter } from './routes/version'
|
||||
import { toolboxRouter } from './routes/toolbox'
|
||||
import { systemRouter } from './routes/system'
|
||||
import { galleryToolboxRouter } from './routes/galleryToolbox'
|
||||
import { albumToolboxRouter } from './routes/albumToolbox'
|
||||
import { cloudRouter } from './routes/cloud'
|
||||
import { pluginsRouter } from './routes/plugins'
|
||||
import { fail, isIRPCResult, ok } from './utils'
|
||||
|
||||
class RPCServer implements IRPCServer {
|
||||
@@ -72,8 +73,9 @@ rpcServer.use(configRouter.routes())
|
||||
rpcServer.use(versionRouter.routes())
|
||||
rpcServer.use(toolboxRouter.routes())
|
||||
rpcServer.use(systemRouter.routes())
|
||||
rpcServer.use(galleryToolboxRouter.routes())
|
||||
rpcServer.use(albumToolboxRouter.routes())
|
||||
rpcServer.use(cloudRouter.routes())
|
||||
rpcServer.use(pluginsRouter.routes())
|
||||
|
||||
export {
|
||||
rpcServer
|
||||
|
||||
+22
-22
@@ -38,7 +38,7 @@ function buildFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>): stri
|
||||
|
||||
function validateRuleOrThrow (rule: IUrlRewriteRule) {
|
||||
if (!rule.match.trim() || !rule.replace.trim()) {
|
||||
throw new Error(T('GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED'))
|
||||
throw new Error(T('ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED'))
|
||||
}
|
||||
try {
|
||||
new RegExp(rule.match, buildFlags(rule))
|
||||
@@ -57,23 +57,23 @@ function applyFirstMatchRewrite (ctx: IPicGo, imgItem: ImgInfo, rules: IUrlRewri
|
||||
PicGoUtils.applyUrlRewriteToImgInfo(imgInfo, rules, {
|
||||
log: {
|
||||
error: (...args: Parameters<IPicGo['log']['error']>) => ctx.log.error(...args),
|
||||
warn: () => ctx.log.warn(T('GALLERY_URL_REWRITE_EMPTY_RESULT_WARN'))
|
||||
warn: () => ctx.log.warn(T('ALBUM_URL_REWRITE_EMPTY_RESULT_WARN'))
|
||||
}
|
||||
})
|
||||
if (imgInfo.imgUrl === '') return imgItem
|
||||
return imgInfo
|
||||
}
|
||||
|
||||
export const galleryMenu = () => {
|
||||
export const albumMenu = () => {
|
||||
return [{
|
||||
label: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
label: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
|
||||
if (!selectedList.length) {
|
||||
guiApi.showNotification({
|
||||
title: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
body: T('GALLERY_URL_REWRITE_WARN_NO_SELECTION')
|
||||
title: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
body: T('ALBUM_URL_REWRITE_WARN_NO_SELECTION')
|
||||
})
|
||||
logger.warn(T('GALLERY_URL_REWRITE_WARN_NO_SELECTION'))
|
||||
logger.warn(T('ALBUM_URL_REWRITE_WARN_NO_SELECTION'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,14 +81,14 @@ export const galleryMenu = () => {
|
||||
|
||||
const config: IPicGoPluginConfig[] = [
|
||||
{
|
||||
alias: T('GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES'),
|
||||
alias: T('ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES'),
|
||||
name: 'applyGlobalRules',
|
||||
type: 'confirm',
|
||||
default: globalRules.length > 0,
|
||||
required: false,
|
||||
confirmText: T('SETTINGS_OPEN'),
|
||||
cancelText: T('SETTINGS_CLOSE'),
|
||||
tips: `${T('GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
|
||||
tips: `${T('ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
|
||||
},
|
||||
{
|
||||
alias: T('URL_REWRITE_MATCH'),
|
||||
@@ -97,7 +97,7 @@ export const galleryMenu = () => {
|
||||
message: T('URL_REWRITE_MATCH_PLACEHOLDER'),
|
||||
default: '',
|
||||
required: false,
|
||||
tips: `${T('GALLERY_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
|
||||
tips: `${T('ALBUM_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
|
||||
},
|
||||
{
|
||||
alias: T('URL_REWRITE_REPLACE'),
|
||||
@@ -130,7 +130,7 @@ export const galleryMenu = () => {
|
||||
}
|
||||
]
|
||||
const options: IPicGoPluginShowConfigDialogOption = {
|
||||
title: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
title: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
config
|
||||
}
|
||||
const res = await guiApi.showConfigDialog<IUrlRewriteDialogResult>(options)
|
||||
@@ -157,7 +157,7 @@ export const galleryMenu = () => {
|
||||
validateRuleOrThrow(tempRule)
|
||||
} catch (e: any) {
|
||||
guiApi.showNotification({
|
||||
title: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
title: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
body: e.message
|
||||
})
|
||||
return
|
||||
@@ -166,8 +166,8 @@ export const galleryMenu = () => {
|
||||
|
||||
if (!applyGlobalRules && !tempRule) {
|
||||
guiApi.showNotification({
|
||||
title: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
body: T('GALLERY_URL_REWRITE_NO_RULES_TO_APPLY')
|
||||
title: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
body: T('ALBUM_URL_REWRITE_NO_RULES_TO_APPLY')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -175,12 +175,12 @@ export const galleryMenu = () => {
|
||||
let shouldSaveTempRule = false
|
||||
if (tempRule) {
|
||||
const saveRes = await guiApi.showMessageBox({
|
||||
title: T('GALLERY_URL_REWRITE_TITLE'),
|
||||
message: T('GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
|
||||
title: T('ALBUM_URL_REWRITE_TITLE'),
|
||||
message: T('ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
|
||||
type: 'info',
|
||||
buttons: [
|
||||
T('GALLERY_URL_REWRITE_APPLY_AND_SAVE'),
|
||||
T('GALLERY_URL_REWRITE_APPLY_ONLY'),
|
||||
T('ALBUM_URL_REWRITE_APPLY_AND_SAVE'),
|
||||
T('ALBUM_URL_REWRITE_APPLY_ONLY'),
|
||||
T('CANCEL')
|
||||
]
|
||||
})
|
||||
@@ -207,8 +207,8 @@ export const galleryMenu = () => {
|
||||
|
||||
if (changedList.length === 0) {
|
||||
guiApi.showNotification({
|
||||
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
|
||||
body: T('GALLERY_URL_REWRITE_NO_CHANGES')
|
||||
title: T('ALBUM_URL_REWRITE_RESULT_TITLE'),
|
||||
body: T('ALBUM_URL_REWRITE_NO_CHANGES')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -220,9 +220,9 @@ export const galleryMenu = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateRes = await guiApi.galleryDB.updateMany(changedList)
|
||||
const updateRes = await guiApi.albumDB.updateMany(changedList)
|
||||
guiApi.showNotification({
|
||||
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
|
||||
title: T('ALBUM_URL_REWRITE_RESULT_TITLE'),
|
||||
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { albumMenu as changeURLAlbumMenu } from './changeURL'
|
||||
export const builtInAlbumToolboxMenu = () => {
|
||||
const menuList = [...changeURLAlbumMenu()]
|
||||
|
||||
return menuList
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
|
||||
import { AlbumSource } from '#/types/cloudAlbum'
|
||||
import { RPCRouter } from '../../router'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { albumMenuListManager } from './menuListManager'
|
||||
import logger from 'apis/core/picgo/logger'
|
||||
|
||||
const albumToolboxRouter = new RPCRouter()
|
||||
|
||||
albumToolboxRouter.add(IRPCActionType.GET_ALBUM_MENU_LIST, async (args) => {
|
||||
const [selectedList] = args as IGetAlbumMenuListArgs
|
||||
const win = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const menu = albumMenuListManager.getMenu(selectedList)
|
||||
|
||||
menu.popup({
|
||||
window: win
|
||||
})
|
||||
})
|
||||
|
||||
/** Broadcast album source changes to all renderer windows except the sender. */
|
||||
const SYNC_TARGET_WINDOWS: IWindowList[] = [
|
||||
IWindowList.SETTING_WINDOW,
|
||||
IWindowList.TRAY_WINDOW,
|
||||
IWindowList.MINI_WINDOW
|
||||
]
|
||||
|
||||
albumToolboxRouter.add(IRPCActionType.SYNC_ALBUM_SOURCE, async (args, event) => {
|
||||
const [source] = args as [AlbumSource]
|
||||
logger.debug('[Album][syncAlbumSource]', `source=${source}`)
|
||||
const senderWebContents = event.sender
|
||||
for (const windowType of SYNC_TARGET_WINDOWS) {
|
||||
if (!windowManager.has(windowType)) continue
|
||||
const win = windowManager.get(windowType)
|
||||
if (win?.webContents && win.webContents !== senderWebContents) {
|
||||
win.webContents.send(IRPCActionType.SYNC_ALBUM_SOURCE, source)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export {
|
||||
albumToolboxRouter
|
||||
}
|
||||
+9
-9
@@ -1,17 +1,17 @@
|
||||
import { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { builtInGalleryToolboxMenu } from './builtIn'
|
||||
import { builtInAlbumToolboxMenu } from './builtIn'
|
||||
import picgo from '@core/picgo'
|
||||
import GuiApi from 'apis/gui'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
|
||||
import logger from '~/main/apis/core/picgo/logger'
|
||||
|
||||
class GalleryMenuListManager {
|
||||
class AlbumMenuListManager {
|
||||
private menuList: MenuItemConstructorOptions[] = []
|
||||
private menu: Menu | null = null
|
||||
|
||||
private getBuiltInMenuList (selectedList: IGalleryItem[]): MenuItemConstructorOptions[] {
|
||||
const builtInMenu = builtInGalleryToolboxMenu().map(item => {
|
||||
private getBuiltInMenuList (selectedList: IAlbumItem[]): MenuItemConstructorOptions[] {
|
||||
const builtInMenu = builtInAlbumToolboxMenu().map(item => {
|
||||
return {
|
||||
label: item.label,
|
||||
async click () {
|
||||
@@ -20,7 +20,7 @@ class GalleryMenuListManager {
|
||||
} catch (e: any) {
|
||||
logger.error(e)
|
||||
} finally {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)?.webContents.send(IRPCActionType.UPDATE_GALLERY)
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)?.webContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,19 +30,19 @@ class GalleryMenuListManager {
|
||||
return this.menuList
|
||||
}
|
||||
|
||||
private getMenuItemList (selectedList: IGalleryItem[]) {
|
||||
private getMenuItemList (selectedList: IAlbumItem[]) {
|
||||
// current only support built-in menu
|
||||
return this.getBuiltInMenuList(selectedList)
|
||||
}
|
||||
|
||||
public getMenu (selectedList: IGalleryItem[]): Menu {
|
||||
public getMenu (selectedList: IAlbumItem[]): Menu {
|
||||
this.menu = Menu.buildFromTemplate(this.getMenuItemList(selectedList))
|
||||
return this.menu
|
||||
}
|
||||
}
|
||||
|
||||
const galleryMenuListManager = new GalleryMenuListManager()
|
||||
const albumMenuListManager = new AlbumMenuListManager()
|
||||
|
||||
export {
|
||||
galleryMenuListManager
|
||||
albumMenuListManager
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
import { RPCRouter } from '../router'
|
||||
import picgo from '@core/picgo'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import type { IPicGoCloudUserInfo } from '#/types/cloud'
|
||||
import type {
|
||||
CloudAlbumImportAllResult,
|
||||
CloudAlbumListResponse,
|
||||
CloudAlbumBatchUpdateResult,
|
||||
CloudAlbumImportResult,
|
||||
CloudAlbumFiltersResponse,
|
||||
CloudAlbumStatsResponse
|
||||
} from '#/types/cloudAlbum'
|
||||
import {
|
||||
ConfigSyncManager,
|
||||
ConflictType,
|
||||
E2EAskPinReason,
|
||||
EncryptionMethod,
|
||||
IBuildInEvent,
|
||||
SyncStatus,
|
||||
type AlbumListQuery,
|
||||
type IDiffNode,
|
||||
type IConfig
|
||||
} from 'picgo'
|
||||
import { T } from '~/main/i18n'
|
||||
import { fail, ok } from '../utils'
|
||||
import GuiApi from 'apis/gui'
|
||||
@@ -10,15 +30,6 @@ import { parse } from 'comment-json'
|
||||
import { cloneDeep, isPlainObject, set, unset } from 'lodash'
|
||||
import path from 'path'
|
||||
import logger from 'apis/core/picgo/logger'
|
||||
import {
|
||||
ConfigSyncManager,
|
||||
ConflictType,
|
||||
E2EAskPinReason,
|
||||
EncryptionMethod,
|
||||
SyncStatus,
|
||||
type IDiffNode,
|
||||
type IConfig
|
||||
} from 'picgo'
|
||||
import {
|
||||
IPicGoCloudConfigSyncConflictChoice,
|
||||
IPicGoCloudConfigSyncRunStatus,
|
||||
@@ -272,8 +283,14 @@ const buildResolvedConfig = async (resolution: IPicGoCloudConfigSyncResolution):
|
||||
return base
|
||||
}
|
||||
|
||||
const getUserInfo = async (): Promise<IPicGoCloudUserInfo | null> => {
|
||||
return await picgo.cloud.getUserInfo()
|
||||
type PicGoCloudGetUserInfoOptions = {
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
const getUserInfo = async (options?: PicGoCloudGetUserInfoOptions): Promise<IPicGoCloudUserInfo | null> => {
|
||||
return options?.refresh === true
|
||||
? await picgo.cloud.refreshUserInfo()
|
||||
: await picgo.cloud.getUserInfo()
|
||||
}
|
||||
|
||||
const loginWithTimeout = async (): Promise<void> => {
|
||||
@@ -292,14 +309,17 @@ const loginWithTimeout = async (): Promise<void> => {
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
// Avoid unhandled rejection when timeout disposes the core login flow.
|
||||
loginPromise.catch(() => {})
|
||||
loginPromise.catch((error) => {
|
||||
picgo.log.warn(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cloudRouter
|
||||
.add(IRPCActionType.PICGO_CLOUD_GET_USER_INFO, async () => {
|
||||
.add(IRPCActionType.PICGO_CLOUD_GET_USER_INFO, async (args) => {
|
||||
try {
|
||||
const userInfo = await getUserInfo()
|
||||
const [options] = args as [PicGoCloudGetUserInfoOptions | undefined]
|
||||
const userInfo = await getUserInfo(options)
|
||||
return ok(userInfo)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
@@ -554,6 +574,130 @@ cloudRouter
|
||||
}
|
||||
})
|
||||
|
||||
// --- Cloud Album RPC handlers ---
|
||||
|
||||
cloudRouter
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_LIST, async (args) => {
|
||||
try {
|
||||
const [query] = args as [AlbumListQuery | undefined]
|
||||
logger.debug('[PicGo Cloud][album][list]', JSON.stringify(query ?? {}))
|
||||
const result = await picgo.cloud.album.list(query ?? {})
|
||||
return ok(result as CloudAlbumListResponse)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_DELETE, async (args) => {
|
||||
try {
|
||||
const [ids] = args as [string | string[]]
|
||||
logger.debug('[PicGo Cloud][album][delete]', JSON.stringify(ids))
|
||||
await picgo.cloud.album.delete(ids)
|
||||
return ok(true)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_UPDATE, async (args) => {
|
||||
try {
|
||||
const [id, data] = args as [string, Partial<ImgInfo>]
|
||||
logger.debug('[PicGo Cloud][album][update]', `id=${id}`, JSON.stringify(data))
|
||||
const result = await picgo.cloud.album.update(id, data)
|
||||
return ok(result)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_BATCH_UPDATE, async (args) => {
|
||||
try {
|
||||
const [items] = args as [{ id: string, data: Partial<ImgInfo> }[]]
|
||||
logger.debug('[PicGo Cloud][album][batchUpdate]', `count=${items.length}`)
|
||||
const result = await picgo.cloud.album.batchUpdate(items)
|
||||
return ok(result as CloudAlbumBatchUpdateResult)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT, async (args) => {
|
||||
try {
|
||||
const [items] = args as [ImgInfo[]]
|
||||
logger.debug('[PicGo Cloud][album][import]', `count=${items.length}`)
|
||||
const result = await picgo.cloud.album.import(items)
|
||||
return ok(result as CloudAlbumImportResult)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_GET_STATS, async () => {
|
||||
try {
|
||||
logger.debug('[PicGo Cloud][album][getStats]')
|
||||
const result = await picgo.cloud.album.getStats()
|
||||
return ok(result as CloudAlbumStatsResponse)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_GET_FILTERS, async () => {
|
||||
try {
|
||||
logger.debug('[PicGo Cloud][album][getFilters]')
|
||||
const result = await picgo.cloud.album.getFilters()
|
||||
return ok(result as CloudAlbumFiltersResponse)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_SET_AUTO_IMPORT, async (args) => {
|
||||
try {
|
||||
const [autoImport] = args as [boolean]
|
||||
logger.debug('[PicGo Cloud][album][setAutoImport]', `autoImport=${autoImport}`)
|
||||
const userInfo = await picgo.cloud.setAutoImport(autoImport)
|
||||
return ok(userInfo)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
|
||||
cloudRouter
|
||||
.add(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT_ALL, async () => {
|
||||
try {
|
||||
logger.debug('[PicGo Cloud][album][importAll] starting')
|
||||
// 1. Enable auto-import
|
||||
const userInfo = await picgo.cloud.setAutoImport(true)
|
||||
logger.debug('[PicGo Cloud][album][importAll] user info', JSON.stringify(userInfo, null, 2))
|
||||
// 2. Read all local gallery items
|
||||
const albumResult = await AlbumDB.getInstance().get({ orderBy: 'desc' })
|
||||
const localItems = albumResult.data as ImgInfo[]
|
||||
logger.debug('[PicGo Cloud][album][importAll]', `localItems=${localItems.length}`)
|
||||
let created = 0
|
||||
if (localItems.length > 0) {
|
||||
const importResult = await picgo.cloud.album.import(localItems)
|
||||
created = importResult.created
|
||||
if (created > 0) {
|
||||
picgo.emit(IBuildInEvent.CLOUD_ALBUM_UPDATED)
|
||||
}
|
||||
}
|
||||
const result: CloudAlbumImportAllResult = { userInfo, created }
|
||||
return ok(result)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_GET_USAGE, async () => {
|
||||
try {
|
||||
const usage = await picgo.cloud.getUsage()
|
||||
return ok(usage)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.PICGO_CLOUD_GET_BILLING_OVERVIEW, async () => {
|
||||
try {
|
||||
const overview = await picgo.cloud.getBillingOverview()
|
||||
return ok(overview)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
|
||||
export {
|
||||
cloudRouter
|
||||
}
|
||||
|
||||
@@ -4,10 +4,39 @@ import picgo from '@core/picgo'
|
||||
import { T } from '~/main/i18n'
|
||||
import { fail, ok } from '../utils'
|
||||
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { IWindowList } from '#/types/enum'
|
||||
|
||||
const configRouter = new RPCRouter()
|
||||
|
||||
configRouter
|
||||
.add(IRPCActionType.CHANGE_CURRENT_UPLOADER, async (args) => {
|
||||
try {
|
||||
const [type, configName] = args as ISelectUploaderConfigArgs
|
||||
|
||||
if (configName) {
|
||||
const activeConfig = picgo.uploaderConfig.use(type, configName)
|
||||
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
|
||||
}
|
||||
|
||||
notifyAppConfigUpdated()
|
||||
return ok(activeConfig._id)
|
||||
}
|
||||
|
||||
const activeConfig = picgo.uploaderConfig.use(type)
|
||||
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
|
||||
}
|
||||
|
||||
notifyAppConfigUpdated()
|
||||
return ok(activeConfig._id)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.GET_PICBED_CONFIG_LIST, async (args) => {
|
||||
try {
|
||||
const [type] = args as IGetUploaderConfigListArgs
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { galleryMenu as changeURLGalleryMenu } from './changeURL'
|
||||
export const builtInGalleryToolboxMenu = () => {
|
||||
const menuList = [...changeURLGalleryMenu()]
|
||||
|
||||
return menuList
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
|
||||
import { RPCRouter } from '../../router'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { galleryMenuListManager } from './menuListManager'
|
||||
|
||||
const galleryToolboxRouter = new RPCRouter()
|
||||
|
||||
galleryToolboxRouter.add(IRPCActionType.GET_GALLERY_MENU_LIST, async (args) => {
|
||||
const [selectedList] = args as IGetGalleryMenuListArgs
|
||||
const win = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const menu = galleryMenuListManager.getMenu(selectedList)
|
||||
|
||||
menu.popup({
|
||||
window: win
|
||||
})
|
||||
})
|
||||
|
||||
export {
|
||||
galleryToolboxRouter
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import picgo from '@core/picgo'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
import { RPCRouter } from '../router'
|
||||
import { fail, ok } from '../utils'
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
|
||||
import { T } from '~/main/i18n'
|
||||
import { showNotification } from '~/main/utils/common'
|
||||
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
|
||||
import { dialog } from 'electron'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { IWindowList } from '#/types/enum'
|
||||
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||
|
||||
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
|
||||
|
||||
function handleRestoreState (fullName: string) {
|
||||
const plugin = picgo.pluginLoader.getPlugin(fullName)
|
||||
|
||||
if (!plugin) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentUploader =
|
||||
picgo.getConfig<string>('picBed.uploader') ||
|
||||
picgo.getConfig<string>('picBed.current') ||
|
||||
'smms'
|
||||
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
|
||||
const uploaderName = plugin.uploader || ''
|
||||
const transformerName = plugin.transformer || ''
|
||||
const configToRestore: Record<string, string> = {}
|
||||
|
||||
if (uploaderName && currentUploader === uploaderName) {
|
||||
configToRestore['picBed.current'] = 'smms'
|
||||
configToRestore['picBed.uploader'] = 'smms'
|
||||
}
|
||||
|
||||
if (transformerName && currentTransformer === transformerName) {
|
||||
configToRestore['picBed.transformer'] = 'path'
|
||||
}
|
||||
|
||||
if (Object.keys(configToRestore).length > 0) {
|
||||
picgo.saveConfig(configToRestore)
|
||||
}
|
||||
}
|
||||
|
||||
const pluginsRouter = new RPCRouter()
|
||||
|
||||
pluginsRouter
|
||||
.add(IRPCActionType.INSTALL_PLUGIN, async (args) => {
|
||||
const [fullName] = args as [string]
|
||||
const result = await picgo.pluginHandler.install([fullName])
|
||||
|
||||
if (!result.success) {
|
||||
showNotification({
|
||||
title: T('PLUGIN_INSTALL_FAILED'),
|
||||
body: result.body as string
|
||||
})
|
||||
return fail(new Error(result.body as string))
|
||||
}
|
||||
|
||||
shortKeyHandler.registerPluginShortKey(result.body[0])
|
||||
return ok(result.body[0])
|
||||
})
|
||||
.add(IRPCActionType.IMPORT_LOCAL_PLUGIN, async () => {
|
||||
try {
|
||||
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)
|
||||
|
||||
if (!settingWindow) {
|
||||
throw new Error('Setting window not found')
|
||||
}
|
||||
|
||||
const dialogResult = await dialog.showOpenDialog(settingWindow, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
|
||||
const filePaths = dialogResult.filePaths
|
||||
|
||||
if (filePaths.length === 0) {
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
const result = await picgo.pluginHandler.install(filePaths)
|
||||
|
||||
if (!result.success) {
|
||||
showNotification({
|
||||
title: T('PLUGIN_IMPORT_FAILED'),
|
||||
body: result.body as string
|
||||
})
|
||||
return fail(new Error(result.body as string))
|
||||
}
|
||||
|
||||
shortKeyHandler.registerPluginShortKey(result.body[0])
|
||||
return ok(result.body[0])
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.UNINSTALL_PLUGIN, async (args) => {
|
||||
const [fullName] = args as [string]
|
||||
handleRestoreState(fullName)
|
||||
const result = await picgo.pluginHandler.uninstall([fullName])
|
||||
|
||||
if (!result.success) {
|
||||
showNotification({
|
||||
title: T('PLUGIN_UNINSTALL_FAILED'),
|
||||
body: result.body as string
|
||||
})
|
||||
return fail(new Error(result.body as string))
|
||||
}
|
||||
|
||||
shortKeyHandler.unregisterPluginShortKey(result.body[0])
|
||||
picgo.saveConfig({
|
||||
needReload: true
|
||||
})
|
||||
notifyAppConfigUpdated()
|
||||
return ok(result.body[0])
|
||||
})
|
||||
.add(IRPCActionType.UPDATE_PLUGIN, async (args) => {
|
||||
const [fullName] = args as [string]
|
||||
const result = await picgo.pluginHandler.update([fullName])
|
||||
|
||||
if (!result.success) {
|
||||
showNotification({
|
||||
title: T('PLUGIN_UPDATE_FAILED'),
|
||||
body: result.body as string
|
||||
})
|
||||
return fail(new Error(result.body as string))
|
||||
}
|
||||
|
||||
picgo.saveConfig({
|
||||
needReload: true
|
||||
})
|
||||
notifyAppConfigUpdated()
|
||||
return ok(result.body[0])
|
||||
})
|
||||
.add(IRPCActionType.ENABLE_PLUGIN, async (args) => {
|
||||
try {
|
||||
const [fullName] = args as [string]
|
||||
|
||||
picgo.saveConfig({
|
||||
[`picgoPlugins.${fullName}`]: true,
|
||||
needReload: true
|
||||
})
|
||||
|
||||
notifyAppConfigUpdated()
|
||||
return ok(fullName)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.DISABLE_PLUGIN, async (args) => {
|
||||
try {
|
||||
const [fullName] = args as [string]
|
||||
|
||||
handleRestoreState(fullName)
|
||||
picgo.saveConfig({
|
||||
[`picgoPlugins.${fullName}`]: false,
|
||||
needReload: true
|
||||
})
|
||||
|
||||
notifyAppConfigUpdated()
|
||||
return ok(fullName)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.GET_INSTALLED_PLUGIN_README, async (args) => {
|
||||
try {
|
||||
const [fullName] = args as [string]
|
||||
if (typeof fullName !== 'string' || fullName.length === 0) {
|
||||
return ok('')
|
||||
}
|
||||
|
||||
const pluginDir = path.join(picgo.baseDir, 'node_modules', fullName)
|
||||
|
||||
for (const candidate of README_FILE_CANDIDATES) {
|
||||
const readmePath = path.join(pluginDir, candidate)
|
||||
if (await fs.pathExists(readmePath)) {
|
||||
const content = await fs.readFile(readmePath, 'utf-8')
|
||||
return ok(content)
|
||||
}
|
||||
}
|
||||
|
||||
return ok('')
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.REFRESH_CONFIG_SCHEMA, async (args) => {
|
||||
try {
|
||||
const [payload] = args as [IRefreshConfigSchemaArgs]
|
||||
const draftValues = payload.draftValues ?? {}
|
||||
let rawSchema: unknown[] | null = null
|
||||
|
||||
if (payload.target === 'plugin') {
|
||||
const plugin = picgo.pluginLoader.getPlugin(payload.pluginFullName)
|
||||
if (plugin?.config) {
|
||||
rawSchema = plugin.config(picgo)
|
||||
}
|
||||
} else if (payload.target === 'transformer') {
|
||||
const transformerName = picgo.pluginLoader.getPlugin(payload.pluginFullName)?.transformer
|
||||
if (transformerName) {
|
||||
const handler = picgo.helper.transformer.get(transformerName)
|
||||
if (handler?.config) {
|
||||
rawSchema = handler.config(picgo)
|
||||
}
|
||||
}
|
||||
} else if (payload.target === 'uploader') {
|
||||
const handler = picgo.helper.uploader.get(payload.uploaderName)
|
||||
if (handler?.config) {
|
||||
const configContext = payload.schemaOnly
|
||||
? createSchemaOnlyUploaderContext(picgo, payload.uploaderName)
|
||||
: picgo
|
||||
rawSchema = handler.config(configContext)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawSchema) {
|
||||
picgo.log.warn(`[plugin-config] refresh target not found: ${JSON.stringify(payload)}`)
|
||||
return ok([])
|
||||
}
|
||||
|
||||
const resolved = evaluatePluginConfig(rawSchema as Parameters<typeof evaluatePluginConfig>[0], draftValues, {
|
||||
onError: (fieldName, kind, error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
picgo.log.warn(`[plugin-config] ${fieldName}.${kind} threw during refresh: ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
return ok(resolved)
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
})
|
||||
|
||||
type IRefreshConfigSchemaArgs =
|
||||
| { target: 'plugin', pluginFullName: string, draftValues?: Record<string, unknown> }
|
||||
| { target: 'transformer', pluginFullName: string, draftValues?: Record<string, unknown> }
|
||||
| {
|
||||
target: 'uploader'
|
||||
uploaderName: string
|
||||
draftValues?: Record<string, unknown>
|
||||
schemaOnly?: boolean
|
||||
}
|
||||
|
||||
export {
|
||||
pluginsRouter
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
|
||||
import { RPCRouter } from '../router'
|
||||
import { app, clipboard, shell } from 'electron'
|
||||
import { app, BrowserWindow, clipboard, shell } from 'electron'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { handleMenubarIcon } from '~/main/apis/app/system'
|
||||
import { PICGO_NOTIFICATION_CLICKED } from '~/universal/events/constants'
|
||||
@@ -13,6 +13,13 @@ systemRouter
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
.add(IRPCActionType.GET_WINDOW_STATE, async (_args, event) => {
|
||||
const senderWindow = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
return {
|
||||
isMaximized: senderWindow?.isMaximized() ?? false
|
||||
}
|
||||
})
|
||||
.add(IRPCActionType.OPEN_FILE, async (args) => {
|
||||
const [filePath] = args as IOpenFileArgs
|
||||
shell.openPath(filePath)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import fs from 'fs-extra'
|
||||
import { IToolboxItemCheckStatus, IToolboxItemType } from '~/universal/types/enum'
|
||||
import { sendToolboxResWithType } from './utils'
|
||||
import { dbPathChecker, getGalleryDBPath } from '~/main/apis/core/datastore/dbChecker'
|
||||
import { GalleryDB } from '~/main/apis/core/datastore'
|
||||
import { dbPathChecker, getAlbumDBPath } from '~/main/apis/core/datastore/dbChecker'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import path from 'path'
|
||||
import { T } from '~/main/i18n'
|
||||
|
||||
export const checkFileMap: IToolboxCheckerMap<
|
||||
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_ALBUM_FILE_BROKEN
|
||||
> = {
|
||||
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async (event) => {
|
||||
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_CONFIG_FILE_BROKEN)
|
||||
@@ -34,17 +34,17 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
})
|
||||
}
|
||||
},
|
||||
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: async (event) => {
|
||||
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_GALLERY_FILE_BROKEN)
|
||||
[IToolboxItemType.IS_ALBUM_FILE_BROKEN]: async (event) => {
|
||||
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_ALBUM_FILE_BROKEN)
|
||||
sendToolboxRes(event, {
|
||||
status: IToolboxItemCheckStatus.LOADING
|
||||
})
|
||||
const { dbPath } = getGalleryDBPath()
|
||||
const galleryDB = GalleryDB.getInstance()
|
||||
if (galleryDB.errorList.length === 0) {
|
||||
const { dbPath } = getAlbumDBPath()
|
||||
const albumDB = AlbumDB.getInstance()
|
||||
if (albumDB.errorList.length === 0) {
|
||||
sendToolboxRes(event, {
|
||||
status: IToolboxItemCheckStatus.SUCCESS,
|
||||
msg: T('TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS', {
|
||||
msg: T('TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS', {
|
||||
path: dbPath
|
||||
}),
|
||||
value: path.dirname(dbPath)
|
||||
@@ -52,7 +52,7 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
} else {
|
||||
sendToolboxRes(event, {
|
||||
status: IToolboxItemCheckStatus.ERROR,
|
||||
msg: T('TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS'),
|
||||
msg: T('TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS'),
|
||||
value: path.dirname(dbPath)
|
||||
})
|
||||
}
|
||||
@@ -60,7 +60,7 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
}
|
||||
|
||||
export const fixFileMap: IToolboxFixMap<
|
||||
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_ALBUM_FILE_BROKEN
|
||||
> = {
|
||||
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async () => {
|
||||
try {
|
||||
@@ -73,14 +73,14 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
|
||||
status: IToolboxItemCheckStatus.SUCCESS
|
||||
}
|
||||
},
|
||||
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: async () => {
|
||||
[IToolboxItemType.IS_ALBUM_FILE_BROKEN]: async () => {
|
||||
try {
|
||||
fs.unlinkSync(getGalleryDBPath().dbPath)
|
||||
fs.unlinkSync(getAlbumDBPath().dbPath)
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
return {
|
||||
type: IToolboxItemType.IS_GALLERY_FILE_BROKEN,
|
||||
type: IToolboxItemType.IS_ALBUM_FILE_BROKEN,
|
||||
status: IToolboxItemCheckStatus.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import logger from '@core/picgo/logger'
|
||||
const errorToMessage = (e: unknown): string => {
|
||||
if (e instanceof Error) return e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从抛出的 error 里提取错误码字符串。两个字段都查,优先级:apiCode > code。
|
||||
*
|
||||
* - apiCode:picgo-core `createCloudServiceError` 包装后挂上的业务错误码字段,
|
||||
* 值来自后端响应体里的 `data.code`(如 'ACCOUNT_FROZEN' / 'QUOTA_EXCEEDED')。
|
||||
* 走过 picgo-core 包装的云端调用,错误码都在这里。
|
||||
*
|
||||
* - code:兜底字段,覆盖两种场景:
|
||||
* 1) axios 底层错误自带的 `code`(如 'ECONNREFUSED' / 'ETIMEDOUT'),未被
|
||||
* picgo-core 包装就穿透到这里时,错误码仅存在于 `code`;
|
||||
* 2) 其他非 picgo-core 抛出的自定义错误对象用 `code` 字段。
|
||||
*
|
||||
* 防御性保留两者,避免因调用路径不一致丢失错误码。
|
||||
*/
|
||||
const errorToCode = (e: unknown): string | undefined => {
|
||||
if (!e || typeof e !== 'object') return undefined
|
||||
const maybe = e as { apiCode?: unknown, code?: unknown }
|
||||
if (typeof maybe.apiCode === 'string') return maybe.apiCode
|
||||
if (typeof maybe.code === 'string') return maybe.code
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const ok = <T>(data: T): IRPCResult<T> => ({
|
||||
success: true,
|
||||
data
|
||||
})
|
||||
|
||||
export const fail = <T>(e: unknown): IRPCResult<T> => ({
|
||||
success: false,
|
||||
error: errorToMessage(e)
|
||||
})
|
||||
export const fail = <T>(e: unknown): IRPCResult<T> => {
|
||||
const code = errorToCode(e)
|
||||
const error = errorToMessage(e)
|
||||
logger.error(`[RPC][FAIL] code=${code} message=${error}`)
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
...(code ? { code } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export const isIRPCResult = (value: unknown): value is IRPCResult<any> => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
@@ -22,4 +51,3 @@ export const isIRPCResult = (value: unknown): value is IRPCResult<any> => {
|
||||
if (maybe.success) return 'data' in maybe
|
||||
return typeof maybe.error === 'string'
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IRemoteNoticeTriggerHook, IWindowList } from '#/types/enum'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import {
|
||||
updateShortKeyFromVersion212,
|
||||
migrateGalleryFromVersion230
|
||||
migrateAlbumFromVersion230
|
||||
} from '~/main/migrate'
|
||||
import {
|
||||
uploadSelectedFiles,
|
||||
@@ -25,7 +25,7 @@ import server from '~/main/server/index'
|
||||
import updateChecker from '~/main/utils/updateChecker'
|
||||
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
|
||||
import { getUploadFiles } from '~/main/utils/handleArgv'
|
||||
import { GalleryDB } from '~/main/apis/core/datastore'
|
||||
import { AlbumDB } from '~/main/apis/core/datastore'
|
||||
import bus from '@core/bus'
|
||||
import logger from 'apis/core/picgo/logger'
|
||||
import picgo from 'apis/core/picgo'
|
||||
@@ -66,7 +66,7 @@ class LifeCycle {
|
||||
ipcList.listen()
|
||||
busEventList.listen()
|
||||
updateShortKeyFromVersion212(picgo)
|
||||
await migrateGalleryFromVersion230(GalleryDB.getInstance(), picgo)
|
||||
await migrateAlbumFromVersion230(AlbumDB.getInstance(), picgo)
|
||||
}
|
||||
|
||||
private onReady () {
|
||||
|
||||
@@ -39,8 +39,8 @@ const updateShortKeyFromVersion212 = (picgo: PicGoCore) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore) => {
|
||||
const originGallery = picgo.getConfig<ImgInfo[] | undefined>('uploaded')
|
||||
const migrateAlbumFromVersion230 = async (albumDB: DBStore, picgo: PicGoCore) => {
|
||||
const originAlbum = picgo.getConfig<ImgInfo[] | undefined>('uploaded')
|
||||
// if hasMigrate, we don't need to migrate
|
||||
const hasMigrate = picgo.getConfig<boolean | undefined>('__migrateUploaded') === true
|
||||
if (hasMigrate) {
|
||||
@@ -49,11 +49,11 @@ const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore
|
||||
const configPath = picgo.configPath
|
||||
const configBakPath = path.join(path.dirname(configPath), 'config.bak.json')
|
||||
// migrate gallery from config to gallery db
|
||||
if (originGallery && Array.isArray(originGallery) && originGallery?.length > 0) {
|
||||
if (originAlbum && Array.isArray(originAlbum) && originAlbum?.length > 0) {
|
||||
if (fse.existsSync(configBakPath)) {
|
||||
fse.copyFileSync(configPath, configBakPath)
|
||||
}
|
||||
await galleryDB.insertMany(originGallery)
|
||||
await albumDB.insertMany(originAlbum)
|
||||
picgo.saveConfig({
|
||||
uploaded: [],
|
||||
__migrateUploaded: true
|
||||
@@ -63,5 +63,5 @@ const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore
|
||||
|
||||
export {
|
||||
updateShortKeyFromVersion212,
|
||||
migrateGalleryFromVersion230
|
||||
migrateAlbumFromVersion230
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import logger from '@core/picgo/logger'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import { uploadClipboardFiles, uploadSelectedFiles } from 'apis/app/uploader/apis'
|
||||
import path from 'path'
|
||||
import { dbPathDir, getFormImageFolderPath } from 'apis/core/datastore/dbChecker'
|
||||
import fs from 'fs-extra'
|
||||
import { cleanupFormUploaderFiles } from '~/main/utils/cleanupFormUploaderFiles'
|
||||
import {
|
||||
buildError,
|
||||
buildSuccess,
|
||||
getFormDataFileName,
|
||||
isFileLike,
|
||||
isRecord,
|
||||
type HonoContextLike
|
||||
} from './utils'
|
||||
|
||||
const STORE_PATH = dbPathDir()
|
||||
const LOG_PATH = path.join(STORE_PATH, 'picgo.log')
|
||||
|
||||
const errorMessage = `upload error. see ${LOG_PATH} for more detail.`
|
||||
|
||||
const handleClipboardUpload = async (c: HonoContextLike): Promise<Response> => {
|
||||
try {
|
||||
logger.info('[PicGo Server] upload clipboard file')
|
||||
const res = await uploadClipboardFiles()
|
||||
if (res) {
|
||||
return c.json(buildSuccess([res]))
|
||||
}
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
} catch (e: unknown) {
|
||||
logger.error('[PicGo Server] upload clipboard error', e)
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleListUpload = async (c: HonoContextLike, list: string[]): Promise<Response> => {
|
||||
try {
|
||||
logger.info('[PicGo Server] upload files in list')
|
||||
const win = windowManager.getAvailableWindow()
|
||||
if (!win) {
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
}
|
||||
const pathList = list.map(item => ({ path: item }))
|
||||
const res = await uploadSelectedFiles(win.webContents, pathList)
|
||||
if (res.length) {
|
||||
return c.json(buildSuccess(res))
|
||||
}
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
} catch (e: unknown) {
|
||||
logger.error('[PicGo Server] upload list error', e)
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadHandler = async (c: HonoContextLike): Promise<Response> => {
|
||||
const contentType = c.req.raw.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const tempFiles: string[] = []
|
||||
try {
|
||||
const formData = await c.req.formData()
|
||||
const files = formData.getAll('files') as unknown[]
|
||||
if (files.length === 0) {
|
||||
return c.json(buildError('No files found in form-data: files'), 400)
|
||||
}
|
||||
|
||||
// Pre-validate to avoid leaking already-written temp files.
|
||||
const fileLikes: Array<Parameters<typeof getFormDataFileName>[0]> = []
|
||||
for (const file of files) {
|
||||
if (!isFileLike(file)) {
|
||||
return c.json(buildError('Invalid form-data: files must be file(s)'), 400)
|
||||
}
|
||||
fileLikes.push(file)
|
||||
}
|
||||
|
||||
const formImagesPath = getFormImageFolderPath()
|
||||
await fs.ensureDir(formImagesPath)
|
||||
|
||||
for (const file of fileLikes) {
|
||||
const fileName = getFormDataFileName(file)
|
||||
const safeName = path.basename(fileName)
|
||||
const filePath = path.join(formImagesPath, safeName)
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await fs.writeFile(filePath, buffer)
|
||||
tempFiles.push(filePath)
|
||||
}
|
||||
|
||||
return await handleListUpload(c, tempFiles)
|
||||
} catch (e: unknown) {
|
||||
logger.error('[PicGo Server] process form upload error', e)
|
||||
return c.json(buildError(errorMessage), 500)
|
||||
} finally {
|
||||
cleanupFormUploaderFiles(tempFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = await c.req.raw.text().catch(() => '')
|
||||
|
||||
// No request body -> upload from clipboard.
|
||||
if (bodyText.trim() === '') {
|
||||
return await handleClipboardUpload(c)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(bodyText)
|
||||
} catch {
|
||||
return c.json(buildError('Invalid JSON body'), 400)
|
||||
}
|
||||
|
||||
// GUI compatibility: JSON without list (or empty list) -> upload from clipboard.
|
||||
if (!isRecord(body) || !('list' in body)) {
|
||||
return await handleClipboardUpload(c)
|
||||
}
|
||||
|
||||
const listValue = body.list
|
||||
if (listValue === undefined) {
|
||||
return await handleClipboardUpload(c)
|
||||
}
|
||||
|
||||
if (!Array.isArray(listValue)) {
|
||||
return c.json(buildError('Invalid request body: { list: string[] } required'), 400)
|
||||
}
|
||||
|
||||
if (listValue.length === 0) {
|
||||
return await handleClipboardUpload(c)
|
||||
}
|
||||
|
||||
if (!listValue.every((item) => typeof item === 'string')) {
|
||||
return c.json(buildError('Invalid request body: { list: string[] } required'), 400)
|
||||
}
|
||||
|
||||
return await handleListUpload(c, listValue)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import picgo from '@core/picgo'
|
||||
import logger from '@core/picgo/logger'
|
||||
import { uploadHandler } from './handler'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
import {
|
||||
uploadClipboardFilesWithInfo,
|
||||
uploadSelectedFilesWithInfo
|
||||
} from 'apis/app/uploader/apis'
|
||||
import { getFormImageFolderPath } from 'apis/core/datastore/dbChecker'
|
||||
import type { IInternalServerManager } from 'picgo/dist/types/internal'
|
||||
|
||||
class Server {
|
||||
private config: IServerConfig
|
||||
private hasRegisteredUploadOverride = false
|
||||
private hasInstalledUploadAdapter = false
|
||||
constructor () {
|
||||
this.config = this.ensureConfig()
|
||||
}
|
||||
@@ -34,17 +40,24 @@ class Server {
|
||||
return config
|
||||
}
|
||||
|
||||
private ensureUploadOverrideRegistered () {
|
||||
if (this.hasRegisteredUploadOverride) return
|
||||
// @ts-expect-error override internal handler
|
||||
picgo.server.registerPost('/upload', uploadHandler, true)
|
||||
this.hasRegisteredUploadOverride = true
|
||||
private ensureUploadAdapterInstalled () {
|
||||
if (this.hasInstalledUploadAdapter) return
|
||||
const server = picgo.server as IInternalServerManager
|
||||
server.setUploadAdapter({
|
||||
uploadClipboard: async () => await uploadClipboardFilesWithInfo(),
|
||||
uploadPaths: async (paths: string[]) => {
|
||||
const win = windowManager.getAvailableWindow()
|
||||
return await uploadSelectedFilesWithInfo(win.webContents, paths.map(item => ({ path: item })))
|
||||
},
|
||||
getTempDir: getFormImageFolderPath
|
||||
})
|
||||
this.hasInstalledUploadAdapter = true
|
||||
}
|
||||
|
||||
startup () {
|
||||
this.config = this.ensureConfig()
|
||||
if (this.config.enable) {
|
||||
this.ensureUploadOverrideRegistered()
|
||||
this.ensureUploadAdapterInstalled()
|
||||
// let core resolve config defaults when possible, but preserve GUI config semantics.
|
||||
const port = typeof this.config.port === 'string' ? parseInt(this.config.port, 10) : this.config.port
|
||||
picgo.server.listen(port, this.config.host)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type UploadResponseBody = {
|
||||
success: boolean
|
||||
result: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type HonoContextLike = {
|
||||
req: {
|
||||
raw: Request
|
||||
formData: () => Promise<FormData>
|
||||
}
|
||||
json: (data: unknown, status?: number) => Response
|
||||
}
|
||||
|
||||
type FormDataFileLike = {
|
||||
name?: string
|
||||
arrayBuffer: () => Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
export const isFileLike = (value: unknown): value is FormDataFileLike => {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
if (!('arrayBuffer' in value)) return false
|
||||
return typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
|
||||
}
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export const buildSuccess = (result: string[]): UploadResponseBody => ({
|
||||
success: true,
|
||||
result
|
||||
})
|
||||
|
||||
export const buildError = (message: string): UploadResponseBody => ({
|
||||
success: false,
|
||||
result: [],
|
||||
message
|
||||
})
|
||||
|
||||
export const getFormDataFileName = (value: FormDataFileLike): string => {
|
||||
const name = value.name
|
||||
if (typeof name === 'string' && name.trim() !== '') return name
|
||||
return `${randomUUID()}.png`
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import windowManager from 'apis/app/window/windowManager'
|
||||
const TARGET_WINDOWS: IWindowList[] = [
|
||||
IWindowList.SETTING_WINDOW,
|
||||
IWindowList.TRAY_WINDOW,
|
||||
IWindowList.MINI_WINDOW
|
||||
IWindowList.MINI_WINDOW,
|
||||
IWindowList.RENAME_WINDOW,
|
||||
IWindowList.TOOLBOX_WINDOW
|
||||
]
|
||||
|
||||
export const notifyAppConfigUpdated = (): void => {
|
||||
|
||||
@@ -1,34 +1,109 @@
|
||||
// for referer policy, we can't use it in renderer
|
||||
import axios from 'axios'
|
||||
import { RELEASE_URL, RELEASE_URL_BACKUP } from '../../universal/utils/static'
|
||||
import semver from 'semver'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
interface IGithubRelease {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
prerelease?: boolean
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
interface IReleaseYAML {
|
||||
version?: unknown
|
||||
}
|
||||
|
||||
const REQUEST_HEADERS = {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
|
||||
function normalizeVersion (version: unknown): string {
|
||||
if (typeof version !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const normalized = version.trim().replace(/^v/i, '')
|
||||
return semver.valid(normalized) ?? ''
|
||||
}
|
||||
|
||||
function pickLatestVersion (versions: string[]): string {
|
||||
return versions.reduce((latest, current) => {
|
||||
if (latest === '' || semver.gt(current, latest)) {
|
||||
return current
|
||||
}
|
||||
return latest
|
||||
}, '')
|
||||
}
|
||||
|
||||
async function fetchLatestVersionFromGitHub (isCheckBetaUpdate: boolean): Promise<string> {
|
||||
const response = await axios.get(RELEASE_URL, {
|
||||
headers: REQUEST_HEADERS
|
||||
})
|
||||
const releaseList: IGithubRelease[] = Array.isArray(response.data) ? response.data : []
|
||||
|
||||
const versions = releaseList.flatMap((release) => {
|
||||
if (release.draft) {
|
||||
return []
|
||||
}
|
||||
if (!isCheckBetaUpdate && release.prerelease) {
|
||||
return []
|
||||
}
|
||||
|
||||
const version = normalizeVersion(release.tag_name ?? release.name)
|
||||
return version ? [version] : []
|
||||
})
|
||||
|
||||
return pickLatestVersion(versions)
|
||||
}
|
||||
|
||||
async function fetchVersionFromBackupYAML (fileName: 'latest.yml' | 'latest.beta.yml'): Promise<string> {
|
||||
const response = await axios.get(`${RELEASE_URL_BACKUP}/${fileName}`, {
|
||||
headers: REQUEST_HEADERS
|
||||
})
|
||||
const releaseInfo = yaml.load(response.data)
|
||||
|
||||
if (typeof releaseInfo !== 'object' || releaseInfo === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalizeVersion((releaseInfo as IReleaseYAML).version)
|
||||
}
|
||||
|
||||
async function fetchLatestVersionFromBackup (isCheckBetaUpdate: boolean): Promise<string> {
|
||||
if (!isCheckBetaUpdate) {
|
||||
return fetchVersionFromBackupYAML('latest.yml')
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
fetchVersionFromBackupYAML('latest.yml'),
|
||||
fetchVersionFromBackupYAML('latest.beta.yml')
|
||||
])
|
||||
|
||||
const versions: string[] = []
|
||||
settled.forEach((item) => {
|
||||
if (item.status === 'fulfilled' && item.value) {
|
||||
versions.push(item.value)
|
||||
}
|
||||
})
|
||||
|
||||
return pickLatestVersion(versions)
|
||||
}
|
||||
|
||||
export const getLatestVersion = async (isCheckBetaUpdate: boolean = false) => {
|
||||
let res: string = ''
|
||||
try {
|
||||
res = await axios.get(RELEASE_URL, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
}).then(r => {
|
||||
const list = r.data as IStringKeyMap[]
|
||||
if (isCheckBetaUpdate) {
|
||||
const betaList = list.filter(item => item.name.includes('beta'))
|
||||
return betaList[0].name
|
||||
}
|
||||
const normalList = list.filter(item => !item.name.includes('beta'))
|
||||
return normalList[0].name
|
||||
}).catch(async () => {
|
||||
const result = await axios.get(isCheckBetaUpdate ? `${RELEASE_URL_BACKUP}/latest.beta.yml` : `${RELEASE_URL_BACKUP}/latest.yml`, {
|
||||
headers: {
|
||||
Referer: 'https://github.com'
|
||||
}
|
||||
})
|
||||
const r = yaml.load(result.data) as IStringKeyMap
|
||||
return r.version
|
||||
})
|
||||
const version = await fetchLatestVersionFromGitHub(isCheckBetaUpdate)
|
||||
if (version) {
|
||||
return version
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
return res
|
||||
|
||||
try {
|
||||
return await fetchLatestVersionFromBackup(isCheckBetaUpdate)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,15 @@ import picgo from '@core/picgo'
|
||||
const getPicBeds = () => {
|
||||
const picBedTypes = picgo.helper.uploader.getIdList()
|
||||
const picBedFromDB = picgo.getConfig<IPicBedType[]>('picBed.list') || []
|
||||
const picBeds = picBedTypes.map((item: string) => {
|
||||
const visible = picBedFromDB.find((i: IPicBedType) => i.type === item) // object or undefined
|
||||
return {
|
||||
type: item,
|
||||
name: picgo.helper.uploader.get(item)!.name || item,
|
||||
visible: visible ? visible.visible : true
|
||||
}
|
||||
}).sort((a) => {
|
||||
if (a.type === 'tcyun') {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}) as IPicBedType[]
|
||||
const picBeds = picBedTypes
|
||||
.map((item: string) => {
|
||||
const visible = picBedFromDB.find((i: IPicBedType) => i.type === item) // object or undefined
|
||||
return {
|
||||
type: item,
|
||||
name: picgo.helper.uploader.get(item)!.name || item,
|
||||
visible: visible ? visible.visible : true
|
||||
}
|
||||
}) as IPicBedType[]
|
||||
return picBeds
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { simpleClone, trimValues } from '#/utils/common'
|
||||
import picgo from '@core/picgo'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
export const handleConfigWithFunction = (config: IPicGoPluginOriginConfig[]): IPicGoPluginConfig[] => {
|
||||
for (const i in config) {
|
||||
if (typeof config[i].default === 'function') {
|
||||
config[i].default = config[i].default()
|
||||
const resolved = evaluatePluginConfig(config as Parameters<typeof evaluatePluginConfig>[0], {}, {
|
||||
onError: (fieldName, kind, error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
picgo.log.warn(`[plugin-config] ${fieldName}.${kind} threw: ${message}`)
|
||||
}
|
||||
if (typeof config[i].choices === 'function') {
|
||||
config[i].choices = (config[i].choices as Function)()
|
||||
}
|
||||
}
|
||||
return config as IPicGoPluginConfig[]
|
||||
})
|
||||
return resolved as unknown as IPicGoPluginConfig[]
|
||||
}
|
||||
|
||||
export const completeUploaderMetaConfig = (originData: IStringKeyMap): IUploaderConfigListItem => {
|
||||
|
||||
@@ -14,14 +14,15 @@ const formatCustomLink = (customLink: string, item: ImgInfo) => {
|
||||
keys.forEach(item => {
|
||||
if (customLink.indexOf(`$${item}`) !== -1) {
|
||||
const reg = new RegExp(`\\$${item}`, 'g')
|
||||
customLink = customLink.replace(reg, formatObj[item])
|
||||
customLink = customLink.replace(reg, formatObj[item]!)
|
||||
}
|
||||
})
|
||||
return customLink
|
||||
}
|
||||
|
||||
export default (style: IPasteStyle, item: ImgInfo, customLink: string | undefined) => {
|
||||
const url = handleUrlEncodeWithSetting(item.url || item.imgUrl)
|
||||
const pasteUrl = item.url || item.imgUrl || ''
|
||||
const url = handleUrlEncodeWithSetting(pasteUrl)
|
||||
const _customLink = customLink || '$url'
|
||||
const tpl = {
|
||||
markdown: ``,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
interface ConfigReadableContext {
|
||||
getConfig<T>(name?: string): T
|
||||
}
|
||||
|
||||
type ConfigRecord = Record<string, unknown>
|
||||
|
||||
function isConfigRecord(value: unknown): value is ConfigRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function omitUploaderConfig(
|
||||
value: unknown,
|
||||
uploaderName: string
|
||||
): unknown {
|
||||
if (!isConfigRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const nextValue = { ...value }
|
||||
delete nextValue[uploaderName]
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function sanitizeFullConfig(value: unknown, uploaderName: string): unknown {
|
||||
if (!isConfigRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const nextValue = { ...value }
|
||||
|
||||
if ('picBed' in value) {
|
||||
nextValue.picBed = omitUploaderConfig(value.picBed, uploaderName)
|
||||
}
|
||||
|
||||
if ('uploader' in value) {
|
||||
nextValue.uploader = omitUploaderConfig(value.uploader, uploaderName)
|
||||
}
|
||||
|
||||
return nextValue
|
||||
}
|
||||
|
||||
export function createSchemaOnlyUploaderContext<T extends ConfigReadableContext>(
|
||||
context: T,
|
||||
uploaderName: string
|
||||
): T {
|
||||
const hiddenConfigPaths = [
|
||||
`picBed.${uploaderName}`,
|
||||
`uploader.${uploaderName}`
|
||||
]
|
||||
|
||||
const getConfig = <V>(name?: string): V => {
|
||||
if (
|
||||
name &&
|
||||
hiddenConfigPaths.some(
|
||||
(configPath) => name === configPath || name.startsWith(`${configPath}.`)
|
||||
)
|
||||
) {
|
||||
return undefined as V
|
||||
}
|
||||
|
||||
if (name === 'picBed' || name === 'uploader') {
|
||||
return omitUploaderConfig(
|
||||
context.getConfig<unknown>(name),
|
||||
uploaderName
|
||||
) as V
|
||||
}
|
||||
|
||||
if (name === undefined) {
|
||||
return sanitizeFullConfig(
|
||||
context.getConfig<unknown>(),
|
||||
uploaderName
|
||||
) as V
|
||||
}
|
||||
|
||||
return context.getConfig<V>(name)
|
||||
}
|
||||
|
||||
return new Proxy(context, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'getConfig') {
|
||||
return getConfig
|
||||
}
|
||||
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
+98
-8
@@ -1,13 +1,103 @@
|
||||
// temp no used
|
||||
// will be refactor in future
|
||||
import { contextBridge, webUtils } from 'electron'
|
||||
import {
|
||||
contextBridge,
|
||||
ipcRenderer,
|
||||
webFrame,
|
||||
webUtils
|
||||
} from 'electron'
|
||||
import { I18n } from '@picgo/i18n/dist/i18n'
|
||||
import { ObjectAdapter } from '@picgo/i18n/dist/adapters/object'
|
||||
|
||||
const getFilePath = (file: File): string => webUtils.getPathForFile(file)
|
||||
type PreloadIpcListener = (...args: unknown[]) => void
|
||||
type ElectronIpcListener = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||
|
||||
const electronApi = {
|
||||
getFilePath
|
||||
const createCleanup = (channel: string, wrappedListener: ElectronIpcListener) => {
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, wrappedListener)
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronApi', electronApi)
|
||||
const ipc = {
|
||||
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args),
|
||||
invoke: <T>(channel: string, ...args: unknown[]) => {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<T>
|
||||
},
|
||||
on: (channel: string, listener: PreloadIpcListener) => {
|
||||
const wrappedListener: ElectronIpcListener = (_event, ...args) => {
|
||||
listener(...args)
|
||||
}
|
||||
|
||||
export type ElectronApi = typeof electronApi
|
||||
ipcRenderer.on(channel, wrappedListener)
|
||||
|
||||
return createCleanup(channel, wrappedListener)
|
||||
},
|
||||
once: (channel: string, listener: PreloadIpcListener) => {
|
||||
const wrappedListener: ElectronIpcListener = (_event, ...args) => {
|
||||
listener(...args)
|
||||
}
|
||||
|
||||
ipcRenderer.once(channel, wrappedListener)
|
||||
|
||||
return createCleanup(channel, wrappedListener)
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel)
|
||||
}
|
||||
}
|
||||
|
||||
type I18nLocalesMap = Record<string, ILocales>
|
||||
|
||||
const createObjectAdapterBridge = (locales: I18nLocalesMap) => {
|
||||
const adapter = new ObjectAdapter(locales)
|
||||
|
||||
return {
|
||||
getLocale: (language: string) => adapter.getLocale(language) as ILocales | undefined,
|
||||
setLocales: (nextLocales: I18nLocalesMap) => adapter.setLocales(nextLocales),
|
||||
setLocale: (language: string, locale: ILocales) => adapter.setLocale(language, locale)
|
||||
}
|
||||
}
|
||||
|
||||
const createI18nBridge = (locales: I18nLocalesMap, defaultLanguage: string) => {
|
||||
const i18n = new I18n({
|
||||
adapter: new ObjectAdapter(locales),
|
||||
defaultLanguage
|
||||
})
|
||||
|
||||
return {
|
||||
getLanguage: () => i18n.getLanguage(),
|
||||
setLanguage: (language: string) => i18n.setLanguage(language),
|
||||
setDefaultLanguage: (language: string) => i18n.setDefaultLanguage(language),
|
||||
translate: (key: ILocalesKey, args: IStringKeyMap = {}) => i18n.translate(key, args) || key
|
||||
}
|
||||
}
|
||||
|
||||
const bridgeApi = {
|
||||
ipc,
|
||||
// 注:clipboard 不在此暴露——sandboxed preload(Electron 22+ 默认)下
|
||||
// `require('electron').clipboard` 为 undefined。剪贴板操作改走 RPC(IRPCActionType.COPY_TEXT)。
|
||||
webUtils: {
|
||||
getPathForFile: (file: File): string => webUtils.getPathForFile(file)
|
||||
},
|
||||
webFrame: {
|
||||
setVisualZoomLevelLimits: (minimumLevel: number, maximumLevel: number) => {
|
||||
webFrame.setVisualZoomLevelLimits(minimumLevel, maximumLevel)
|
||||
}
|
||||
},
|
||||
env: {
|
||||
platform: process.platform,
|
||||
isDev: Boolean(process.env.ELECTRON_RENDERER_URL) || process.env.NODE_ENV === 'development'
|
||||
},
|
||||
i18n: {
|
||||
ObjectAdapter: {
|
||||
create: (locales: I18nLocalesMap) => createObjectAdapterBridge(locales)
|
||||
},
|
||||
I18n: {
|
||||
createFromLocales: (locales: I18nLocalesMap, defaultLanguage: string) => {
|
||||
return createI18nBridge(locales, defaultLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('bridgeApi', bridgeApi)
|
||||
|
||||
export type BridgeApi = typeof bridgeApi
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHashHistory } from "@tanstack/history"
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { GlobalAppearanceSync } from "@/components/common/global-appearance-sync"
|
||||
import { PicGoCloudUserInfoSync } from "@/components/common/picgo-cloud-user-info-sync"
|
||||
import { RendererRuntimeBridge } from "@/components/common/renderer-runtime-bridge"
|
||||
import { RendererStoreHydrator } from "@/components/common/renderer-store-hydrator"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { rendererQueryClient } from "@/queries/query-client"
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
history: createHashHistory(),
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={rendererQueryClient}>
|
||||
<RendererStoreHydrator />
|
||||
<RendererRuntimeBridge />
|
||||
<PicGoCloudUserInfoSync />
|
||||
<GlobalAppearanceSync />
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" richColors />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStore } from '@/hooks/useStore'
|
||||
import { onBeforeMount, onMounted, onUnmounted } from 'vue'
|
||||
import bus from './utils/bus'
|
||||
import { APP_CONFIG_UPDATED, FORCE_UPDATE } from '~/universal/events/constants'
|
||||
import { useATagClick } from './hooks/useATagClick'
|
||||
import { useIPCOn } from './hooks/useIPC'
|
||||
|
||||
useATagClick()
|
||||
|
||||
const store = useStore()
|
||||
const handleAppConfigUpdated = () => {
|
||||
store?.refreshAppConfig()
|
||||
store?.refreshPicBeds()
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
if (!store) return
|
||||
await store.refreshAppConfig()
|
||||
await store.refreshPicBeds()
|
||||
})
|
||||
|
||||
useIPCOn(APP_CONFIG_UPDATED, handleAppConfigUpdated)
|
||||
|
||||
onMounted(() => {
|
||||
bus.on(FORCE_UPDATE, () => {
|
||||
store?.updateForceUpdateTime()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.off(FORCE_UPDATE)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'PicGoApp'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
body,
|
||||
html
|
||||
padding 0
|
||||
margin 0
|
||||
height 100%
|
||||
font-family "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif
|
||||
#app
|
||||
user-select none
|
||||
overflow hidden
|
||||
.el-button-group
|
||||
width 100%
|
||||
.el-button
|
||||
width 50%
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PASTE_TEXT } from '#/events/constants'
|
||||
import db from '@/utils/db'
|
||||
import { sendToMain } from '@/utils/dataSender'
|
||||
import { clipboard, ipc } from '@/utils/bridge'
|
||||
|
||||
import { resolveTimestampValue } from '@/utils/common'
|
||||
|
||||
function resolveAlbumItemTimestamp (item: ImgInfo) {
|
||||
return resolveTimestampValue(item.createdAt) || resolveTimestampValue(item.updatedAt)
|
||||
}
|
||||
|
||||
export const albumAdapter = {
|
||||
async getAlbumItems () {
|
||||
const result = await db.get<ImgInfo>({ orderBy: 'desc' })
|
||||
return result.data
|
||||
},
|
||||
async getRecentUploads (limit = 100) {
|
||||
const result = await db.get<ImgInfo>({ orderBy: 'desc' })
|
||||
return [...result.data]
|
||||
.sort((left, right) => resolveAlbumItemTimestamp(right) - resolveAlbumItemTimestamp(left))
|
||||
.slice(0, limit)
|
||||
},
|
||||
async updateImageUrl (id: string, imgUrl: string) {
|
||||
await db.updateById(id, { imgUrl })
|
||||
},
|
||||
async updateImportFlag (id: string, imported: boolean) {
|
||||
await db.updateById(id, { _importToPicGoCloud: imported })
|
||||
},
|
||||
async removeById (id: string) {
|
||||
const file = await db.getById<ImgInfo>(id)
|
||||
await db.removeById(id)
|
||||
if (file) {
|
||||
sendToMain('removeFiles', [file])
|
||||
}
|
||||
},
|
||||
async copyImageLink (item: ImgInfo) {
|
||||
return ipc.invoke<string>(PASTE_TEXT, item)
|
||||
},
|
||||
copyBatchLinks (links: string[]) {
|
||||
clipboard.writeText(links.join('\n'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IConfig } from 'picgo'
|
||||
import { getConfig, getPicBeds, saveConfig } from '@/utils/dataSender'
|
||||
|
||||
export const appConfigAdapter = {
|
||||
getAppConfig () {
|
||||
return getConfig<IConfig>()
|
||||
},
|
||||
getPicBeds () {
|
||||
return getPicBeds()
|
||||
},
|
||||
saveConfig
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user