mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
Merge remote-tracking branch 'origin/claude/fix-directory-symlink-pull-260713'
Resolves conflicts between issue #78 (delete-remote-only-file) fixes and the i18n work landed on this branch in parallel: the delete-result Notice now uses the i18n t() helper with a new 'partialWithMessage' key (en + zh-tw) that carries the real error text.
This commit is contained in:
commit
563a28e44f
16 changed files with 716 additions and 208 deletions
|
|
@ -69,10 +69,10 @@
|
|||
{
|
||||
"id": "feat-009",
|
||||
"name": "feat: add i18n (multi-language) support (issue #38)",
|
||||
"description": "Extract hardcoded UI strings (~47 in settings.ts, ~24 in SyncStatusView.ts) and 37 Notice() call sites (4 files, many with interpolated values) into an i18n key system with locale detection and English fallback",
|
||||
"description": "Extract hardcoded UI strings (settings tab, ribbon/command labels, Notice messages, sync status view/modals) into an i18n key system with locale detection and English fallback",
|
||||
"dependencies": [],
|
||||
"status": "blocked",
|
||||
"evidence": "Scope question asked, dismissed without an answer. No code written. Blocked on user picking a scope: (a) settings.ts only, Notices later; (b) settings.ts + all Notices now; (c) infra/mechanism only, defer string migration."
|
||||
"status": "done",
|
||||
"evidence": "Commit 144eb28 on branch claude/i18n-support-260713, merged into claude/fix-directory-symlink-pull-260713 - added src/i18n/{index,locales/en,locales/zh-tw}.ts (159 keys, en + zh-tw) and tests/i18n/index.test.ts; ~130 strings replaced across settings.ts, main.ts, and 7 ui/ files; lint/build clean, 308 tests pass; consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-010",
|
||||
|
|
|
|||
55
progress.md
55
progress.md
|
|
@ -12,62 +12,67 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
|
|||
|
||||
## Current State
|
||||
|
||||
**Last Updated:** 2026-07-14 04:20
|
||||
**Last Updated:** 2026-07-14 09:20
|
||||
**Session ID:** current
|
||||
**Active Feature:** issue #78 (blog repo, misfiled — actually a git-files-sync bug: delete remote-only file fails) — investigation complete, fix in progress
|
||||
**Active Feature:** issue #78 (blog repo, misfiled — actually a git-files-sync bug: delete remote-only file fails) — two root causes fixed, user re-testing after rebuild
|
||||
|
||||
## Status
|
||||
|
||||
### What's Done
|
||||
|
||||
- [x] feat-001..008 (project setup, settings UX bundle, folder picker, symlink pull fix, tree-SHA refresh, HTML-response error clarity, what's-new modal) — see archive/2026-07.md
|
||||
- [x] feat-001..009 (project setup, settings UX bundle, folder picker, symlink pull fix, tree-SHA refresh, HTML-response error clarity, what's-new modal, i18n/multi-language support) — see archive/2026-07.md
|
||||
- All consolidated onto branch `claude/fix-directory-symlink-pull-260713` → **PR #51** (open, all CI green as of last check), per user's explicit request to keep the PR count down rather than one PR per issue.
|
||||
- [x] "Root path" folder picker now suggests folders from the **remote repo tree** instead of the local vault. Added `src/ui/RemoteFolderSuggest.ts` (derives folder paths from `gitService.listFiles(branch, false)`); wired into the Root path field in `src/settings.ts`. Vault folder field untouched (still uses local `FolderSuggest`). Evidence: `npx eslint .` → 0 errors; `npm run build` → clean; `npx vitest run` → 302/302 passed.
|
||||
|
||||
- [x] Issue #78 (filed in `firstsun-dev/blog` but actually a git-files-sync bug — user confirmed to fix it here regardless of repo mismatch): "delete remote only file fail". Root-caused via subagent investigation, then fixed:
|
||||
1. `github-service.ts`/`gitea-service.ts` `getApiUrl()` now URL-encodes each path segment (`fullPath.split('/').map(encodeURIComponent).join('/')`), matching `gitlab-service.ts`'s existing behavior. This was the likely actual trigger: paths with spaces/non-ASCII (e.g. Chinese filenames) 404'd on the Contents API.
|
||||
2. `deleteFile()` in both services now throws a clear error (`Cannot delete "<path>": file was not found on branch "<branch>".`) instead of silently sending a DELETE with an empty `sha` when the pre-delete `getFile()` lookup 404s.
|
||||
3. `SyncStatusView.ts` `performLocalDeletion`/`performRemoteDeletion` now capture `{path, message}` instead of swallowing the error; the failure Notice shows the real message(s), and `logger.error` logs full detail.
|
||||
- [x] "Root path" folder picker now suggests folders from the **remote repo tree** instead of the local vault. Added `src/ui/RemoteFolderSuggest.ts` (derives folder paths from `gitService.listFiles(branch, false)`); wired into the Root path field in `src/settings.ts`. Vault folder field untouched (still uses local `FolderSuggest`).
|
||||
- [x] Issue #78 (filed in `firstsun-dev/blog` but actually a git-files-sync bug — user confirmed to fix it here regardless of repo mismatch): "delete remote only file fail". Two distinct root causes found and fixed:
|
||||
1. **URL encoding + silent empty-sha delete**: `github-service.ts`/`gitea-service.ts` `getApiUrl()` built the Contents API URL without `encodeURIComponent` on path segments (unlike `gitlab-service.ts`, which already encodes), so paths with spaces/non-ASCII (e.g. Chinese filenames) 404'd on the pre-delete sha lookup; that 404 silently produced an empty `sha`, and the resulting DELETE with `sha: ''` was rejected — silently, since the UI swallowed all delete errors. Fixed: encode path segments in both services; `deleteFile()` now throws a clear error if the pre-delete lookup's sha is empty; `SyncStatusView.ts` delete flow now captures `{path, message}` per failure and surfaces the real message in the Notice (`syncStatus.notice.deleteResult.partialWithMessage` i18n key, added to both `en.ts`/`zh-tw.ts`) instead of a bare "N failed".
|
||||
2. **EISDIR on local-only symlinked folders**: user confirmed the actual file they tried to delete was *not* a symlink, but a real console error they pasted (`EISDIR: illegal operation on a directory, read` for `.claude/skills/polish-blog`) revealed a second bug: `SyncStatusView.ts` `readFileContent()` called `adapter.read()` directly on string paths with no symlink guard — only the sha-based path (`readLocalContentForSha`) checked for symlinks, and only when the *remote* already knew the entry was a symlink. A local-only symlinked folder not yet pushed hit `adapter.read()` on a directory and crashed, leaving its status stuck. Fixed by extracting `readStringPathContent()` with a catch-and-fallback to `readLocalSymlinkTarget()`.
|
||||
- Added 4 new tests (2 in `tests/services/github-service.test.ts`, 2 in `tests/services/gitea-service.test.ts`) covering the empty-sha throw and non-ASCII/space path encoding.
|
||||
- Evidence: `npx eslint .` → 0 errors; `npm run build` → clean; `npx vitest run` → 306/306 passed.
|
||||
- Committed as `896d77b`, merged with the i18n branch's concurrent changes to the same files (`45ea8fa`-range merge commit), pushed to `claude/fix-directory-symlink-pull-260713`.
|
||||
- **Not yet confirmed fixed**: the user's last live test (before this push) still showed the old bare "N failed" text with a genuinely non-symlink file on GitHub — that was because the fix hadn't reached their test vault yet (everything was uncommitted). Now pushed; next step is the user rebuilding/reloading and re-testing to confirm root cause #1 (or find a third cause) with the new detailed error message.
|
||||
- Not yet done: comment on/reference issue #78 (in the `blog` repo) noting the fix landed in `git-files-sync` instead — flag to user before doing so since it crosses repos.
|
||||
- **Second root cause found after user re-tested**: user confirmed the file they were deleting was NOT a symlink, so the encoding/empty-sha fixes above weren't the whole story. Separately spotted (from a real console error the user pasted: `EISDIR: illegal operation on a directory, read` for a local-only symlinked folder `.claude/skills/polish-blog`) that `SyncStatusView.ts` `readFileContent()` called `adapter.read()` directly on string paths with no symlink guard — only the sha-based path (`readLocalContentForSha`) checked for symlinks, and only when the *remote* already knew about the entry as a symlink. A new local-only symlinked folder (not yet pushed) hit `adapter.read()` on a directory → crash → status stuck, contributing to "delete never resolves". Fixed by extracting `readStringPathContent()` with a catch-and-fallback to `readLocalSymlinkTarget()`. This is a distinct bug from the original #78 report but was surfaced by the same debugging session.
|
||||
- **Still unresolved as of this turn**: user reports "N failed" with no message when deleting a genuinely non-symlink remote-only file on GitHub — this is the *old* pre-fix Notice text, meaning none of the above fixes have reached the user's actual test vault yet (everything above is uncommitted in this sandbox). Next step: commit + push these changes to `claude/fix-directory-symlink-pull-260713` so the user can rebuild/reload and re-test with real error messages before further root-causing.
|
||||
|
||||
### What's In Progress
|
||||
|
||||
- [ ] feat-009 - i18n / multi-language support (issue #38) — still paused, awaiting scope decision from user (unchanged from prior session).
|
||||
- Nothing else actively in progress.
|
||||
|
||||
### What's Next
|
||||
|
||||
1. Resolve feat-009's scope question with the user, then implement.
|
||||
2. Issue #37 (Bitbucket provider support) — large, deferred until #38 lands (per agreed order: 39→38→37).
|
||||
3. Re-sync against `gh issue list --repo firstsun-dev/git-files-sync --state open` — as of this session, #40/#41/#42/#48/#33/#36/#31/#39 are technically still "open" on GitHub but are all done in code and waiting on PR #51 to merge (they'll auto-close then). Remaining genuinely-unstarted issues: #47 (regex ignore lists), #45 (SonarQube findings), #38 (i18n, in progress), #37 (Bitbucket), #28 (non-engineering: community visibility).
|
||||
1. Get user confirmation that delete now works (or a new detailed error message) after they rebuild/reload with the latest push.
|
||||
2. Issue #37 (Bitbucket provider support, feat-010) — large, was deferred until #38 (i18n) landed. Now unblocked.
|
||||
3. Re-sync against `gh issue list --repo firstsun-dev/git-files-sync --state open`. Remaining genuinely-unstarted issues as of this session: #47 (regex ignore lists), #45 (SonarQube findings), #37 (Bitbucket), #28 (non-engineering: community visibility).
|
||||
4. PR #51 is large (7+ issues' worth of changes now). If the user wants to review/merge it before more work piles on, flag this rather than continuing to add commits indefinitely.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- feat-009 (i18n) is blocked on a scope decision from the user — do not start writing `src/i18n/*` or touching `settings.ts` strings until that's confirmed, to avoid an inconsistent half-migration.
|
||||
- PR #51 is large (6 issues' worth of changes). If the user wants to review/merge it before more work piles on, flag this rather than continuing to add commits indefinitely.
|
||||
- None currently.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **All work goes into one PR (#51)**: user explicitly said "不要那麼多pr merge" (don't want so many PRs). Branches `claude/tree-sha-status-refresh-260713` and `claude/fix-html-response-json-error-260713` were merged into `claude/fix-directory-symlink-pull-260713` and their PRs (#52, #53) closed/auto-resolved; `claude/settings-ux-improvements-260713` and `claude/folder-picker-settings-260713` were already merged in earlier the same way (PRs #49, #50 closed). **Any further issue work this session should commit directly onto `claude/fix-directory-symlink-pull-260713`, not a new branch.**
|
||||
- **`src/changelog.ts` is hand-curated, separate from the auto-generated `CHANGELOG.md`**: semantic-release already maintains `CHANGELOG.md` from Conventional Commit messages, but it's commit-log-level detail (too granular for an end-user popup) and isn't shipped in the release assets (only `main.js`/`manifest.json`/`styles.css` are). The new modal needs a small, hand-written, user-facing "highlights" list instead — added as part of cutting a release, not auto-generated.
|
||||
- **All work goes into one PR (#51)**: user explicitly said "不要那麼多pr merge" (don't want so many PRs). Any further issue work should commit directly onto `claude/fix-directory-symlink-pull-260713`, not a new branch.
|
||||
- **feat-009 scope (i18n)**: settings.ts + all Notice() messages, done in one pass rather than split into infra-first/settings-only phases.
|
||||
- **Flat key-value i18n dict, not nested namespaces**: matches this plugin's small scale; simpler `t(key, vars)` lookup, no external i18n library dependency.
|
||||
- **Locale detection via `window.moment.locale()`**: per issue #38's suggested approach; unresolvable/unsupported locales fall back to English. A bare `zh` locale code maps to `zh-tw`, the only Chinese variant shipped.
|
||||
- **Diff-format markers, changelog release-note text, and proper nouns (GitHub/GitLab/Gitea) were left untranslated** — out of scope for UI-chrome i18n.
|
||||
- **`src/changelog.ts` is hand-curated, separate from the auto-generated `CHANGELOG.md`**: semantic-release already maintains `CHANGELOG.md` from Conventional Commit messages, but it's commit-log-level detail; the what's-new modal needs a small, hand-written, user-facing "highlights" list instead.
|
||||
- **Fixed two duplication regressions mid-session** (SonarCloud gate is 3% on new code, learned the hard way on PR #49 earlier): deduped `TextComponent`/`TextAreaComponent` test mocks, and deduped the GitHub/Gitea `getBlob()` bodies into a shared `fetchGitHubStyleBlob()` base helper.
|
||||
|
||||
## Files Modified This Session
|
||||
|
||||
See archive/2026-07.md entries feat-003/005/006/007/008 for the full per-feature file lists. No files touched yet for feat-009 (i18n) — paused before any code was written.
|
||||
- Issue #78 fix: `src/services/github-service.ts`, `src/services/gitea-service.ts`, `src/ui/SyncStatusView.ts`, `src/i18n/locales/en.ts`, `src/i18n/locales/zh-tw.ts`, `tests/services/github-service.test.ts`, `tests/services/gitea-service.test.ts`.
|
||||
- Root path picker fix: `src/ui/RemoteFolderSuggest.ts` (new), `src/settings.ts`.
|
||||
- feat-009 (i18n, prior parallel session): `src/i18n/index.ts`, `src/i18n/locales/en.ts`, `src/i18n/locales/zh-tw.ts`, `tests/i18n/index.test.ts` (all new); `src/settings.ts`, `src/main.ts`, `src/ui/SyncStatusView.ts`, `src/ui/SyncConflictModal.ts`, `src/ui/WhatsNewModal.ts`, `src/ui/ConfirmModal.ts`, `src/ui/components/ActionBar.ts`, `FileListItem.ts`, `DiffPanel.ts` (modified).
|
||||
- See archive/2026-07.md entries feat-003/005/006/007/008 for earlier features' file lists.
|
||||
|
||||
## Evidence of Completion
|
||||
|
||||
- [x] Tests pass: `npx vitest run` → 302/302 passed (as of commit 4eebebc)
|
||||
- [x] Type check clean: `npm run build` → clean
|
||||
- [x] Tests pass: `npx vitest run` → 306/306 passed (post-merge, this session)
|
||||
- [x] Type check clean: `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) → clean
|
||||
- [x] Lint clean: `npx eslint .` → 0 errors
|
||||
- [ ] Manual verification in Obsidian: not done (no Obsidian instance available in this environment) for any feature this session
|
||||
- [ ] Manual verification in Obsidian: in progress — user is re-testing the delete fix live; not yet confirmed resolved.
|
||||
|
||||
## Notes for Next Session
|
||||
|
||||
- Start by getting the user's answer on feat-009's scope (see "What's In Progress" above) before writing any i18n code.
|
||||
- `feature_list.json`'s backlog is a snapshot from this session — re-check `gh issue list` before trusting it, though as of now it should be accurate (checked at end of session).
|
||||
- Working branch for all further commits: `claude/fix-directory-symlink-pull-260713` (PR #51). Do not open a new branch/PR for the next issue unless the user says otherwise.
|
||||
- `feature_list.json`'s backlog is a snapshot from this session — re-check `gh issue list` before trusting it.
|
||||
- If the user reports delete still failing after this push, get the exact new error message (should no longer be a bare "N failed") to find the next root cause.
|
||||
|
|
|
|||
|
|
@ -9,18 +9,20 @@
|
|||
|
||||
## Current Objective
|
||||
|
||||
- Goal: Work through open issues one at a time, all consolidated into a single PR (user explicitly asked for fewer PRs, not one per issue).
|
||||
- Current status: 6 issues done and pushed (#33, #36, #31, #39, plus #40/#41/#42/#48 from an earlier session), all on one branch/PR. Issue #38 (i18n) was scoped but paused before any code was written — waiting on the user's answer to a scope question.
|
||||
- Branch / commit: `claude/fix-directory-symlink-pull-260713` @ 4eebebc → **PR #51** (open, all CI checks green as of last check)
|
||||
- Goal: Work through open issues one at a time, all consolidated into a single PR (user explicitly asked for fewer PRs, not one per issue). This session's issue: #38 (i18n / multi-language support).
|
||||
- Current status: #38 implemented, tested, linted, built clean, and merged onto the shared branch. 7 issues done total (#33, #36, #31, #39, #38, plus #40/#41/#42/#48 from an earlier session), all on one branch/PR.
|
||||
- Branch / commit: `claude/fix-directory-symlink-pull-260713` → **PR #51** (open). i18n work landed via a temporary branch `claude/i18n-support-260713` (commit `144eb28`), merged in and pushed.
|
||||
|
||||
## Completed This Session
|
||||
|
||||
- [x] #31 - `fix: surface a clear error when requestUrl() itself rejects with HTML content` — some Obsidian versions eagerly JSON-parse inside `requestUrl()`, so a login/proxy HTML page rejected the whole call with a raw `SyntaxError`, bypassing the existing `parseJson()` HTML-detection wrapper. Added the same detection to `safeRequest`'s outer catch.
|
||||
- [x] #33 - `fix: symlinked directories no longer break pull discovery` — a directory symlink is a single git blob, not a real tree; local scanning recursed into it as if it were, causing bogus remote `.gitignore` 404s and a permanently-stuck "remote only" status. Also fixed `createLocalSymlink`'s missing Windows `symlinkSync` type hint.
|
||||
- [x] #36 - `perf(refresh): use tree blob SHAs to avoid per-file content fetches` — refresh now compares a locally-computed git blob SHA against each tree entry's SHA instead of one `getFile` network call per file; diff content is fetched on demand via a new `getBlob(sha, path)` per service. Symlink-aware hashing (real/follow/skip modes) per the issue's follow-up design.
|
||||
- [x] #39 - `feat: show new feature tips after update` — new `src/changelog.ts` (hand-curated, separate from the auto-generated `CHANGELOG.md`) with a `notable` flag per entry, a `WhatsNewModal`, and a `lastSeenVersion` setting so the tip shows once per upgrade only.
|
||||
- [x] Consolidated everything (this session's 4 issues + the prior session's #40/#41/#42/#48) onto one branch/PR (#51) per the user's explicit request to reduce PR count — closed/merged the now-redundant PRs #49/#50/#52/#53.
|
||||
- [ ] #38 (i18n) - **paused, not started.** Counted scope (~47 strings in `settings.ts`, ~24 in `SyncStatusView.ts`, 37 `Notice()` call sites across 4 files) and asked the user how deep to scope it; the question prompt was dismissed without an answer before they ran `/firstsun-harness`. No `src/i18n/*` files exist yet — don't assume a design, ask again first.
|
||||
- [x] #38 - i18n / multi-language support: `src/i18n/index.ts` (`t(key, vars?)` flat-dict lookup, `{placeholder}` interpolation, English fallback), `src/i18n/locales/en.ts` (159 keys, source of truth) and `zh-tw.ts` (full parity, verified no missing/extra keys)
|
||||
- [x] Locale detection via `window.moment.locale()`; unresolvable/unsupported locales fall back to `en`; bare `zh` maps to `zh-tw`
|
||||
- [x] Replaced ~130 hardcoded strings across `src/settings.ts`, `src/main.ts`, `src/ui/SyncStatusView.ts`, `SyncConflictModal.ts`, `WhatsNewModal.ts`, `ConfirmModal.ts`, `components/ActionBar.ts`, `FileListItem.ts`, `DiffPanel.ts`
|
||||
- [x] Left untranslated on purpose: GitHub/GitLab/Gitea proper nouns, diff-format markers (`--- Remote`/`+++ Local`), URL placeholders, changelog release-note content
|
||||
- [x] Fixed a lint regression (cognitive-complexity + nested-ternary) introduced by the change in `SyncStatusView.runBatchOperation` by extracting a lookup table and a helper method
|
||||
- [x] Added `tests/i18n/index.test.ts` (6 cases: fallback with no `window.moment`, fallback for unsupported locale, zh-tw resolution, bare `zh` → `zh-tw` mapping, interpolation, fallback-per-key when zh-tw is missing a translation)
|
||||
- [x] Worked on a temporary branch (`claude/i18n-support-260713`, off `origin/claude/fix-directory-symlink-pull-260713`) because that branch was already checked out in this repo's other worktree; merged it back into `claude/fix-directory-symlink-pull-260713` immediately afterward per the one-PR policy, rather than leaving it as a standing separate branch/PR
|
||||
- [x] Resolved a merge conflict in `feature_list.json`/`progress.md`/`session-handoff.md` against a parallel session's harness-state commit that had (incorrectly, since it predated this session's actual implementation) recorded #38 as "blocked, awaiting scope decision"
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
|
|
@ -28,39 +30,31 @@
|
|||
|---|---|---|---|
|
||||
| Lint | `npx eslint .` | 0 errors | Repo-wide, no exceptions |
|
||||
| Type check + compat | `npm run build` | Pass | Includes `typecheck-compat.mjs` against Obsidian 1.11.0 |
|
||||
| Tests | `npx vitest run` | 302/302 passed | 22 test files |
|
||||
| Duplication | `npx jscpd --min-lines 5 --min-tokens 50 src` | 14 clones, all pre-existing | Checked after every commit — SonarCloud's gate is 3% on *new* code, learned this the hard way earlier (PR #49 failed at 8.3%) |
|
||||
| Tests | `npx vitest run` | 308/308 passed | 23 test files (302 pre-existing + 6 new i18n tests) |
|
||||
| Manual (in Obsidian) | — | Not done | No Obsidian instance available in this environment |
|
||||
|
||||
## Files Changed (this session, cumulative across the 4 issues)
|
||||
## Files Changed (this session)
|
||||
|
||||
- `src/services/git-service-base.ts`, `git-service-interface.ts`, `github-service.ts`, `gitlab-service.ts`, `gitea-service.ts` — tree-entry `sha`, `getBlob()`, HTML-error detection in `safeRequest`
|
||||
- `src/logic/gitignore-manager.ts`, `src/ui/SyncStatusView.ts` — symlink-directory recursion guard, SHA-based status refresh, lazy diff loading
|
||||
- `src/utils/symlink.ts` — Windows `symlinkSync` type hint
|
||||
- `src/utils/git-blob-sha.ts`, `src/utils/version.ts` (new) — SHA-1 blob hashing, numeric version compare
|
||||
- `src/changelog.ts`, `src/ui/WhatsNewModal.ts` (new) — what's-new modal + data
|
||||
- `src/ui/components/DiffPanel.ts`, `FileListItem.ts` — on-demand diff fetch
|
||||
- `src/settings.ts`, `src/main.ts` — `lastSeenVersion` field, update-check wiring
|
||||
- Corresponding test files under `tests/` for all of the above (302 tests total)
|
||||
- New: `src/i18n/index.ts`, `src/i18n/locales/en.ts`, `src/i18n/locales/zh-tw.ts`, `tests/i18n/index.test.ts`
|
||||
- Modified: `src/settings.ts`, `src/main.ts`, `src/ui/SyncStatusView.ts`, `src/ui/SyncConflictModal.ts`, `src/ui/WhatsNewModal.ts`, `src/ui/ConfirmModal.ts`, `src/ui/components/ActionBar.ts`, `src/ui/components/FileListItem.ts`, `src/ui/components/DiffPanel.ts`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **One PR, not one-per-issue**: user said "不要那麼多pr merge" (don't want so many PRs). All further issue work goes directly onto `claude/fix-directory-symlink-pull-260713` — do not create a new branch/PR for the next issue.
|
||||
- **One PR, not one-per-issue**: user said "不要那麼多pr merge" (don't want so many PRs). All further issue work goes directly onto `claude/fix-directory-symlink-pull-260713` — do not create a new branch/PR for the next issue unless a branch conflict (as above) forces a temporary one, and merge it back in immediately if so.
|
||||
- **#38 scope**: settings.ts + all Notice() messages, done in one pass — user confirmed flat key-value dict (not nested namespaces) and `window.moment.locale()` detection with English fallback.
|
||||
- **`src/changelog.ts` is hand-curated, separate from `CHANGELOG.md`**: the auto-generated changelog (via semantic-release) isn't shipped in release assets and is too commit-log-granular for an end-user popup anyway.
|
||||
- Deduped two accidental code-duplication regressions mid-session (test mocks, `getBlob` bodies) before they could trip SonarCloud's new-code gate again.
|
||||
- Deduped two accidental code-duplication regressions in an earlier part of this session (test mocks, `getBlob` bodies) before they could trip SonarCloud's new-code gate again.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- **#38 (i18n) needs a scope answer from the user before any code is written.** Options put to them: (a) build the i18n mechanism + fully migrate `settings.ts` only, Notices later; (b) fully migrate settings.ts *and* all 37 Notice call sites now; (c) build just the `t()`/detection/fallback mechanism with a couple of sample keys, defer actual string migration. Don't guess — the wrong choice means redoing a large diff.
|
||||
- PR #51 is now fairly large (6 issues). Consider flagging to the user that it may be worth reviewing/merging before more commits pile on.
|
||||
- None currently. PR #51 is now fairly large (7 issues). Consider flagging to the user that it may be worth reviewing/merging before more commits pile on.
|
||||
|
||||
## Next Session Startup
|
||||
|
||||
1. Read `CLAUDE.md`, `feature_list.json`, `progress.md`, then this file.
|
||||
2. Run `./init.sh` before editing.
|
||||
3. Ask the user for #38's scope decision (see "Blockers / Risks") before touching any i18n code.
|
||||
4. All new commits go on `claude/fix-directory-symlink-pull-260713` (PR #51) unless told otherwise.
|
||||
3. All new commits go on `claude/fix-directory-symlink-pull-260713` (PR #51) unless told otherwise.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
- Get the #38 scope decision, implement it, then move to #37 (Bitbucket provider) per the previously agreed order (39→38→37, all done).
|
||||
- Move to issue #37 (Bitbucket provider support) per the previously agreed order (39→38→37, all now done ahead of it).
|
||||
|
|
|
|||
42
src/i18n/index.ts
Normal file
42
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import en, { TranslationKey } from './locales/en';
|
||||
import zhTw from './locales/zh-tw';
|
||||
|
||||
export type { TranslationKey };
|
||||
|
||||
const locales: Record<string, Partial<Record<TranslationKey, string>>> = {
|
||||
en,
|
||||
'zh-tw': zhTw,
|
||||
};
|
||||
|
||||
// Obsidian sets window.moment's locale to match the app's display language
|
||||
// before plugins load. Not typed in the `obsidian` package, so read it off
|
||||
// the global defensively.
|
||||
function detectMomentLocale(): string {
|
||||
const w = window as unknown as { moment?: { locale: () => string } };
|
||||
try {
|
||||
return w.moment?.locale()?.toLowerCase() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a moment locale code to one of our shipped locales, falling back to
|
||||
// English when there's no matching translation.
|
||||
function resolveLocale(rawLocale: string): string {
|
||||
if (rawLocale in locales) return rawLocale;
|
||||
if (rawLocale.startsWith('zh')) return 'zh-tw';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export function getActiveLocale(): string {
|
||||
return resolveLocale(detectMomentLocale());
|
||||
}
|
||||
|
||||
export function t(key: TranslationKey, vars?: Record<string, string | number>): string {
|
||||
const dict = locales[getActiveLocale()] ?? en;
|
||||
const template = dict[key] ?? en[key];
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in vars ? String(vars[name]) : match
|
||||
);
|
||||
}
|
||||
189
src/i18n/locales/en.ts
Normal file
189
src/i18n/locales/en.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// Source of truth for translation keys. Every key used by `t()` must exist
|
||||
// here; other locales may omit keys and fall back to this file.
|
||||
const en = {
|
||||
'settings.connectionStatus.checking': 'Checking…',
|
||||
'settings.connectionStatus.connected': 'Connected',
|
||||
'settings.connectionStatus.disconnected': 'Not connected',
|
||||
'settings.connectionStatus.withDetail': '{label} — {detail}',
|
||||
|
||||
'settings.gitService.name': 'Git service',
|
||||
'settings.gitService.desc': 'Choose your Git hosting service',
|
||||
|
||||
'settings.branch.name': 'Branch',
|
||||
'settings.branch.desc': 'Branch to push or pull from',
|
||||
'settings.branch.placeholder': 'Main',
|
||||
|
||||
'settings.rootPath.name': 'Root path',
|
||||
'settings.rootPath.desc': 'Optional: subfolder in repository (e.g. "notes")',
|
||||
'settings.rootPath.placeholder': 'Enter subfolder path',
|
||||
|
||||
'settings.vaultFolder.name': 'Vault folder',
|
||||
'settings.vaultFolder.desc': 'Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)',
|
||||
'settings.vaultFolder.placeholder': 'Leave empty to sync all files',
|
||||
|
||||
'settings.ignorePatterns.name': 'Ignore patterns',
|
||||
'settings.ignorePatterns.desc': 'Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.',
|
||||
|
||||
'settings.symlinks.name': 'Symbolic links',
|
||||
'settings.symlinks.desc.supported': 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.',
|
||||
'settings.symlinks.desc.unsupported': 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.',
|
||||
'settings.symlinks.option.real': 'Real symlink (recommended)',
|
||||
'settings.symlinks.option.follow': 'Follow (sync target content)',
|
||||
'settings.symlinks.option.skip': 'Skip',
|
||||
|
||||
'settings.testConnection.name': 'Test connection',
|
||||
'settings.testConnection.desc': 'Verify your {service} settings',
|
||||
'settings.testConnection.button': 'Test connection',
|
||||
'settings.testConnection.failed': 'Connection failed: {reason}',
|
||||
'settings.testConnection.failed.unreachable': 'could not reach the repository',
|
||||
'settings.testConnection.branchNotFound.badge': 'branch "{branch}" not found',
|
||||
'settings.testConnection.branchNotFound.notice': 'Connected, but branch "{branch}" was not found. Check the Branch setting, or confirm the repository has a branch with this name.',
|
||||
'settings.testConnection.success': '{service} connection successful!',
|
||||
|
||||
'settings.token.placeholder': 'Enter your token',
|
||||
'settings.token.show': 'Show token',
|
||||
'settings.token.hide': 'Hide token',
|
||||
|
||||
'settings.gitlab.token.name': 'GitLab personal access token',
|
||||
'settings.gitlab.token.desc': 'Create a token in GitLab user settings > access tokens with "API" scope',
|
||||
'settings.gitlab.baseUrl.name': 'GitLab base URL',
|
||||
'settings.gitlab.baseUrl.desc': 'Defaults to https://gitlab.com',
|
||||
'settings.gitlab.projectId.name': 'Project ID',
|
||||
'settings.gitlab.projectId.desc': 'Found in GitLab project overview',
|
||||
'settings.gitlab.projectId.placeholder': 'Enter numeric project ID',
|
||||
|
||||
'settings.gitea.token.name': 'Gitea personal access token',
|
||||
'settings.gitea.token.desc': 'Create a token in user settings > applications > access tokens',
|
||||
'settings.gitea.baseUrl.name': 'Gitea base URL',
|
||||
'settings.gitea.baseUrl.desc': 'URL of your Gitea instance (e.g. https://gitea.example.com)',
|
||||
|
||||
'settings.github.token.name': 'GitHub personal access token',
|
||||
'settings.github.token.desc': 'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope',
|
||||
|
||||
'settings.repoOwner.name': 'Repository owner',
|
||||
'settings.repoOwner.desc.gitea': 'Gitea username or organization name',
|
||||
'settings.repoOwner.desc.github': 'GitHub username or organization name',
|
||||
'settings.repoOwner.placeholder': 'Username',
|
||||
|
||||
'settings.repoName.name': 'Repository name',
|
||||
'settings.repoName.desc.gitea': 'Name of the repository',
|
||||
'settings.repoName.desc.github': 'Name of the GitHub repository',
|
||||
'settings.repoName.placeholder': 'My notes',
|
||||
|
||||
'main.ribbon.openSyncStatus': 'Open sync status',
|
||||
'main.ribbon.push': 'Push',
|
||||
'main.ribbon.pushTo': 'Push to {service}',
|
||||
'main.command.openSyncStatus': 'Open sync status',
|
||||
'main.command.pushCurrentFile': 'Push current file',
|
||||
'main.command.pullCurrentFile': 'Pull current file',
|
||||
'main.command.pushAllFiles': 'Push all files',
|
||||
'main.command.pullAllFiles': 'Pull all files',
|
||||
'main.notice.noActiveNote': 'No active note to push',
|
||||
'main.contextMenu.pushTo': 'Push to {service}',
|
||||
'main.contextMenu.pullFrom': 'Pull from {service}',
|
||||
'main.notice.noFilesToRun': 'No files to {op} in the configured vault folder',
|
||||
'main.confirm.pushAll': 'Push {count} file(s) to {service}?',
|
||||
'main.confirm.pullAll': 'Pull {count} file(s) from {service}? This will overwrite local changes.',
|
||||
'main.progress.running': '{verb} 0/{total} files...',
|
||||
'main.progress.step': '{verb} {current}/{total}: {fileName}',
|
||||
'main.notice.runFailed': '{verb} failed: {message}',
|
||||
'main.op.push': 'push',
|
||||
'main.op.pull': 'pull',
|
||||
'main.verb.pushing': 'Pushing',
|
||||
'main.verb.pulling': 'Pulling',
|
||||
'main.verb.push': 'Push',
|
||||
'main.verb.pull': 'Pull',
|
||||
|
||||
'confirmModal.title': 'Confirm',
|
||||
'confirmModal.confirm': 'Confirm',
|
||||
'confirmModal.cancel': 'Cancel',
|
||||
|
||||
'whatsNew.title': "What's new",
|
||||
'whatsNew.viewChangelog': 'View full changelog',
|
||||
'whatsNew.gotIt': 'Got it',
|
||||
|
||||
'syncStatus.viewTitle': 'Sync status',
|
||||
'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status',
|
||||
'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)',
|
||||
'syncStatus.progress.checking': 'Checking files…',
|
||||
'syncStatus.lastSync': 'Last sync: {time}',
|
||||
'syncStatus.tab.all': 'All',
|
||||
'syncStatus.tab.synced': 'Synced',
|
||||
'syncStatus.tab.modified': 'Changed',
|
||||
'syncStatus.tab.unsynced': 'Local only',
|
||||
'syncStatus.tab.remote-only': 'Remote',
|
||||
'syncStatus.noFilesForFilter': 'No {filter} files',
|
||||
'syncStatus.confirmDeleteLocal': 'Delete local file "{path}"? Handled per your vault\'s "Deleted files" setting.',
|
||||
'syncStatus.notice.deleted': 'Deleted {path}',
|
||||
'syncStatus.notice.deleteFailed': 'Failed to delete: {message}',
|
||||
'syncStatus.notice.opFailed': '{verb} failed: {message}',
|
||||
'syncStatus.notice.alreadyRefreshing': 'Already refreshing…',
|
||||
'syncStatus.notice.refreshed': 'Checked {local} local + {remote} remote files',
|
||||
'syncStatus.notice.refreshFailed': 'Failed to refresh: {message}',
|
||||
'syncStatus.notice.noPushableFiles.selected': 'No pushable files selected.',
|
||||
'syncStatus.notice.noPushableFiles.found': 'No pushable files found.',
|
||||
'syncStatus.notice.noPullableFiles.selected': 'No pullable files selected.',
|
||||
'syncStatus.notice.noPullableFiles.found': 'No pullable files found.',
|
||||
'syncStatus.confirm.pushSelected': 'Push {count} file(s) to {service}?',
|
||||
'syncStatus.confirm.pullSelected': 'Pull {count} file(s) from {service}? This will overwrite local changes.',
|
||||
'syncStatus.notice.opCompleted': '{verb} completed. Refreshing…',
|
||||
'syncStatus.notice.nothingToDelete': 'Nothing to delete',
|
||||
'syncStatus.notice.noFilesSelected': 'No files selected',
|
||||
'syncStatus.confirmDelete.localAndRemote': "Delete {local} local file(s) (per your vault's trash setting) and {remote} remote file(s) (cannot be undone)?",
|
||||
'syncStatus.confirmDelete.localOnly': 'Delete {local} local file(s)? They\'ll be handled per your vault\'s "Deleted files" setting.',
|
||||
'syncStatus.confirmDelete.remoteOnly': 'Delete {remote} remote file(s)? This cannot be undone.',
|
||||
'syncStatus.notice.deleteResult.partial': 'Deleted {succeeded}/{total}. {failed} failed.',
|
||||
'syncStatus.notice.deleteResult.partialWithMessage': 'Deleted {succeeded}/{total}. {failed} failed: {message}',
|
||||
'syncStatus.notice.deleteResult.success': 'Deleted {total} files',
|
||||
'syncStatus.progress.deleting': 'Deleting 0/{total} files…',
|
||||
'syncStatus.progress.pushing': 'Pushing {current}/{total}: {name}',
|
||||
'syncStatus.progress.pulling': 'Pulling {current}/{total}: {name}',
|
||||
'syncStatus.progress.deletingLocal': 'Deleting local {current}/{total}: {path}',
|
||||
'syncStatus.progress.deletingRemote': 'Deleting remote {current}/{total}: {path}',
|
||||
|
||||
'actionBar.select': 'Select',
|
||||
'actionBar.refresh': ' Refresh',
|
||||
'actionBar.refreshAll': 'Refresh all statuses',
|
||||
'actionBar.pushCount': ' Push ({count})',
|
||||
'actionBar.pushFiles': 'Push {count} files',
|
||||
'actionBar.pullCount': ' Pull ({count})',
|
||||
'actionBar.pullFiles': 'Pull {count} files',
|
||||
'actionBar.deleteCount': ' Delete ({count})',
|
||||
'actionBar.deleteFiles': 'Delete {count} files',
|
||||
|
||||
'syncStatus.status.checking': 'Checking',
|
||||
|
||||
'fileListItem.action.push': ' Push',
|
||||
'fileListItem.action.pull': ' Pull',
|
||||
'fileListItem.action.remove': ' Remove',
|
||||
'fileListItem.action.diff': ' Diff',
|
||||
'fileListItem.action.hide': ' Hide',
|
||||
'fileListItem.tooltip.pushToRemote': 'Push to remote',
|
||||
'fileListItem.tooltip.pullFromRemote': 'Pull from remote',
|
||||
'fileListItem.tooltip.deleteLocalFile': 'Delete local file',
|
||||
'fileListItem.tooltip.toggleDiff': 'Toggle diff view',
|
||||
'fileListItem.diff.symlinkChanged': 'Symlink target changed',
|
||||
'fileListItem.diff.loading': 'Loading diff…',
|
||||
'fileListItem.diff.clickToLoad': 'Click Diff to load…',
|
||||
'fileListItem.diff.binaryChanged': 'Binary file changed',
|
||||
|
||||
'diffPanel.remote': 'Remote',
|
||||
'diffPanel.local': 'Local',
|
||||
|
||||
'syncConflictModal.title': 'Conflict in {fileName}',
|
||||
'syncConflictModal.description': 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
'syncConflictModal.tab.diff': 'Diff',
|
||||
'syncConflictModal.tab.local': 'Local',
|
||||
'syncConflictModal.tab.remote': 'Remote',
|
||||
'syncConflictModal.localVersion': 'Local version',
|
||||
'syncConflictModal.remoteVersion': 'Remote version',
|
||||
'syncConflictModal.differences': 'Differences',
|
||||
'syncConflictModal.keepLocal': 'Keep local',
|
||||
'syncConflictModal.keepLocal.tooltip': 'Overwrite remote with your local content',
|
||||
'syncConflictModal.keepRemote': 'Keep remote',
|
||||
'syncConflictModal.keepRemote.tooltip': 'Overwrite local with remote content',
|
||||
'syncConflictModal.cancel': 'Cancel',
|
||||
};
|
||||
|
||||
export default en;
|
||||
export type TranslationKey = keyof typeof en;
|
||||
190
src/i18n/locales/zh-tw.ts
Normal file
190
src/i18n/locales/zh-tw.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import type { TranslationKey } from './en';
|
||||
|
||||
// Only needs to cover the keys that have a translation; anything missing
|
||||
// falls back to en.ts at lookup time.
|
||||
const zhTw: Partial<Record<TranslationKey, string>> = {
|
||||
'settings.connectionStatus.checking': '檢查中…',
|
||||
'settings.connectionStatus.connected': '已連線',
|
||||
'settings.connectionStatus.disconnected': '未連線',
|
||||
'settings.connectionStatus.withDetail': '{label} — {detail}',
|
||||
|
||||
'settings.gitService.name': 'Git 服務',
|
||||
'settings.gitService.desc': '選擇您的 Git 代管服務',
|
||||
|
||||
'settings.branch.name': '分支',
|
||||
'settings.branch.desc': '要推送或拉取的分支',
|
||||
'settings.branch.placeholder': 'Main',
|
||||
|
||||
'settings.rootPath.name': '根路徑',
|
||||
'settings.rootPath.desc': '選填:儲存庫中的子資料夾(例如「notes」)',
|
||||
'settings.rootPath.placeholder': '輸入子資料夾路徑',
|
||||
|
||||
'settings.vaultFolder.name': '保存庫資料夾',
|
||||
'settings.vaultFolder.desc': '選填:僅同步此保存庫資料夾內的檔案(例如「sync」則只同步 sync 資料夾內的檔案)',
|
||||
'settings.vaultFolder.placeholder': '留空以同步所有檔案',
|
||||
|
||||
'settings.ignorePatterns.name': '忽略規則',
|
||||
'settings.ignorePatterns.desc': '選填:以 .gitignore 語法(每行一條)排除本機檔案,會與遠端儲存庫的 .gitignore 規則一併套用。',
|
||||
|
||||
'settings.symlinks.name': '符號連結',
|
||||
'settings.symlinks.desc.supported': '符號連結的同步方式:「real」會在桌面版重建連結,行動裝置則改為同步目標內容;「follow」永遠同步目標內容;「skip」則忽略符號連結。',
|
||||
'settings.symlinks.desc.unsupported': '符號連結的同步方式:「follow」以一般檔案同步目標內容,「skip」則忽略符號連結。真實符號連結僅支援 GitHub。',
|
||||
'settings.symlinks.option.real': '真實符號連結(建議)',
|
||||
'settings.symlinks.option.follow': 'Follow(同步目標內容)',
|
||||
'settings.symlinks.option.skip': 'Skip(忽略)',
|
||||
|
||||
'settings.testConnection.name': '測試連線',
|
||||
'settings.testConnection.desc': '驗證您的 {service} 設定',
|
||||
'settings.testConnection.button': '測試連線',
|
||||
'settings.testConnection.failed': '連線失敗:{reason}',
|
||||
'settings.testConnection.failed.unreachable': '無法連線至儲存庫',
|
||||
'settings.testConnection.branchNotFound.badge': '找不到分支「{branch}」',
|
||||
'settings.testConnection.branchNotFound.notice': '已連線,但找不到分支「{branch}」。請檢查分支設定,或確認儲存庫中存在此分支。',
|
||||
'settings.testConnection.success': '{service} 連線成功!',
|
||||
|
||||
'settings.token.placeholder': '輸入您的權杖',
|
||||
'settings.token.show': '顯示權杖',
|
||||
'settings.token.hide': '隱藏權杖',
|
||||
|
||||
'settings.gitlab.token.name': 'GitLab 個人存取權杖',
|
||||
'settings.gitlab.token.desc': '請在 GitLab 使用者設定 > access tokens 建立具有「API」範圍的權杖',
|
||||
'settings.gitlab.baseUrl.name': 'GitLab 基礎 URL',
|
||||
'settings.gitlab.baseUrl.desc': '預設為 https://gitlab.com',
|
||||
'settings.gitlab.projectId.name': '專案 ID',
|
||||
'settings.gitlab.projectId.desc': '可在 GitLab 專案總覽中找到',
|
||||
'settings.gitlab.projectId.placeholder': '輸入數字專案 ID',
|
||||
|
||||
'settings.gitea.token.name': 'Gitea 個人存取權杖',
|
||||
'settings.gitea.token.desc': '請在使用者設定 > applications > access tokens 建立權杖',
|
||||
'settings.gitea.baseUrl.name': 'Gitea 基礎 URL',
|
||||
'settings.gitea.baseUrl.desc': '您的 Gitea 執行個體網址(例如 https://gitea.example.com)',
|
||||
|
||||
'settings.github.token.name': 'GitHub 個人存取權杖',
|
||||
'settings.github.token.desc': '請在 GitHub 設定 > developer settings > personal access tokens 建立具有「repo」範圍的權杖',
|
||||
|
||||
'settings.repoOwner.name': '儲存庫擁有者',
|
||||
'settings.repoOwner.desc.gitea': 'Gitea 使用者名稱或組織名稱',
|
||||
'settings.repoOwner.desc.github': 'GitHub 使用者名稱或組織名稱',
|
||||
'settings.repoOwner.placeholder': '使用者名稱',
|
||||
|
||||
'settings.repoName.name': '儲存庫名稱',
|
||||
'settings.repoName.desc.gitea': '儲存庫的名稱',
|
||||
'settings.repoName.desc.github': 'GitHub 儲存庫的名稱',
|
||||
'settings.repoName.placeholder': '我的筆記',
|
||||
|
||||
'main.ribbon.openSyncStatus': '開啟同步狀態',
|
||||
'main.ribbon.push': '推送',
|
||||
'main.ribbon.pushTo': '推送至 {service}',
|
||||
'main.command.openSyncStatus': '開啟同步狀態',
|
||||
'main.command.pushCurrentFile': '推送目前檔案',
|
||||
'main.command.pullCurrentFile': '拉取目前檔案',
|
||||
'main.command.pushAllFiles': '推送所有檔案',
|
||||
'main.command.pullAllFiles': '拉取所有檔案',
|
||||
'main.notice.noActiveNote': '沒有可推送的目前筆記',
|
||||
'main.contextMenu.pushTo': '推送至 {service}',
|
||||
'main.contextMenu.pullFrom': '從 {service} 拉取',
|
||||
'main.notice.noFilesToRun': '設定的保存庫資料夾中沒有可{op}的檔案',
|
||||
'main.confirm.pushAll': '要推送 {count} 個檔案至 {service} 嗎?',
|
||||
'main.confirm.pullAll': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。',
|
||||
'main.progress.running': '{verb} 0/{total} 個檔案...',
|
||||
'main.progress.step': '{verb} {current}/{total}:{fileName}',
|
||||
'main.notice.runFailed': '{verb}失敗:{message}',
|
||||
'main.op.push': '推送',
|
||||
'main.op.pull': '拉取',
|
||||
'main.verb.pushing': '推送中',
|
||||
'main.verb.pulling': '拉取中',
|
||||
'main.verb.push': '推送',
|
||||
'main.verb.pull': '拉取',
|
||||
|
||||
'confirmModal.title': '確認',
|
||||
'confirmModal.confirm': '確認',
|
||||
'confirmModal.cancel': '取消',
|
||||
|
||||
'whatsNew.title': '有什麼新功能',
|
||||
'whatsNew.viewChangelog': '查看完整更新日誌',
|
||||
'whatsNew.gotIt': '知道了',
|
||||
|
||||
'syncStatus.viewTitle': '同步狀態',
|
||||
'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態',
|
||||
'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)',
|
||||
'syncStatus.progress.checking': '檢查檔案中…',
|
||||
'syncStatus.lastSync': '上次同步:{time}',
|
||||
'syncStatus.tab.all': '全部',
|
||||
'syncStatus.tab.synced': '已同步',
|
||||
'syncStatus.tab.modified': '有變更',
|
||||
'syncStatus.tab.unsynced': '僅本機',
|
||||
'syncStatus.tab.remote-only': '遠端',
|
||||
'syncStatus.noFilesForFilter': '沒有{filter}的檔案',
|
||||
'syncStatus.confirmDeleteLocal': '刪除本機檔案「{path}」?將依您保存庫的「已刪除的檔案」設定處理。',
|
||||
'syncStatus.notice.deleted': '已刪除 {path}',
|
||||
'syncStatus.notice.deleteFailed': '刪除失敗:{message}',
|
||||
'syncStatus.notice.opFailed': '{verb}失敗:{message}',
|
||||
'syncStatus.notice.alreadyRefreshing': '正在重新整理中…',
|
||||
'syncStatus.notice.refreshed': '已檢查 {local} 個本機檔案與 {remote} 個遠端檔案',
|
||||
'syncStatus.notice.refreshFailed': '重新整理失敗:{message}',
|
||||
'syncStatus.notice.noPushableFiles.selected': '未選取任何可推送的檔案。',
|
||||
'syncStatus.notice.noPushableFiles.found': '沒有可推送的檔案。',
|
||||
'syncStatus.notice.noPullableFiles.selected': '未選取任何可拉取的檔案。',
|
||||
'syncStatus.notice.noPullableFiles.found': '沒有可拉取的檔案。',
|
||||
'syncStatus.confirm.pushSelected': '要推送 {count} 個檔案至 {service} 嗎?',
|
||||
'syncStatus.confirm.pullSelected': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。',
|
||||
'syncStatus.notice.opCompleted': '{verb}完成,重新整理中…',
|
||||
'syncStatus.notice.nothingToDelete': '沒有可刪除的項目',
|
||||
'syncStatus.notice.noFilesSelected': '尚未選取任何檔案',
|
||||
'syncStatus.confirmDelete.localAndRemote': '要刪除 {local} 個本機檔案(依保存庫的垃圾桶設定處理)與 {remote} 個遠端檔案(無法復原)嗎?',
|
||||
'syncStatus.confirmDelete.localOnly': '要刪除 {local} 個本機檔案嗎?將依您保存庫的「已刪除的檔案」設定處理。',
|
||||
'syncStatus.confirmDelete.remoteOnly': '要刪除 {remote} 個遠端檔案嗎?此操作無法復原。',
|
||||
'syncStatus.notice.deleteResult.partial': '已刪除 {succeeded}/{total} 個,{failed} 個失敗。',
|
||||
'syncStatus.notice.deleteResult.partialWithMessage': '已刪除 {succeeded}/{total} 個,{failed} 個失敗:{message}',
|
||||
'syncStatus.notice.deleteResult.success': '已刪除 {total} 個檔案',
|
||||
'syncStatus.progress.deleting': '刪除中 0/{total} 個檔案…',
|
||||
'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.deletingLocal': '刪除本機 {current}/{total}:{path}',
|
||||
'syncStatus.progress.deletingRemote': '刪除遠端 {current}/{total}:{path}',
|
||||
|
||||
'actionBar.select': '選取',
|
||||
'actionBar.refresh': ' 重新整理',
|
||||
'actionBar.refreshAll': '重新整理所有狀態',
|
||||
'actionBar.pushCount': ' 推送({count})',
|
||||
'actionBar.pushFiles': '推送 {count} 個檔案',
|
||||
'actionBar.pullCount': ' 拉取({count})',
|
||||
'actionBar.pullFiles': '拉取 {count} 個檔案',
|
||||
'actionBar.deleteCount': ' 刪除({count})',
|
||||
'actionBar.deleteFiles': '刪除 {count} 個檔案',
|
||||
|
||||
'syncStatus.status.checking': '檢查中',
|
||||
|
||||
'fileListItem.action.push': ' 推送',
|
||||
'fileListItem.action.pull': ' 拉取',
|
||||
'fileListItem.action.remove': ' 移除',
|
||||
'fileListItem.action.diff': ' 差異',
|
||||
'fileListItem.action.hide': ' 隱藏',
|
||||
'fileListItem.tooltip.pushToRemote': '推送至遠端',
|
||||
'fileListItem.tooltip.pullFromRemote': '從遠端拉取',
|
||||
'fileListItem.tooltip.deleteLocalFile': '刪除本機檔案',
|
||||
'fileListItem.tooltip.toggleDiff': '切換差異檢視',
|
||||
'fileListItem.diff.symlinkChanged': '符號連結目標已變更',
|
||||
'fileListItem.diff.loading': '載入差異中…',
|
||||
'fileListItem.diff.clickToLoad': '點擊「差異」以載入…',
|
||||
'fileListItem.diff.binaryChanged': '二進位檔案已變更',
|
||||
|
||||
'diffPanel.remote': '遠端',
|
||||
'diffPanel.local': '本機',
|
||||
|
||||
'syncConflictModal.title': '{fileName} 發生衝突',
|
||||
'syncConflictModal.description': '遠端檔案內容有所不同。請檢視差異並選擇要保留的版本。',
|
||||
'syncConflictModal.tab.diff': '差異',
|
||||
'syncConflictModal.tab.local': '本機',
|
||||
'syncConflictModal.tab.remote': '遠端',
|
||||
'syncConflictModal.localVersion': '本機版本',
|
||||
'syncConflictModal.remoteVersion': '遠端版本',
|
||||
'syncConflictModal.differences': '差異',
|
||||
'syncConflictModal.keepLocal': '保留本機',
|
||||
'syncConflictModal.keepLocal.tooltip': '以本機內容覆蓋遠端',
|
||||
'syncConflictModal.keepRemote': '保留遠端',
|
||||
'syncConflictModal.keepRemote.tooltip': '以遠端內容覆蓋本機',
|
||||
'syncConflictModal.cancel': '取消',
|
||||
};
|
||||
|
||||
export default zhTw;
|
||||
37
src/main.ts
37
src/main.ts
|
|
@ -12,6 +12,7 @@ import { ConfirmModal } from './ui/ConfirmModal';
|
|||
import { WhatsNewModal } from './ui/WhatsNewModal';
|
||||
import { CHANGELOG, getUnseenReleases } from './changelog';
|
||||
import { compareVersions } from './utils/version';
|
||||
import { t } from './i18n';
|
||||
|
||||
export default class GitLabFilesPush extends Plugin {
|
||||
settings: GitLabFilesPushSettings;
|
||||
|
|
@ -29,13 +30,13 @@ export default class GitLabFilesPush extends Plugin {
|
|||
(leaf) => new SyncStatusView(leaf, this)
|
||||
);
|
||||
|
||||
this.addRibbonIcon('git-compare', 'Open sync status', async () => {
|
||||
this.addRibbonIcon('git-compare', t('main.ribbon.openSyncStatus'), async () => {
|
||||
await this.activateSyncStatusView();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-sync-status',
|
||||
name: 'Open sync status',
|
||||
name: t('main.command.openSyncStatus'),
|
||||
callback: async () => {
|
||||
await this.activateSyncStatusView();
|
||||
}
|
||||
|
|
@ -50,7 +51,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
if (activeView && activeView.file instanceof TFile) {
|
||||
await this.sync.pushFile(activeView.file);
|
||||
} else {
|
||||
new Notice('No active note to push');
|
||||
new Notice(t('main.notice.noActiveNote'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
// leave a stale name in the Command Palette until Obsidian reloads.
|
||||
this.addCommand({
|
||||
id: 'push-current-file',
|
||||
name: 'Push current file',
|
||||
name: t('main.command.pushCurrentFile'),
|
||||
callback: async () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -71,7 +72,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-current-file',
|
||||
name: 'Pull current file',
|
||||
name: t('main.command.pullCurrentFile'),
|
||||
callback: async () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -82,7 +83,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'push-all-files',
|
||||
name: 'Push all files',
|
||||
name: t('main.command.pushAllFiles'),
|
||||
callback: async () => {
|
||||
await this.pushAllFiles();
|
||||
}
|
||||
|
|
@ -90,7 +91,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-all-files',
|
||||
name: 'Pull all files',
|
||||
name: t('main.command.pullAllFiles'),
|
||||
callback: async () => {
|
||||
await this.pullAllFiles();
|
||||
}
|
||||
|
|
@ -100,12 +101,12 @@ export default class GitLabFilesPush extends Plugin {
|
|||
this.app.workspace.on('file-menu', (menu, file) => {
|
||||
if (file instanceof TFile) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Push to ${this.serviceName}`)
|
||||
item.setTitle(t('main.contextMenu.pushTo', { service: this.serviceName }))
|
||||
.setIcon('upload-cloud')
|
||||
.onClick(async () => { await this.sync.pushFile(file); });
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Pull from ${this.serviceName}`)
|
||||
item.setTitle(t('main.contextMenu.pullFrom', { service: this.serviceName }))
|
||||
.setIcon('download-cloud')
|
||||
.onClick(async () => { await this.sync.pullFile(file); });
|
||||
});
|
||||
|
|
@ -144,7 +145,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
}
|
||||
|
||||
private pushRibbonLabel(): string {
|
||||
return Platform.isMobile ? 'Push' : `Push to ${this.serviceName}`;
|
||||
return Platform.isMobile ? t('main.ribbon.push') : t('main.ribbon.pushTo', { service: this.serviceName });
|
||||
}
|
||||
|
||||
// The ribbon icon's tooltip is set once when addRibbonIcon runs, so it goes
|
||||
|
|
@ -205,26 +206,27 @@ export default class GitLabFilesPush extends Plugin {
|
|||
const files = allPaths.filter(p => !this.gitignoreManager.isIgnored(this.getNormalizedPath(p)));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice(`No files to ${op} in the configured vault folder`);
|
||||
new Notice(t('main.notice.noFilesToRun', { op: op === 'push' ? t('main.op.push') : t('main.op.pull') }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = op === 'push'
|
||||
? `Push ${files.length} file(s) to ${this.serviceName}?`
|
||||
: `Pull ${files.length} file(s) from ${this.serviceName}? This will overwrite local changes.`;
|
||||
? t('main.confirm.pushAll', { count: files.length, service: this.serviceName })
|
||||
: t('main.confirm.pullAll', { count: files.length, service: this.serviceName });
|
||||
|
||||
const confirmed = await this.showConfirmDialog(msg);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files...`, 0);
|
||||
const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling');
|
||||
const progressNotice = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0);
|
||||
|
||||
try {
|
||||
const results = op === 'push'
|
||||
? await this.sync.pushAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`);
|
||||
progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pushing'), current, total, fileName }));
|
||||
})
|
||||
: await this.sync.pullAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`);
|
||||
progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pulling'), current, total, fileName }));
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
|
@ -235,7 +237,8 @@ export default class GitLabFilesPush extends Plugin {
|
|||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
logger.error(String(e));
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('main.notice.runFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
122
src/settings.ts
122
src/settings.ts
|
|
@ -3,6 +3,7 @@ import GitLabFilesPush from "./main";
|
|||
import {FolderSuggest} from "./ui/FolderSuggest";
|
||||
import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest";
|
||||
import { ConnectionTestResult } from "./services/git-service-base";
|
||||
import { t } from "./i18n";
|
||||
|
||||
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
||||
// the plugin still type-checks against older Obsidian typings (minAppVersion
|
||||
|
|
@ -155,12 +156,12 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
badge.addClass(`is-${state}`);
|
||||
|
||||
const labels: Record<ConnectionStatusState, string> = {
|
||||
checking: 'Checking…',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Not connected'
|
||||
checking: t('settings.connectionStatus.checking'),
|
||||
connected: t('settings.connectionStatus.connected'),
|
||||
disconnected: t('settings.connectionStatus.disconnected')
|
||||
};
|
||||
const label = labels[state];
|
||||
badge.setText(detail ? `${label} — ${detail}` : label);
|
||||
badge.setText(detail ? t('settings.connectionStatus.withDetail', { label, detail }) : label);
|
||||
}
|
||||
|
||||
// Debounced so token/branch fields (which call this on every keystroke)
|
||||
|
|
@ -184,9 +185,9 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
if (seq !== this.connectionTestSeq) return result;
|
||||
|
||||
if (!result.repoOk) {
|
||||
this.setStatusBadge('disconnected', result.error ?? 'could not reach the repository');
|
||||
this.setStatusBadge('disconnected', result.error ?? t('settings.testConnection.failed.unreachable'));
|
||||
} else if (!result.branchOk) {
|
||||
this.setStatusBadge('disconnected', `branch "${this.plugin.settings.branch}" not found`);
|
||||
this.setStatusBadge('disconnected', t('settings.testConnection.branchNotFound.badge', { branch: this.plugin.settings.branch }));
|
||||
} else {
|
||||
this.setStatusBadge('connected');
|
||||
}
|
||||
|
|
@ -206,8 +207,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.renderConnectionStatus(containerEl);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Git service')
|
||||
.setDesc('Choose your Git hosting service')
|
||||
.setName(t('settings.gitService.name'))
|
||||
.setDesc(t('settings.gitService.desc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('gitlab', 'GitLab')
|
||||
.addOption('github', 'GitHub')
|
||||
|
|
@ -231,10 +232,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Branch')
|
||||
.setDesc('Branch to push or pull from')
|
||||
.setName(t('settings.branch.name'))
|
||||
.setDesc(t('settings.branch.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Main')
|
||||
.setPlaceholder(t('settings.branch.placeholder'))
|
||||
.setValue(this.plugin.settings.branch)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.branch = value || 'main';
|
||||
|
|
@ -243,10 +244,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Root path')
|
||||
.setDesc('Optional: subfolder in repository (e.g. "notes")')
|
||||
.setName(t('settings.rootPath.name'))
|
||||
.setDesc(t('settings.rootPath.desc'))
|
||||
.addText(text => {
|
||||
text.setPlaceholder('Enter subfolder path')
|
||||
text.setPlaceholder(t('settings.rootPath.placeholder'))
|
||||
.setValue(this.plugin.settings.rootPath)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
|
||||
|
|
@ -257,10 +258,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Vault folder')
|
||||
.setDesc('Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)')
|
||||
.setName(t('settings.vaultFolder.name'))
|
||||
.setDesc(t('settings.vaultFolder.desc'))
|
||||
.addText(text => {
|
||||
text.setPlaceholder('Leave empty to sync all files')
|
||||
text.setPlaceholder(t('settings.vaultFolder.placeholder'))
|
||||
.setValue(this.plugin.settings.vaultFolder)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, '');
|
||||
|
|
@ -270,8 +271,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ignore patterns')
|
||||
.setDesc('Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.')
|
||||
.setName(t('settings.ignorePatterns.name'))
|
||||
.setDesc(t('settings.ignorePatterns.desc'))
|
||||
.addTextArea(text => {
|
||||
text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`)
|
||||
.setValue(this.plugin.settings.ignorePatterns)
|
||||
|
|
@ -286,15 +287,15 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
// other providers, offer follow/skip only so the option can't mislead.
|
||||
const supportsRealSymlink = this.plugin.settings.serviceType === 'github';
|
||||
new Setting(containerEl)
|
||||
.setName('Symbolic links')
|
||||
.setName(t('settings.symlinks.name'))
|
||||
.setDesc(supportsRealSymlink
|
||||
? 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.'
|
||||
: 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.')
|
||||
? t('settings.symlinks.desc.supported')
|
||||
: t('settings.symlinks.desc.unsupported'))
|
||||
.addDropdown(dropdown => {
|
||||
if (supportsRealSymlink) dropdown.addOption('real', 'Real symlink (recommended)');
|
||||
if (supportsRealSymlink) dropdown.addOption('real', t('settings.symlinks.option.real'));
|
||||
dropdown
|
||||
.addOption('follow', 'Follow (sync target content)')
|
||||
.addOption('skip', 'Skip')
|
||||
.addOption('follow', t('settings.symlinks.option.follow'))
|
||||
.addOption('skip', t('settings.symlinks.option.skip'))
|
||||
.setValue(getEffectiveSymlinkHandling(this.plugin.settings))
|
||||
.onChange((value: string) => {
|
||||
this.plugin.settings.symlinkHandling = value as SymlinkHandling;
|
||||
|
|
@ -303,27 +304,26 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Test connection')
|
||||
.setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`)
|
||||
.setName(t('settings.testConnection.name'))
|
||||
.setDesc(t('settings.testConnection.desc', { service: getServiceName(this.plugin.settings) }))
|
||||
.addButton(button => button
|
||||
.setButtonText('Test connection')
|
||||
.setButtonText(t('settings.testConnection.button'))
|
||||
.onClick(async () => {
|
||||
try {
|
||||
const result = await this.testConnectionSilently();
|
||||
if (!result.repoOk) {
|
||||
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
|
||||
new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') }));
|
||||
} else if (!result.branchOk) {
|
||||
new Notice(
|
||||
`Connected, but branch "${this.plugin.settings.branch}" was not found. ` +
|
||||
'Check the Branch setting, or confirm the repository has a branch with this name.',
|
||||
t('settings.testConnection.branchNotFound.notice', { branch: this.plugin.settings.branch }),
|
||||
8000
|
||||
);
|
||||
} else {
|
||||
new Notice(`${getServiceName(this.plugin.settings)} connection successful!`);
|
||||
new Notice(t('settings.testConnection.success', { service: getServiceName(this.plugin.settings) }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
new Notice(`Connection failed: ${message}`);
|
||||
new Notice(t('settings.testConnection.failed', { reason: message }));
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
@ -341,18 +341,18 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.addText(text => {
|
||||
textComponent = text;
|
||||
text.inputEl.type = 'password';
|
||||
text.setPlaceholder('Enter your token')
|
||||
text.setPlaceholder(t('settings.token.placeholder'))
|
||||
.setValue(getValue())
|
||||
.onChange(onChange);
|
||||
})
|
||||
.addExtraButton(btn => {
|
||||
btn.setIcon('eye')
|
||||
.setTooltip('Show token')
|
||||
.setTooltip(t('settings.token.show'))
|
||||
.onClick(() => {
|
||||
const revealing = textComponent.inputEl.type === 'password';
|
||||
textComponent.inputEl.type = revealing ? 'text' : 'password';
|
||||
btn.setIcon(revealing ? 'eye-off' : 'eye');
|
||||
btn.setTooltip(revealing ? 'Hide token' : 'Show token');
|
||||
btn.setTooltip(revealing ? t('settings.token.hide') : t('settings.token.show'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -360,8 +360,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
private displayGitLabSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'GitLab personal access token',
|
||||
'Create a token in GitLab user settings > access tokens with "API" scope',
|
||||
t('settings.gitlab.token.name'),
|
||||
t('settings.gitlab.token.desc'),
|
||||
() => this.plugin.settings.gitlabToken,
|
||||
(value) => {
|
||||
this.plugin.settings.gitlabToken = value;
|
||||
|
|
@ -372,8 +372,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab base URL')
|
||||
.setDesc('Defaults to https://gitlab.com')
|
||||
.setName(t('settings.gitlab.baseUrl.name'))
|
||||
.setDesc(t('settings.gitlab.baseUrl.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitlab.com')
|
||||
.setValue(this.plugin.settings.gitlabBaseUrl)
|
||||
|
|
@ -385,10 +385,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Project ID')
|
||||
.setDesc('Found in GitLab project overview')
|
||||
.setName(t('settings.gitlab.projectId.name'))
|
||||
.setDesc(t('settings.gitlab.projectId.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter numeric project ID')
|
||||
.setPlaceholder(t('settings.gitlab.projectId.placeholder'))
|
||||
.setValue(this.plugin.settings.projectId)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.projectId = value;
|
||||
|
|
@ -401,8 +401,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
private displayGiteaSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'Gitea personal access token',
|
||||
'Create a token in user settings > applications > access tokens',
|
||||
t('settings.gitea.token.name'),
|
||||
t('settings.gitea.token.desc'),
|
||||
() => this.plugin.settings.giteaToken,
|
||||
(value) => {
|
||||
this.plugin.settings.giteaToken = value;
|
||||
|
|
@ -413,8 +413,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Gitea base URL')
|
||||
.setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)')
|
||||
.setName(t('settings.gitea.baseUrl.name'))
|
||||
.setDesc(t('settings.gitea.baseUrl.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitea.example.com')
|
||||
.setValue(this.plugin.settings.giteaBaseUrl)
|
||||
|
|
@ -426,10 +426,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository owner')
|
||||
.setDesc('Gitea username or organization name')
|
||||
.setName(t('settings.repoOwner.name'))
|
||||
.setDesc(t('settings.repoOwner.desc.gitea'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Username')
|
||||
.setPlaceholder(t('settings.repoOwner.placeholder'))
|
||||
.setValue(this.plugin.settings.giteaOwner)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.giteaOwner = value;
|
||||
|
|
@ -439,10 +439,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository name')
|
||||
.setDesc('Name of the repository')
|
||||
.setName(t('settings.repoName.name'))
|
||||
.setDesc(t('settings.repoName.desc.gitea'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('My notes')
|
||||
.setPlaceholder(t('settings.repoName.placeholder'))
|
||||
.setValue(this.plugin.settings.giteaRepo)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.giteaRepo = value;
|
||||
|
|
@ -455,8 +455,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
private displayGitHubSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'GitHub personal access token',
|
||||
'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope',
|
||||
t('settings.github.token.name'),
|
||||
t('settings.github.token.desc'),
|
||||
() => this.plugin.settings.githubToken,
|
||||
(value) => {
|
||||
this.plugin.settings.githubToken = value;
|
||||
|
|
@ -467,10 +467,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository owner')
|
||||
.setDesc('GitHub username or organization name')
|
||||
.setName(t('settings.repoOwner.name'))
|
||||
.setDesc(t('settings.repoOwner.desc.github'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Username')
|
||||
.setPlaceholder(t('settings.repoOwner.placeholder'))
|
||||
.setValue(this.plugin.settings.githubOwner)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubOwner = value;
|
||||
|
|
@ -480,10 +480,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository name')
|
||||
.setDesc('Name of the GitHub repository')
|
||||
.setName(t('settings.repoName.name'))
|
||||
.setDesc(t('settings.repoName.desc.github'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('My notes')
|
||||
.setPlaceholder(t('settings.repoName.placeholder'))
|
||||
.setValue(this.plugin.settings.githubRepo)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubRepo = value;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private readonly message: string;
|
||||
|
|
@ -14,20 +15,20 @@ export class ConfirmModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h3', { text: 'Confirm' });
|
||||
contentEl.createEl('h3', { text: t('confirmModal.title') });
|
||||
contentEl.createEl('p', { text: this.message });
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Cancel')
|
||||
.setButtonText(t('confirmModal.cancel'))
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
if (this.onCancel) this.onCancel();
|
||||
});
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Confirm')
|
||||
.setButtonText(t('confirmModal.confirm'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
|
||||
type ConflictPanelName = 'diff' | 'local' | 'remote';
|
||||
|
||||
|
|
@ -35,9 +36,9 @@ export class SyncConflictModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.addClass('sync-conflict-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.fileName}` });
|
||||
contentEl.createEl('h2', { text: t('syncConflictModal.title', { fileName: this.fileName }) });
|
||||
contentEl.createEl('p', {
|
||||
text: 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
text: t('syncConflictModal.description'),
|
||||
cls: 'conflict-description'
|
||||
});
|
||||
|
||||
|
|
@ -52,7 +53,11 @@ export class SyncConflictModal extends Modal {
|
|||
};
|
||||
|
||||
const tabsContainer = contentEl.createDiv({ cls: 'conflict-tabs' });
|
||||
const tabLabels: Record<ConflictPanelName, string> = { diff: 'Diff', local: 'Local', remote: 'Remote' };
|
||||
const tabLabels: Record<ConflictPanelName, string> = {
|
||||
diff: t('syncConflictModal.tab.diff'),
|
||||
local: t('syncConflictModal.tab.local'),
|
||||
remote: t('syncConflictModal.tab.remote')
|
||||
};
|
||||
(['diff', 'local', 'remote'] as const).forEach(name => {
|
||||
const tab = tabsContainer.createEl('button', { text: tabLabels[name], cls: 'conflict-tab' });
|
||||
tab.addEventListener('click', () => setActivePanel(name));
|
||||
|
|
@ -64,19 +69,19 @@ export class SyncConflictModal extends Modal {
|
|||
const diffContainer = contentArea.createDiv({ cls: 'conflict-diff-container' });
|
||||
|
||||
const localSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||
localSection.createEl('h3', { text: 'Local version' });
|
||||
localSection.createEl('h3', { text: t('syncConflictModal.localVersion') });
|
||||
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
||||
localPre.createEl('code', { text: this.localContent });
|
||||
panels.local = localSection;
|
||||
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||
remoteSection.createEl('h3', { text: 'Remote version' });
|
||||
remoteSection.createEl('h3', { text: t('syncConflictModal.remoteVersion') });
|
||||
const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' });
|
||||
remotePre.createEl('code', { text: this.remoteContent });
|
||||
panels.remote = remoteSection;
|
||||
|
||||
const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
diffSection.createEl('h3', { text: t('syncConflictModal.differences') });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
this.renderDiff(diffPre);
|
||||
panels.diff = diffSection;
|
||||
|
|
@ -87,22 +92,22 @@ export class SyncConflictModal extends Modal {
|
|||
|
||||
new Setting(buttonContainer)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Keep local')
|
||||
.setTooltip('Overwrite remote with your local content')
|
||||
.setButtonText(t('syncConflictModal.keepLocal'))
|
||||
.setTooltip(t('syncConflictModal.keepLocal.tooltip'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onChoose('local');
|
||||
this.close();
|
||||
}))
|
||||
.addButton(btn => applyDestructiveStyle(btn)
|
||||
.setButtonText('Keep remote')
|
||||
.setTooltip('Overwrite local with remote content')
|
||||
.setButtonText(t('syncConflictModal.keepRemote'))
|
||||
.setTooltip(t('syncConflictModal.keepRemote.tooltip'))
|
||||
.onClick(() => {
|
||||
this.onChoose('remote');
|
||||
this.close();
|
||||
}))
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Cancel')
|
||||
.setButtonText(t('syncConflictModal.cancel'))
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { isBinaryPath, contentsEqual } from '../utils/path';
|
|||
import { readLocalSymlinkTarget } from '../utils/symlink';
|
||||
import { gitBlobSha } from '../utils/git-blob-sha';
|
||||
import { type GitTreeEntry } from '../services/git-service-interface';
|
||||
import { t, type TranslationKey } from '../i18n';
|
||||
|
||||
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
getViewType(): string { return SYNC_STATUS_VIEW_TYPE; }
|
||||
getDisplayText(): string { return 'Sync status'; }
|
||||
getDisplayText(): string { return t('syncStatus.viewTitle'); }
|
||||
getIcon(): string { return 'git-compare'; }
|
||||
|
||||
onOpen(): Promise<void> {
|
||||
|
|
@ -56,7 +57,7 @@ export class SyncStatusView extends ItemView {
|
|||
this.renderProgressBar(listEl);
|
||||
this.renderCheckedFilesDuringRefresh(listEl);
|
||||
} else if (this.fileStatuses.size === 0) {
|
||||
listEl.createDiv({ cls: 'ssv-empty', text: 'Click "Refresh" to check sync status' });
|
||||
listEl.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') });
|
||||
} else {
|
||||
this.renderFileList(listEl);
|
||||
}
|
||||
|
|
@ -68,7 +69,7 @@ export class SyncStatusView extends ItemView {
|
|||
const prog = container.createDiv({ cls: 'ssv-progress' });
|
||||
prog.createDiv({
|
||||
cls: 'ssv-progress-text',
|
||||
text: total > 0 ? `Checking files… ${current}/${total} (${pct}%)` : 'Checking files…'
|
||||
text: total > 0 ? t('syncStatus.progress.checkingWithCount', { current, total, pct }) : t('syncStatus.progress.checking')
|
||||
});
|
||||
const bar = prog.createDiv({ cls: 'ssv-progress-bar' });
|
||||
bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${pct}%`);
|
||||
|
|
@ -114,7 +115,7 @@ export class SyncStatusView extends ItemView {
|
|||
cls: 'ssv-info-time',
|
||||
text: Platform.isMobile
|
||||
? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}`
|
||||
: t('syncStatus.lastSync', { time: new Date(this.lastSyncTime).toLocaleTimeString() })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -132,11 +133,11 @@ export class SyncStatusView extends ItemView {
|
|||
};
|
||||
|
||||
const tabs: Array<{ value: FilterValue; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'synced', label: 'Synced' },
|
||||
{ value: 'modified', label: 'Changed' },
|
||||
{ value: 'unsynced', label: 'Local only' },
|
||||
{ value: 'remote-only', label: 'Remote' },
|
||||
{ value: 'all', label: t('syncStatus.tab.all') },
|
||||
{ value: 'synced', label: t('syncStatus.tab.synced') },
|
||||
{ value: 'modified', label: t('syncStatus.tab.modified') },
|
||||
{ value: 'unsynced', label: t('syncStatus.tab.unsynced') },
|
||||
{ value: 'remote-only', label: t('syncStatus.tab.remote-only') },
|
||||
];
|
||||
|
||||
const tabsEl = container.createDiv({ cls: 'ssv-tabs' });
|
||||
|
|
@ -232,7 +233,8 @@ export class SyncStatusView extends ItemView {
|
|||
: all.filter(s => s.status === this.statusFilter);
|
||||
|
||||
if (statuses.length === 0) {
|
||||
container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` });
|
||||
const filterLabel = this.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.statusFilter).label;
|
||||
container.createDiv({ cls: 'ssv-empty', text: t('syncStatus.noFilesForFilter', { filter: filterLabel }) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +247,7 @@ export class SyncStatusView extends ItemView {
|
|||
// ── Single-file operations ──────────────────────────────────────
|
||||
|
||||
private async handleLocalDelete(fileStatus: FileStatus): Promise<void> {
|
||||
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"? Handled per your vault's "Deleted files" setting.`);
|
||||
const confirmed = await this.showConfirmDialog(t('syncStatus.confirmDeleteLocal', { path: fileStatus.path }));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
if (fileStatus.file) {
|
||||
|
|
@ -254,11 +256,11 @@ export class SyncStatusView extends ItemView {
|
|||
await this.app.vault.adapter.remove(fileStatus.path);
|
||||
}
|
||||
await this.plugin.sync.clearMetadata(fileStatus.path);
|
||||
new Notice(`Deleted ${fileStatus.path}`);
|
||||
new Notice(t('syncStatus.notice.deleted', { path: fileStatus.path }));
|
||||
this.fileStatuses.delete(fileStatus.path);
|
||||
this.renderView();
|
||||
} catch (e) {
|
||||
new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(t('syncStatus.notice.deleteFailed', { message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +279,8 @@ export class SyncStatusView extends ItemView {
|
|||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
} catch (e) {
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const verb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opFailed', { verb, message: e instanceof Error ? e.message : String(e) }));
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
}
|
||||
|
|
@ -287,7 +290,7 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
async refreshAllStatuses(): Promise<void> {
|
||||
if (this.isRefreshing) {
|
||||
new Notice('Already refreshing…');
|
||||
new Notice(t('syncStatus.notice.alreadyRefreshing'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -313,11 +316,11 @@ export class SyncStatusView extends ItemView {
|
|||
this.lastSyncTime = Date.now();
|
||||
this.isRefreshing = false; // Set to false BEFORE final renderView
|
||||
this.renderView();
|
||||
new Notice(`Checked ${files.local.length + files.hiddenLocalPaths.size} local + ${files.remoteMap.size} remote files`);
|
||||
new Notice(t('syncStatus.notice.refreshed', { local: files.local.length + files.hiddenLocalPaths.size, remote: files.remoteMap.size }));
|
||||
} catch (e) {
|
||||
this.isRefreshing = false;
|
||||
this.renderView();
|
||||
new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(t('syncStatus.notice.refreshFailed', { message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -627,6 +630,11 @@ export class SyncStatusView extends ItemView {
|
|||
async pushSelected(): Promise<void> { await this.runBatchOperation('selected', 'push'); }
|
||||
async pullSelected(): Promise<void> { await this.runBatchOperation('selected', 'pull'); }
|
||||
|
||||
private static readonly NO_RUNNABLE_FILES_KEYS: Record<'push' | 'pull', Record<'selected' | 'found', TranslationKey>> = {
|
||||
push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' },
|
||||
pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' },
|
||||
};
|
||||
|
||||
private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise<void> {
|
||||
const targets = Array.from(this.fileStatuses.values()).filter(s => {
|
||||
if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false;
|
||||
|
|
@ -636,32 +644,40 @@ export class SyncStatusView extends ItemView {
|
|||
});
|
||||
|
||||
if (targets.length === 0) {
|
||||
new Notice(`No ${op}able files ${filter === 'selected' ? 'selected' : 'found'}.`);
|
||||
const scope = filter === 'selected' ? 'selected' : 'found';
|
||||
new Notice(t(SyncStatusView.NO_RUNNABLE_FILES_KEYS[op][scope]));
|
||||
return;
|
||||
}
|
||||
|
||||
const files = targets.map(s => s.file || s.path);
|
||||
const serviceName = getServiceName(this.plugin.settings);
|
||||
const msg = op === 'push'
|
||||
? `Push ${files.length} file(s) to ${serviceName}?`
|
||||
: `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`;
|
||||
? t('syncStatus.confirm.pushSelected', { count: files.length, service: serviceName })
|
||||
: t('syncStatus.confirm.pullSelected', { count: files.length, service: serviceName });
|
||||
|
||||
if (!await this.showConfirmDialog(msg)) return;
|
||||
|
||||
const prog = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files…`, 0);
|
||||
await this.executeBatchOperation(filter, op, files);
|
||||
}
|
||||
|
||||
private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array<string | TFile>): Promise<void> {
|
||||
const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling');
|
||||
const prog = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0);
|
||||
try {
|
||||
const results = op === 'push'
|
||||
? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(`Pushing ${cur}/${total}: ${name}`))
|
||||
: await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(`Pulling ${cur}/${total}: ${name}`));
|
||||
? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pushing', { current: cur, total, name })))
|
||||
: await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pulling', { current: cur, total, name })));
|
||||
|
||||
prog.hide();
|
||||
if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors);
|
||||
if (filter === 'selected') this.selectedFiles.clear();
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} completed. Refreshing…`);
|
||||
const doneVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb }));
|
||||
await this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
prog.hide();
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -670,11 +686,11 @@ export class SyncStatusView extends ItemView {
|
|||
if (targets.length === 0) return;
|
||||
|
||||
const { local, remote } = this.partitionTargets(targets);
|
||||
if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; }
|
||||
if (local.length === 0 && remote.length === 0) { new Notice(t('syncStatus.notice.nothingToDelete')); return; }
|
||||
if (!await this.confirmDeletion(local.length, remote.length)) return;
|
||||
|
||||
const total = local.length + remote.length;
|
||||
const prog = new Notice(`Deleting 0/${total} files…`, 0);
|
||||
const prog = new Notice(t('syncStatus.progress.deleting', { total }), 0);
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
|
||||
await this.performLocalDeletion(local, total, prog, errors);
|
||||
|
|
@ -683,15 +699,20 @@ export class SyncStatusView extends ItemView {
|
|||
prog.hide();
|
||||
if (errors.length > 0) {
|
||||
logger.error('Delete errors:', errors);
|
||||
new Notice(`Deleted ${total - errors.length}/${total}. ${errors.length} failed: ${errors.map(e => e.message).join('; ')}`);
|
||||
new Notice(t('syncStatus.notice.deleteResult.partialWithMessage', {
|
||||
succeeded: total - errors.length,
|
||||
total,
|
||||
failed: errors.length,
|
||||
message: errors.map(e => e.message).join('; ')
|
||||
}));
|
||||
} else {
|
||||
new Notice(`Deleted ${total} files`);
|
||||
new Notice(t('syncStatus.notice.deleteResult.success', { total }));
|
||||
}
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
private getSelectedTargets(): FileStatus[] {
|
||||
if (this.selectedFiles.size === 0) { new Notice('No files selected'); return []; }
|
||||
if (this.selectedFiles.size === 0) { new Notice(t('syncStatus.notice.noFilesSelected')); return []; }
|
||||
return Array.from(this.selectedFiles)
|
||||
.map(p => this.fileStatuses.get(p))
|
||||
.filter(Boolean) as FileStatus[];
|
||||
|
|
@ -712,11 +733,11 @@ export class SyncStatusView extends ItemView {
|
|||
// recoverability; remote deletes are unconditionally permanent.
|
||||
let msg = '';
|
||||
if (localCount > 0 && remoteCount > 0) {
|
||||
msg = `Delete ${localCount} local file(s) (per your vault's trash setting) and ${remoteCount} remote file(s) (cannot be undone)?`;
|
||||
msg = t('syncStatus.confirmDelete.localAndRemote', { local: localCount, remote: remoteCount });
|
||||
} else if (localCount > 0) {
|
||||
msg = `Delete ${localCount} local file(s)? They'll be handled per your vault's "Deleted files" setting.`;
|
||||
msg = t('syncStatus.confirmDelete.localOnly', { local: localCount });
|
||||
} else {
|
||||
msg = `Delete ${remoteCount} remote file(s)? This cannot be undone.`;
|
||||
msg = t('syncStatus.confirmDelete.remoteOnly', { remote: remoteCount });
|
||||
}
|
||||
return this.showConfirmDialog(msg);
|
||||
}
|
||||
|
|
@ -725,7 +746,7 @@ export class SyncStatusView extends ItemView {
|
|||
let cur = 0;
|
||||
for (const s of local) {
|
||||
cur++;
|
||||
prog.setMessage(`Deleting local ${cur}/${total}: ${s.path}`);
|
||||
prog.setMessage(t('syncStatus.progress.deletingLocal', { current: cur, total, path: s.path }));
|
||||
try {
|
||||
if (s.file) await this.app.fileManager.trashFile(s.file);
|
||||
else await this.app.vault.adapter.remove(s.path);
|
||||
|
|
@ -742,7 +763,7 @@ export class SyncStatusView extends ItemView {
|
|||
let cur = localCount;
|
||||
for (const s of remote) {
|
||||
cur++;
|
||||
prog.setMessage(`Deleting remote ${cur}/${total}: ${s.path}`);
|
||||
prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: s.path }));
|
||||
try {
|
||||
await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`);
|
||||
this.fileStatuses.delete(s.path);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import { type ChangelogRelease } from '../changelog';
|
||||
import { t } from '../i18n';
|
||||
|
||||
const CHANGELOG_URL = 'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md';
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ export class WhatsNewModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h3', { text: "What's new" });
|
||||
contentEl.createEl('h3', { text: t('whatsNew.title') });
|
||||
|
||||
for (const release of this.releases) {
|
||||
contentEl.createEl('h4', { text: `v${release.version}` });
|
||||
|
|
@ -27,11 +28,11 @@ export class WhatsNewModal extends Modal {
|
|||
const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('View full changelog')
|
||||
.setButtonText(t('whatsNew.viewChangelog'))
|
||||
.onClick(() => window.open(CHANGELOG_URL, '_blank', 'noopener'));
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Got it')
|
||||
.setButtonText(t('whatsNew.gotIt'))
|
||||
.setCta()
|
||||
.onClick(() => this.close());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { setIcon, setTooltip } from 'obsidian';
|
||||
import { ICONS } from './icons';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export interface ActionBarProps {
|
||||
hasFiles: boolean;
|
||||
|
|
@ -25,17 +26,17 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c
|
|||
if (props.hasFiles) {
|
||||
bar.createDiv({ cls: 'ssv-bar-spacer' });
|
||||
renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll);
|
||||
renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
|
||||
renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
|
||||
renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
|
||||
renderLargeButton(bar, ICONS.push, t('actionBar.pushCount', { count: props.canPush }), t('actionBar.pushFiles', { count: props.canPush }), callbacks.onPush, 'push', props.canPush === 0);
|
||||
renderLargeButton(bar, ICONS.pull, t('actionBar.pullCount', { count: props.canPull }), t('actionBar.pullFiles', { count: props.canPull }), callbacks.onPull, 'pull', props.canPull === 0);
|
||||
renderLargeButton(bar, ICONS.delete, t('actionBar.deleteCount', { count: props.canDelete }), t('actionBar.deleteFiles', { count: props.canDelete }), callbacks.onDelete, 'danger', props.canDelete === 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void {
|
||||
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
|
||||
setIcon(btn.createSpan(), ICONS.refresh);
|
||||
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
|
||||
setTooltip(btn, 'Refresh all statuses');
|
||||
btn.createSpan({ cls: 'ssv-btn-label', text: t('actionBar.refresh') });
|
||||
setTooltip(btn, t('actionBar.refreshAll'));
|
||||
btn.addEventListener('click', onRefresh);
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat
|
|||
const cb = selectRow.createEl('input', { type: 'checkbox' });
|
||||
cb.checked = allSelected;
|
||||
cb.indeterminate = indeterminate;
|
||||
selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' });
|
||||
selectRow.createSpan({ cls: 'ssv-select-label', text: t('actionBar.select') });
|
||||
cb.addEventListener('change', () => onSelectAll(cb.checked));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { computeSideBySideDiff, type DiffSide } from '../../utils/diff';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
/** Renders the side-by-side + unified diff body into an existing container. */
|
||||
export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void {
|
||||
const rows = computeSideBySideDiff(remoteContent, localContent);
|
||||
|
||||
const grid = container.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.remote') });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.local') });
|
||||
for (const row of rows) {
|
||||
renderDiffCell(grid, row.left);
|
||||
renderDiffCell(grid, row.right);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { setIcon, setTooltip } from 'obsidian';
|
|||
import { type FileStatus } from '../types';
|
||||
import { renderDiffPanel } from './DiffPanel';
|
||||
import { ICONS } from './icons';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export interface FileItemCallbacks {
|
||||
onSelect: (path: string, selected: boolean) => void;
|
||||
|
|
@ -21,11 +22,11 @@ export interface FileItemCallbacks {
|
|||
// uses the same icon set and renders consistently across platforms.
|
||||
export function statusMeta(status: FileStatus['status']) {
|
||||
switch (status) {
|
||||
case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
|
||||
case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
|
||||
case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
|
||||
case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
|
||||
default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
|
||||
case 'synced': return { icon: ICONS.synced, label: t('syncStatus.tab.synced'), iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
|
||||
case 'modified': return { icon: ICONS.modified, label: t('syncStatus.tab.modified'), iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
|
||||
case 'unsynced': return { icon: ICONS.push, label: t('syncStatus.tab.unsynced'), iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
|
||||
case 'remote-only': return { icon: ICONS.pull, label: t('syncStatus.tab.remote-only'), iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
|
||||
default: return { icon: ICONS.checking, label: t('syncStatus.status.checking'), iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,15 +61,15 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback
|
|||
}
|
||||
|
||||
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
|
||||
renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
|
||||
renderActionBtn(actions, ICONS.push, t('fileListItem.action.push'), t('fileListItem.tooltip.pushToRemote'), () => callbacks.onPush(fileStatus), 'push');
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
|
||||
renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
|
||||
renderActionBtn(actions, ICONS.pull, t('fileListItem.action.pull'), t('fileListItem.tooltip.pullFromRemote'), () => callbacks.onPull(fileStatus), 'pull');
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'unsynced') {
|
||||
renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
|
||||
renderActionBtn(actions, ICONS.delete, t('fileListItem.action.remove'), t('fileListItem.tooltip.deleteLocalFile'), () => callbacks.onDelete(fileStatus), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,21 +77,21 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS
|
|||
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
|
||||
const iconEl = diffBtn.createSpan();
|
||||
setIcon(iconEl, ICONS.diff);
|
||||
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
|
||||
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: t('fileListItem.action.diff') });
|
||||
|
||||
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
renderDiffBody(diffEl, fileStatus);
|
||||
|
||||
setTooltip(diffBtn, 'Toggle diff view');
|
||||
setTooltip(diffBtn, t('fileListItem.tooltip.toggleDiff'));
|
||||
diffBtn.addEventListener('click', () => {
|
||||
const open = diffEl.hasClass('visible');
|
||||
if (!open && needsContentFetch(fileStatus)) {
|
||||
diffEl.empty();
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Loading diff…' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.loading') });
|
||||
void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus));
|
||||
}
|
||||
diffEl.toggleClass('visible', !open);
|
||||
btnLabel.setText(open ? ' Diff' : ' Hide');
|
||||
btnLabel.setText(open ? t('fileListItem.action.diff') : t('fileListItem.action.hide'));
|
||||
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
|
||||
});
|
||||
}
|
||||
|
|
@ -102,13 +103,13 @@ function needsContentFetch(fileStatus: FileStatus): boolean {
|
|||
function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void {
|
||||
diffEl.empty();
|
||||
if (fileStatus.isSymlink) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Symlink target changed' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') });
|
||||
} else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') {
|
||||
renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent);
|
||||
} else if (fileStatus.remoteContent === undefined) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Click Diff to load…' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.clickToLoad') });
|
||||
} else {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.binaryChanged') });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
54
tests/i18n/index.test.ts
Normal file
54
tests/i18n/index.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { t, getActiveLocale } from '../../src/i18n';
|
||||
|
||||
type MomentGlobal = { moment?: { locale: () => string } };
|
||||
|
||||
function setMomentLocale(locale: string | undefined): void {
|
||||
const w = window as unknown as MomentGlobal;
|
||||
if (locale === undefined) {
|
||||
delete w.moment;
|
||||
} else {
|
||||
w.moment = { locale: () => locale };
|
||||
}
|
||||
}
|
||||
|
||||
describe('i18n', () => {
|
||||
afterEach(() => {
|
||||
setMomentLocale(undefined);
|
||||
});
|
||||
|
||||
it('falls back to English when window.moment is unavailable', () => {
|
||||
setMomentLocale(undefined);
|
||||
expect(getActiveLocale()).toBe('en');
|
||||
expect(t('confirmModal.cancel')).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('falls back to English for an unsupported locale', () => {
|
||||
setMomentLocale('fr');
|
||||
expect(getActiveLocale()).toBe('en');
|
||||
expect(t('confirmModal.cancel')).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('resolves zh-tw translations when moment locale is zh-tw', () => {
|
||||
setMomentLocale('zh-tw');
|
||||
expect(getActiveLocale()).toBe('zh-tw');
|
||||
expect(t('confirmModal.cancel')).toBe('取消');
|
||||
});
|
||||
|
||||
it('maps a bare "zh" locale to zh-tw', () => {
|
||||
setMomentLocale('zh');
|
||||
expect(getActiveLocale()).toBe('zh-tw');
|
||||
});
|
||||
|
||||
it('interpolates variables into the template', () => {
|
||||
setMomentLocale(undefined);
|
||||
expect(t('settings.testConnection.success', { service: 'GitHub' })).toBe('GitHub connection successful!');
|
||||
});
|
||||
|
||||
it('falls back to English text when a zh-tw key is missing a translation', () => {
|
||||
setMomentLocale('zh-tw');
|
||||
// Every key defined in en.ts should resolve to *some* string even if
|
||||
// zh-tw hasn't translated it yet, rather than throwing or returning undefined.
|
||||
expect(typeof t('confirmModal.title')).toBe('string');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue