mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
feat(core,ui,ci,docs): add GitHub support, sync status view, batch operations, and CI/CD workflows (#1)
Major feature release including: - GitHub service support alongside GitLab - Sync status view with visual diff - Batch push/pull operations - Conflict resolution modal - Vault folder filtering - File rename detection - Complete CI/CD workflows (check, auto-release, semantic-release) - Comprehensive documentation (CHANGELOG, COMPLIANCE, RELEASE guides) Co-authored-by: tianyao <tianyao@heavendev01.royal-powan.ts.net> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b354f651cf
commit
d5539434dc
45 changed files with 10449 additions and 778 deletions
81
.claude/agents/boilerplate-generator.md
Normal file
81
.claude/agents/boilerplate-generator.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
name: boilerplate-generator
|
||||
description: Scaffolds new files following project patterns: Astro pages, React components with SCSS modules, API routes, Drizzle schema tables. Invoke when creating a file from scratch.
|
||||
model: haiku
|
||||
color: yellow
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are a scaffolding agent. You create new files that follow this project's established patterns and conventions exactly. You do not implement business logic beyond what is needed to wire the boilerplate together.
|
||||
|
||||
## Project Context
|
||||
|
||||
* **Stack**: Astro + TypeScript + React + SCSS Modules
|
||||
* **Deployment**: Cloudflare Pages (SSR via `@astrojs/cloudflare`)
|
||||
* **Database**: Cloudflare D1 via Drizzle ORM — schema in `src/db/schema.ts`
|
||||
* **Styling**: SCSS Modules (`ComponentName.module.scss`) — no Tailwind
|
||||
* **i18n**: Pages live under `src/pages/[lang]/`; use `getLangFromUrl` and `useTranslations` helpers
|
||||
* **Package manager**: `pnpm`
|
||||
|
||||
## Patterns to Follow
|
||||
|
||||
### Astro Page
|
||||
|
||||
```astro
|
||||
---
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
// imports...
|
||||
---
|
||||
<Layout title="...">
|
||||
<!-- content -->
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### React Component with SCSS Module
|
||||
|
||||
* File: `src/components/<section>/ComponentName.tsx`
|
||||
* Style: `src/components/<section>/ComponentName.module.scss`
|
||||
* Use named export: `export function ComponentName(...)`
|
||||
* Import styles: `import styles from './ComponentName.module.scss'`
|
||||
* Use `styles.className` references
|
||||
|
||||
### Astro API Route
|
||||
|
||||
```ts
|
||||
// src/pages/api/<route>.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
const db = locals.runtime.env.DB;
|
||||
// ...
|
||||
return new Response(JSON.stringify({ ... }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Drizzle Schema Table
|
||||
|
||||
```ts
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
export const tableName = sqliteTable('table_name', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
// columns...
|
||||
});
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the type of file to create (Astro page, React component, API route, schema table, TypeScript interface).
|
||||
2. Check if a similar file already exists in the project to use as a reference for naming and structure.
|
||||
3. Generate the boilerplate file with correct imports, exports, and type annotations.
|
||||
4. If a SCSS module is needed alongside a component, create it too.
|
||||
5. Report the created file paths.
|
||||
|
||||
## Constraints
|
||||
|
||||
* Do not implement full business logic — leave `// TODO: implement` comments for non-trivial logic.
|
||||
* Do not modify existing files unless adding an export to an index barrel file is strictly required.
|
||||
* Do not use Tailwind utility classes — use SCSS Modules for all styling.
|
||||
* Respect the i18n routing structure: user-facing pages go under `src/pages/[lang]/`.
|
||||
36
.claude/agents/code-formatter.md
Normal file
36
.claude/agents/code-formatter.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: code-formatter
|
||||
description: Runs ESLint and Prettier, fixes lint and style errors. For mechanical code quality tasks only — not logic or architectural changes.
|
||||
model: haiku
|
||||
color: green
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are a code quality automation agent. You run linting and formatting tools, read their output, and apply the resulting fixes. You do not make logic changes, architectural decisions, or feature additions.
|
||||
|
||||
## Project Context
|
||||
|
||||
* **Stack**: Astro + TypeScript + React + SCSS Modules, deployed to Cloudflare Pages
|
||||
* **Package manager**: `pnpm`
|
||||
* **Lint command**: `pnpm lint` (ESLint with `--fix`)
|
||||
* **Format command**: `pnpm format` (Prettier)
|
||||
* **Styling**: SCSS Modules (`*.module.scss`) — no Tailwind
|
||||
* **Config files**: `eslint.config.mjs`, `.prettierrc`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the scope: single file, directory, or entire project.
|
||||
2. Run the appropriate command:
|
||||
* Format only: `pnpm format`
|
||||
* Lint + auto-fix: `pnpm lint`
|
||||
* Both: `pnpm format && pnpm lint`
|
||||
3. Read the command output. If errors remain that `--fix` could not resolve automatically, read the affected file and apply the minimal manual fix.
|
||||
4. Do not change logic, rename variables for non-style reasons, or restructure code beyond what the linter/formatter requires.
|
||||
5. Report what was fixed in a brief summary.
|
||||
|
||||
## Constraints
|
||||
|
||||
* Only fix what the linter or formatter flags. Do not "improve" code outside of reported issues.
|
||||
* Do not modify `.eslintrc`, `eslint.config.mjs`, or `.prettierrc` unless explicitly instructed.
|
||||
* Do not run `pnpm build` or `pnpm dev` — formatting tasks only.
|
||||
125
.claude/agents/context-gatherer.md
Normal file
125
.claude/agents/context-gatherer.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
name: context-gatherer
|
||||
description: Scans the codebase and fetches external URLs, returning a concise summary — offloads file reads and WebFetch to Haiku so the main agent's context stays clean. Invoke before any non-trivial task when relevant files or external docs are not yet known.
|
||||
model: haiku
|
||||
color: cyan
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are a codebase reconnaissance and fetch agent. Your job is to read files, list directories, search the codebase, and fetch external URLs or web content so the main agent does not have to. All intermediate file reads and HTTP fetches happen here; the main agent only receives your concise written summary.
|
||||
|
||||
You never write, edit, or delete source files. You only read, search, and fetch.
|
||||
|
||||
***
|
||||
|
||||
## Project Snapshot
|
||||
|
||||
* **Stack**: Astro + TypeScript + React + SCSS Modules
|
||||
* **Deployment**: Cloudflare Pages (SSR), D1 (Drizzle ORM)
|
||||
* **Package manager**: `pnpm`
|
||||
* **Key directories**:
|
||||
* `src/pages/` — Astro pages and API routes
|
||||
* `src/components/` — UI components (Astro + React)
|
||||
* `src/actions/` — Astro server actions
|
||||
* `src/db/` — Drizzle schema and database helpers
|
||||
* `src/i18n/` — translation files and helpers
|
||||
* `src/styles/` — global CSS variables and base styles
|
||||
* `src/utils/` — shared utility functions
|
||||
* `tests/` — Playwright e2e tests
|
||||
|
||||
***
|
||||
|
||||
## Mandatory Workflow
|
||||
|
||||
### Step 1 — Understand the task
|
||||
|
||||
Read the task description carefully. Identify:
|
||||
|
||||
* What feature, bug, or question is being addressed?
|
||||
* Which part of the codebase is likely involved?
|
||||
|
||||
### Step 2 — Locate relevant files
|
||||
|
||||
Search and list files related to the task. Use available tools to:
|
||||
|
||||
* List directory contents for the relevant section
|
||||
* Search for function names, component names, or keywords mentioned in the task
|
||||
* Identify entry points, related components, shared utilities, and type definitions
|
||||
|
||||
### Step 3 — Read and summarise
|
||||
|
||||
Read only the files directly relevant to the task. For each file, extract:
|
||||
|
||||
* Purpose and responsibility
|
||||
* Key exports, functions, or types
|
||||
* Patterns or conventions used
|
||||
* Any constraints (e.g., Cloudflare runtime limits, i18n requirements)
|
||||
|
||||
Do **not** dump raw file contents — summarise in your own words.
|
||||
|
||||
### Step 4 — Write the context report
|
||||
|
||||
Ensure the `.claude/context/` directory exists before writing (create it if needed). Write a Markdown context report to `.claude/context/context-<timestamp>-<random4>.md` (append 4 random alphanumeric chars to avoid collisions when multiple agents run concurrently) with the following structure:
|
||||
|
||||
```markdown
|
||||
# Context Report: <task summary>
|
||||
|
||||
## Relevant Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `path/to/file.ts` | Brief description |
|
||||
|
||||
## Key Patterns & Conventions
|
||||
|
||||
- [Pattern name]: [Brief explanation]
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
- [Constraint]: [Why it matters for this task]
|
||||
|
||||
## Recommended Starting Points
|
||||
|
||||
1. [File or function] — [Why to start here]
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [Any ambiguity the main agent should resolve before starting]
|
||||
```
|
||||
|
||||
### Step 5 — Return summary to main agent
|
||||
|
||||
Return a brief message:
|
||||
|
||||
```
|
||||
上下文報告已建立:.claude/context/context-<timestamp>-<random4>.md
|
||||
|
||||
摘要:[2–3 sentences: which files are relevant, key patterns found, recommended entry point]
|
||||
|
||||
請在開始實作前先閱讀該報告。
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## Web Fetch
|
||||
|
||||
When the task requires external information (documentation, API specs, URLs provided by the user):
|
||||
|
||||
1. Use `WebFetch` or `WebSearch` to retrieve the content.
|
||||
2. Determine the precision requirement:
|
||||
* **Conceptual** (understanding a feature, confirming existence) → summarise in your own words.
|
||||
* **Exact** (JSON schema, API response format, config syntax, anything the main agent will copy into code) → paste the raw content verbatim into the report. Do NOT paraphrase — precision loss here causes bugs.
|
||||
3. Include findings in the context report under a **## External References** section.
|
||||
4. Never fetch URLs not directly relevant to the task.
|
||||
|
||||
***
|
||||
|
||||
## Constraints
|
||||
|
||||
* **Read only** — never create, edit, or delete source files.
|
||||
* Do not return raw file contents or raw fetch responses to the main agent — always summarise.
|
||||
* Keep the final message short; full detail belongs in the report file.
|
||||
* If the task is ambiguous, list open questions in the report rather than guessing.
|
||||
* Do not invoke yourself recursively.
|
||||
* **Your job is to narrow scope, not to understand code deeply.** Identify the 2–5 most relevant files and explain why — do not attempt to fully analyse logic, control flow, or side effects. The main agent will read those files itself for precise understanding.
|
||||
78
.claude/agents/dev-task.md
Normal file
78
.claude/agents/dev-task.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
name: dev-tasks
|
||||
description: "Handles simple developer tasks: running pnpm commands, build, executing tests, shell operations (ls, grep, awk, sed, cat), and generating Conventional Commits messages. Use proactively for these routine tasks to keep the main context focused on higher-level decisions."
|
||||
model: haiku
|
||||
color: blue
|
||||
---
|
||||
You are a focused assistant for routine developer tasks. You handle simple, well-defined operations efficiently.
|
||||
|
||||
## Responsibilities
|
||||
- Read files and return their contents
|
||||
- Run pnpm commands (install, build, test, lint, etc.)
|
||||
- Execute test suites and report pass/fail results with summaries
|
||||
- Shell data operations: `ls`, `grep`, `awk`, `sed`, `cat`, `wc`, `sort`, `uniq` and similar Unix tools
|
||||
- Any zero-reasoning shell command the main agent would otherwise run itself
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
pnpm dev # start dev server
|
||||
pnpm build # astro check + build
|
||||
pnpm preview # preview production build
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
**Always run `pnpm build` before any e2e test (`pnpm test`). Unit tests (`pnpm test:unit`) do not require a build step.**
|
||||
|
||||
```bash
|
||||
pnpm run db:generate && pnpm run db:migrate:local # prepare necessaary db for e2e tests
|
||||
pnpm build && pnpm test # run all Playwright e2e tests
|
||||
pnpm build && pnpm test tests/e2e/p1/p1_001-homepage.spec.ts # run a single e2e test file
|
||||
pnpm test:unit # run Vitest unit tests (no build needed)
|
||||
```
|
||||
|
||||
**E2E test log management** — Playwright output can be very large. Always run e2e tests with log capture:
|
||||
```bash
|
||||
LOG=/tmp/e2e-$(date +%s).log
|
||||
pnpm test 2>&1 | tee $LOG
|
||||
echo "=== LOG: $LOG ==="
|
||||
grep -E '(passed|failed|skipped)' $LOG | tail -5
|
||||
grep -E '(FAILED|●\s)' $LOG | head -30
|
||||
```
|
||||
Return to the main agent:
|
||||
- **All passed**: one line only — `✓ X passed (Xs) — log: $LOG`
|
||||
- **Some failed**: summary line + failed test names (no stack traces) + log path
|
||||
|
||||
Never return more than 30 lines regardless of outcome. Never include stack traces, DOM diffs, or raw log content. The main agent will read the log file directly if it needs details.
|
||||
|
||||
### Database (Drizzle + Cloudflare D1)
|
||||
```bash
|
||||
pnpm db:generate # generate migration files from schema changes
|
||||
pnpm db:migrate # apply migrations
|
||||
pnpm db:studio # open Drizzle Studio
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
```bash
|
||||
git diff --staged # view staged changes
|
||||
git diff HEAD # view unstaged changes
|
||||
```
|
||||
When generating a commit message, follow Conventional Commits: `type(scope): subject`.
|
||||
Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `revert`.
|
||||
Common scopes: `blog`, `admin`, `auth`, `comments`, `layout`, `ui`, `i18n`, `db`, `api`, `tools`, `config`, `deps`, `e2e`.
|
||||
Output only the commit message in a code block. Subject line ≤ 72 chars. Never commit on behalf of the user.
|
||||
|
||||
## Guidelines
|
||||
1. Execute the requested task directly without over-explaining.
|
||||
2. For test runs, report: total tests, passed, failed, and any failure messages.
|
||||
3. For file reads, return the relevant content concisely.
|
||||
4. If a command fails, report the error output clearly.
|
||||
5. Do not make architectural decisions or code changes — escalate those to the parent agent.
|
||||
51
.claude/agents/i18n-manager.md
Normal file
51
.claude/agents/i18n-manager.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
name: i18n-manager
|
||||
description: Manages i18n keys in src/i18n/ui.ts — adds entries to both zh-tw and en, finds missing or unused keys. Invoke when adding UI text or auditing translation coverage.
|
||||
model: haiku
|
||||
color: cyan
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are an i18n maintenance agent. You keep translation keys in sync across all supported locales, find gaps, and add new entries. You do not modify page logic or component structure.
|
||||
|
||||
## Project Context
|
||||
|
||||
* **Locales**: `zh-tw` (default, served at `/` without prefix) and `en`
|
||||
* **Translation file**: `src/i18n/ui.ts` — contains a `ui` object keyed by locale
|
||||
* **Helpers**: `src/i18n/utils.ts` — `getLangFromUrl(url)`, `useTranslations(lang)`
|
||||
* **Usage pattern**: `const t = useTranslations(lang); t('key.path')`
|
||||
* **Routing**: All user-facing pages under `src/pages/[lang]/`
|
||||
* **Key naming convention**: Dot-notation like `section.subsection.key` (e.g., `nav.home`, `blog.readMore`, `admin.users.deleteConfirm`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Add new translation key
|
||||
1. Read `src/i18n/ui.ts` to understand the existing key structure.
|
||||
2. Add the new key under **both** `zh-tw` and `en` entries.
|
||||
3. Use dot-notation grouping consistent with surrounding keys (e.g., `nav.home`, `blog.readMore`).
|
||||
4. Never add a key to one locale only.
|
||||
|
||||
### Audit for missing keys
|
||||
1. Read `src/i18n/ui.ts` and collect all keys for each locale.
|
||||
2. Diff the key sets — report any key present in `zh-tw` but missing in `en`, or vice versa.
|
||||
3. For missing keys, add a placeholder value: `'[TODO: translate]'` and note it in the report.
|
||||
|
||||
### Find unused keys
|
||||
1. Read `src/i18n/ui.ts` and collect all defined keys.
|
||||
2. Use Grep to search for each key across `src/pages/`, `src/components/`, `src/layouts/`.
|
||||
3. Report keys that appear in `ui.ts` but are not referenced anywhere in source.
|
||||
4. Do not delete unused keys automatically — report them for the user to decide.
|
||||
|
||||
### Find hardcoded strings
|
||||
1. Grep for Chinese characters or suspiciously long English strings in `.astro` and `.tsx` files under `src/`.
|
||||
2. Identify strings that should be translation keys.
|
||||
3. Suggest appropriate key names and values — do not auto-refactor without instruction.
|
||||
|
||||
## Constraints
|
||||
|
||||
* Only modify `src/i18n/ui.ts` — do not edit page or component files.
|
||||
* Always add keys to **all** locales in the same edit.
|
||||
* Keep key names consistent with the existing naming convention in the file.
|
||||
* Do not remove keys — only report them as candidates for removal.
|
||||
* Do not translate content — use `'[TODO: translate]'` for keys where the translation is unknown.
|
||||
50
.claude/agents/test-writer.md
Normal file
50
.claude/agents/test-writer.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
name: test-writer
|
||||
description: Writes Vitest unit tests or Playwright e2e tests for existing code. Invoke when adding tests to already-implemented functions, components, or flows.
|
||||
model: haiku
|
||||
color: purple
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are a test-writing specialist. You read existing source code and produce well-structured, focused tests. You do not modify the source files under test.
|
||||
|
||||
## Project Context
|
||||
|
||||
* **Stack**: Astro + TypeScript + React + SCSS Modules, Cloudflare Pages/D1
|
||||
* **Unit tests**: Vitest — run with `pnpm test:unit`; config in `vitest.config.ts`
|
||||
* **E2E tests**: Playwright — run with `pnpm test:e2e`; config in `playwright.config.ts`
|
||||
* **E2E test location**: `tests/e2e/` — files named `p<priority>_<id>-<description>.spec.ts`
|
||||
* **Package manager**: `pnpm`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the source file(s) the user wants tested.
|
||||
2. Identify the functions, exports, or user flows to cover.
|
||||
3. Determine the appropriate test type:
|
||||
* Pure functions / utilities → Vitest unit tests
|
||||
* React components → Vitest + React Testing Library (if already used in project), otherwise Vitest with basic rendering
|
||||
* User-facing pages / flows → Playwright e2e tests
|
||||
4. Write tests that cover:
|
||||
* Happy path (expected inputs produce expected outputs)
|
||||
* Edge cases (empty input, boundary values, null/undefined)
|
||||
* Error cases (invalid input, network failure where applicable)
|
||||
5. Place the test file:
|
||||
* Unit tests: alongside the source file as `<name>.test.ts` or in a `__tests__/` sibling directory
|
||||
* E2E tests: `tests/e2e/p3/<next-id>-<description>.spec.ts` (default to p3 unless user specifies priority)
|
||||
6. Do not run the tests — only write the files.
|
||||
|
||||
## Test Quality Rules
|
||||
|
||||
* Each test has a single, descriptive `it`/`test` label in imperative mood: `'returns empty array when input is null'`
|
||||
* Group related tests with `describe` blocks named after the function or component
|
||||
* No logic inside assertions — compute expected values before the `expect` call
|
||||
* Do not import from `node_modules` paths that are not already used in the project
|
||||
* For Playwright tests, use the page object pattern if a similar test file already does so
|
||||
|
||||
## Constraints
|
||||
|
||||
* Do not modify source files under test.
|
||||
* Do not add test dependencies to `package.json` without confirming with the user.
|
||||
* Keep tests focused: one concern per test case.
|
||||
* Do not mock unless necessary — prefer testing real behaviour.
|
||||
53
.claude/commands/clean_gone.md
Normal file
53
.claude/commands/clean_gone.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
description: Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees.
|
||||
---
|
||||
|
||||
## Your Task
|
||||
|
||||
You need to execute the following bash commands to clean up stale local branches that have been deleted from the remote repository.
|
||||
|
||||
## Commands to Execute
|
||||
|
||||
1. **First, list branches to identify any with [gone] status**
|
||||
Execute this command:
|
||||
```bash
|
||||
git branch -v
|
||||
```
|
||||
|
||||
Note: Branches with a '+' prefix have associated worktrees and must have their worktrees removed before deletion.
|
||||
|
||||
2. **Next, identify worktrees that need to be removed for [gone] branches**
|
||||
Execute this command:
|
||||
```bash
|
||||
git worktree list
|
||||
```
|
||||
|
||||
3. **Finally, remove worktrees and delete [gone] branches (handles both regular and worktree branches)**
|
||||
Execute this command:
|
||||
```bash
|
||||
# Process all [gone] branches, removing '+' prefix if present
|
||||
git branch -v | grep '\[gone\]' | sed 's/^[+* ]//' | awk '{print $1}' | while read branch; do
|
||||
echo "Processing branch: $branch"
|
||||
# Find and remove worktree if it exists
|
||||
worktree=$(git worktree list | grep "\\[$branch\\]" | awk '{print $1}')
|
||||
if [ ! -z "$worktree" ] && [ "$worktree" != "$(git rev-parse --show-toplevel)" ]; then
|
||||
echo " Removing worktree: $worktree"
|
||||
git worktree remove --force "$worktree"
|
||||
fi
|
||||
# Delete the branch
|
||||
echo " Deleting branch: $branch"
|
||||
git branch -D "$branch"
|
||||
done
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
After executing these commands, you will:
|
||||
|
||||
- See a list of all local branches with their status
|
||||
- Identify and remove any worktrees associated with [gone] branches
|
||||
- Delete all branches marked as [gone]
|
||||
- Provide feedback on which worktrees and branches were removed
|
||||
|
||||
If no branches are marked as [gone], report that no cleanup was needed.
|
||||
|
||||
55
.claude/commands/commit-push-pr.md
Normal file
55
.claude/commands/commit-push-pr.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
allowed-tools: Agent(dev-tasks), Bash(git add:*), Bash(git status:*), Bash(git commit:*)
|
||||
description: Complete commit-push-PR workflow for GitLab/GitHub. Use when user asks to commit, push, or create MR/PR. Enforces quality checks and delegates to dev-tasks.
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- Current git status: !`git status`
|
||||
- Current git diff (staged and unstaged changes): !`git diff HEAD`
|
||||
- Current branch: !`git branch --show-current`
|
||||
- Git platform: !`git remote get-url origin`
|
||||
|
||||
## Your task
|
||||
|
||||
Based on the above changes, delegate to dev-tasks agent with a comprehensive prompt:
|
||||
|
||||
**Spawn ONE dev-tasks agent** with the following instructions:
|
||||
|
||||
```
|
||||
Complete the commit-push-pr workflow:
|
||||
|
||||
Current branch: [branch name from context]
|
||||
Changes: [summarize from git diff]
|
||||
Platform: [GitLab if origin contains 'gitlab', GitHub if contains 'github.com']
|
||||
|
||||
Steps:
|
||||
1. Run quality checks (skip if changes are only to *.md, CLAUDE.md, or .claude/ files):
|
||||
- pnpm run check
|
||||
- pnpm lint
|
||||
- pnpm test:e2e (if external service tests fail with timeouts, note them but proceed)
|
||||
- If any check fails with actual code errors, STOP and report
|
||||
|
||||
2. Safety check: Run `git status --porcelain` and verify no .gitignore patterns (node_modules, .pnpm-store, .env, etc.) would be staged
|
||||
|
||||
3. Stage files: git add [specific files, never use -A]
|
||||
|
||||
4. Create commit with conventional commit message including:
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
|
||||
5. Push: git push origin [branch] (or git push -u origin [branch] if new)
|
||||
|
||||
6. If not on main and MR/PR doesn't exist:
|
||||
- GitLab: glab mr create --repo firstsun-dev/blog --title "[title]" --description "[description]" --remove-source-branch --yes
|
||||
- GitHub: gh pr create --title "[title]" --body "[description]"
|
||||
|
||||
7. If MR/PR was created, enable auto-merge:
|
||||
- GitLab: parse MR IID from output, then run glab mr merge [iid] --repo firstsun-dev/blog --auto-merge --yes
|
||||
- GitHub: gh pr merge --auto --squash
|
||||
|
||||
8. Monitor CI:
|
||||
- GitLab: glab ci status --repo firstsun-dev/blog --live
|
||||
- GitHub: gh run watch
|
||||
```
|
||||
|
||||
**IMPORTANT**: You must spawn the agent with `run_in_background: false` so you can report the result to the user.
|
||||
17
.claude/commands/commit.md
Normal file
17
.claude/commands/commit.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
|
||||
description: Create a git commit
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- Current git status: !`git status`
|
||||
- Current git diff (staged and unstaged changes): !`git diff HEAD`
|
||||
- Current branch: !`git branch --show-current`
|
||||
- Recent commits: !`git log --oneline -10`
|
||||
|
||||
## Your task
|
||||
|
||||
Based on the above changes, create a single git commit.
|
||||
|
||||
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.
|
||||
138
.claude/skills/dev-conventions/SKILL.md
Normal file
138
.claude/skills/dev-conventions/SKILL.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
name: dev-conventions
|
||||
description: Project-specific coding conventions and guardrails for the firstsun-blog Astro/Cloudflare project. Consult this skill whenever writing new code, creating components, adding pages, editing RSS/sitemap, handling SEO/i18n, or planning tests — including requests like "新增一個頁面", "幫我寫這個元件", "add a component", "fix the SEO", "update RSS", "add to sitemap", "寫測試", or "add tests". These conventions must be followed for all code changes in this project.
|
||||
---
|
||||
|
||||
# Dev Conventions — firstsun-blog
|
||||
|
||||
Core rules always in effect. For domain-specific detail, read the relevant reference file before writing code.
|
||||
|
||||
| Situation | Read |
|
||||
|---|---|
|
||||
| Writing DB mutations / Astro Actions | `references/database.md` |
|
||||
| Adding or changing actions / updating OpenAPI spec | `references/api.md` |
|
||||
| Adding SEO tags, RSS, or sitemap changes | `references/seo-rss.md` |
|
||||
| Adding images, tools (mini-apps), or content queries | `references/tools-images.md` |
|
||||
| Writing or planning e2e tests | `references/testing.md` |
|
||||
| Creating or modifying tool pages under `src/apps/tools/` | `references/tool-pages.md` |
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
**No `eslint-disable` comments.** Fix the root cause. If the file is dependency-emitted and you truly can't touch it, document why in a comment.
|
||||
|
||||
**After every code change, run `pnpm lint` and confirm zero errors and zero warnings before considering the task done.** Warnings are not acceptable — treat them the same as errors and fix them immediately.
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
**Share components between ZH-TW and EN pages.** Both locales render the same React/Astro components — don't create parallel `PostCardZh`/`PostCardEn` variants. Handle locale-specific text via `useTranslations`.
|
||||
|
||||
**Extract repeated UI.** Same visual pattern in more than one place → pull it into `src/components/<section>/ComponentName.tsx` or `.astro`.
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
|
||||
**SCSS Modules only — no Tailwind, no inline `style` props.** Co-locate `ComponentName.module.scss` with every component. Use `styles.className` references.
|
||||
|
||||
**Use design tokens** from `src/styles/tokens/` instead of hardcoded values:
|
||||
|
||||
| Category | Token pattern |
|
||||
|---|---|
|
||||
| Color | `var(--color-*)` |
|
||||
| Spacing | `var(--space-*)` |
|
||||
| Typography | `var(--font-*)`, `var(--text-*)` |
|
||||
| Radius / Shadow / Motion | `var(--radius-*)`, `var(--shadow-*)`, `var(--duration-*)` |
|
||||
|
||||
**Reuse mixins** — check `src/styles/mixins/` (`_flex.scss`, `_layout.scss`, `_text.scss`) before writing custom helpers.
|
||||
|
||||
**Dark mode** — handled by tokens via `:root` / `[data-theme="dark"]`. Don't add `prefers-color-scheme` queries manually.
|
||||
|
||||
**SCSS imports — ALWAYS use namespaced imports, NEVER wildcard (`as *`):**
|
||||
|
||||
```scss
|
||||
// ✅ CORRECT - namespaced imports
|
||||
@use "@/styles/tokens/spacing" as spacing;
|
||||
@use "@/styles/tokens/colors" as colors;
|
||||
@use "@/styles/mixins/flex" as flex;
|
||||
@use "@/styles/breakpoints/breakpoints" as bp;
|
||||
@use "sass:map";
|
||||
|
||||
.container {
|
||||
@include flex.flex-col;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
// ❌ WRONG - wildcard imports cause namespace pollution
|
||||
@use "@/styles/tokens/spacing" as *;
|
||||
@use "@/styles/tokens/colors" as *;
|
||||
@use "@/styles/mixins/flex" as *;
|
||||
```
|
||||
|
||||
**Why:** Wildcard imports (`as *`) pollute the global namespace and can cause CSS variable conflicts with parent components (e.g., ToolLayout styles being overridden). Always use explicit namespaces to avoid naming collisions.
|
||||
|
||||
---
|
||||
|
||||
## i18n
|
||||
|
||||
**Use `useTranslations` — no `if lang === 'zh-tw'` branches for UI strings:**
|
||||
|
||||
```ts
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
// t('nav.home'), t('blog.readMore')
|
||||
```
|
||||
|
||||
Add missing keys to both locales in `src/i18n/ui.ts` before use (delegate to `i18n-manager` agent). Structural/routing logic may use `lang` directly — that's not a UI string.
|
||||
|
||||
---
|
||||
|
||||
## SVG / Icons
|
||||
|
||||
**Use `astro-icon` — not inline SVG or `<img src="*.svg">`:**
|
||||
|
||||
```astro
|
||||
import { Icon } from 'astro-icon/components';
|
||||
<Icon name="mdi:home" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Testing / data-testid
|
||||
|
||||
**Before running e2e tests, always build them first:**
|
||||
|
||||
```bash
|
||||
pnpm build:e2e # Build e2e test suite
|
||||
pnpm test:e2e # Then run tests
|
||||
```
|
||||
|
||||
The build step compiles Playwright fixtures and test setup — skipping it will cause test failures.
|
||||
|
||||
**Always use stable English keys for `data-testid`** — never use translated labels or Chinese text.
|
||||
|
||||
```astro
|
||||
// ✅ CORRECT — stable, locale-independent
|
||||
data-testid={`nav-link-${item.key}`} // e.g. "nav-link-tools", "nav-link-tech"
|
||||
|
||||
// ❌ WRONG — breaks when locale changes or labels are renamed
|
||||
data-testid={`nav-link-${item.label.toLowerCase()}`} // e.g. "nav-link-工具"
|
||||
```
|
||||
|
||||
For nav items, define a `key` field (English string) alongside `i18nKey`, and use `key` for testid generation. Tests reference testids like `nav-link-tools`, never `nav-link-工具`.
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Path Aliases
|
||||
|
||||
Never climb with `../../`. Use project aliases:
|
||||
|
||||
| Alias | Resolves to |
|
||||
|---|---|
|
||||
| `@/` | `src/` |
|
||||
| `@layouts/` | `src/layouts/` |
|
||||
| `@assets/` | `src/assets/` |
|
||||
| `@consts` | `src/consts.ts` |
|
||||
50
.claude/skills/dev-conventions/references/api.md
Normal file
50
.claude/skills/dev-conventions/references/api.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# API & Astro Actions
|
||||
|
||||
## OpenAPI spec
|
||||
|
||||
`src/pages/console/openapi.yml.ts` documents all actions. **Whenever a new action is added or an existing one changes its input/output shape, update the spec in the same commit.**
|
||||
|
||||
Add new entries under the appropriate tag (`Public`, `Admin`, `RSS Feeds`, etc.).
|
||||
|
||||
Pattern for a new action entry:
|
||||
|
||||
```yaml
|
||||
/_actions/adminMyAction:
|
||||
post:
|
||||
summary: One-line description
|
||||
tags: [Admin]
|
||||
security:
|
||||
- cookieAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
myParam: { type: string }
|
||||
required: [myParam]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
'401':
|
||||
description: Unauthorized
|
||||
```
|
||||
|
||||
## Action naming
|
||||
|
||||
- Public actions: camelCase, no prefix — `getLikes`, `submitComment`
|
||||
- Admin actions: `admin` prefix — `adminGetComments`, `adminFetchFeed`
|
||||
|
||||
## Auth guard
|
||||
|
||||
All admin actions must call `verifyAuth` at the top of the handler:
|
||||
|
||||
```ts
|
||||
if (!(await verifyAuth(context))) throw new Error("Unauthorized");
|
||||
```
|
||||
60
.claude/skills/dev-conventions/references/database.md
Normal file
60
.claude/skills/dev-conventions/references/database.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Database & Astro Actions
|
||||
|
||||
## Where mutations live
|
||||
|
||||
All DB mutations go through Astro Actions in `src/actions/index.ts`. Never write directly to D1 from `.astro` components or one-off API routes.
|
||||
|
||||
## Three rules for every action handler
|
||||
|
||||
**1. Guard the binding** — return a safe fallback if `DB` is absent (covers local dev without wrangler):
|
||||
```ts
|
||||
if (!runtime?.env?.DB) return { count: 0 };
|
||||
```
|
||||
|
||||
**2. Wrap in try/catch** — read operations return a fallback on error, write operations may throw:
|
||||
```ts
|
||||
try {
|
||||
const result = await db.select()...get();
|
||||
return result || { likes: 0, views: 0 };
|
||||
} catch (error) {
|
||||
console.error("D1 Error:", error);
|
||||
return { likes: 0, views: 0 }; // reads: fallback. writes: throw instead.
|
||||
}
|
||||
```
|
||||
|
||||
**3. Validate inputs with `z` from `astro:schema`** — not from `zod` directly:
|
||||
```ts
|
||||
import { z } from "astro:schema";
|
||||
input: z.object({ slug: z.string() }),
|
||||
```
|
||||
|
||||
## Env var access pattern
|
||||
|
||||
Always try runtime first, fall back to `import.meta.env`:
|
||||
```ts
|
||||
const SECRET = (runtime?.env?.SECRET_KEY || import.meta.env.SECRET_KEY || "").toString().trim();
|
||||
```
|
||||
|
||||
## Soft delete
|
||||
|
||||
The `comments` table uses `deletedAt` timestamp — not SQL `DELETE`. Follow this pattern for any table needing trash/restore:
|
||||
```ts
|
||||
// soft delete
|
||||
await db.update(table).set({ deletedAt: new Date() }).where(eq(table.id, id));
|
||||
// restore
|
||||
await db.update(table).set({ deletedAt: null }).where(eq(table.id, id));
|
||||
// hard delete (admin empty trash only)
|
||||
await db.delete(table).where(sql`${table.deletedAt} IS NOT NULL`);
|
||||
```
|
||||
|
||||
## Schema changes
|
||||
|
||||
1. Edit `src/db/schema.ts`
|
||||
2. `pnpm run db:generate` — let drizzle-kit generate the migration file in `migrations/`
|
||||
3. `pnpm run db:migrate` — applies to local D1
|
||||
4. Before production deploy: `wrangler d1 execute <db-name> --remote --file=migrations/<generated>.sql`
|
||||
5. Then `wrangler deploy`
|
||||
|
||||
Skipping step 4 before step 5 will leave production without the new table/column.
|
||||
|
||||
**NEVER manually create files in `migrations/`.** Drizzle tracks schema state via `migrations/meta/_journal.json` and `migrations/meta/*.snapshot.json`. Hand-written SQL files are invisible to drizzle-kit and will cause journal/snapshot drift — future `db:generate` runs will produce incorrect diffs. Always go through `pnpm run db:generate`.
|
||||
56
.claude/skills/dev-conventions/references/seo-rss.md
Normal file
56
.claude/skills/dev-conventions/references/seo-rss.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# SEO, RSS & Sitemap
|
||||
|
||||
## SEO Metadata
|
||||
|
||||
**Required for all pages:**
|
||||
- `<title>` — unique, descriptive
|
||||
- `<meta name="description">` — 120–160 chars
|
||||
- `<link rel="canonical">` — absolute URL
|
||||
|
||||
**Required for blog posts (add to `<BaseHead>` / `<Layout>`, not individual pages):**
|
||||
```html
|
||||
<meta property="og:title" content="..." />
|
||||
<meta property="og:description" content="..." />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="..." />
|
||||
<meta property="og:image" content="..." /> <!-- hero image, absolute URL -->
|
||||
<meta property="article:published_time" content="..." /> <!-- ISO 8601 -->
|
||||
<meta property="article:author" content="..." />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
```
|
||||
|
||||
Check `src/components/layout/BaseHead.astro` first — most tags are already handled there.
|
||||
|
||||
## RSS Feed
|
||||
|
||||
Centralised in `src/lib/rss.ts`. Both feeds call `buildRssFeed(lang, context)` — never duplicate feed logic.
|
||||
|
||||
**Required fields when modifying:**
|
||||
- `atom:link` self-reference with `rel="self"`
|
||||
- `<language>` tag
|
||||
- `<lastBuildDate>` and `<copyright>`
|
||||
- `content:encoded` (CDATA) for item body
|
||||
- `dc:creator` for author
|
||||
- `<enclosure>` for hero images
|
||||
- `<category>` entries from post tags
|
||||
- All namespaces declared in `xmlns`: `atom`, `dc`, `content`
|
||||
|
||||
Only individual blog posts belong in RSS — aggregation/pagination pages never do. The feed filters via `getFilteredPosts({ lang })` which excludes drafts and test posts.
|
||||
|
||||
## Sitemap
|
||||
|
||||
Generated by `@astrojs/sitemap` with a filter in `astro.config.mjs`.
|
||||
|
||||
**Already excluded** (do not remove these rules):
|
||||
- `/tags/*`
|
||||
- Blog pagination: `/blog/2/`, `/en/blog/2/`, etc.
|
||||
- Category pagination: `/categories/tech/2/`, etc.
|
||||
- `/console/*`, `/preview/*`, `*/embed/`
|
||||
|
||||
**What belongs in the sitemap:**
|
||||
- Individual blog posts ✓
|
||||
- Tool pages ✓
|
||||
- Static informational pages ✓
|
||||
- `/categories/tech/` (first page, no number) — currently included, keep as-is
|
||||
|
||||
When adding a new page type with pagination, add a filter rule to `astro.config.mjs` to exclude the `/2/`, `/3/`, ... variants.
|
||||
92
.claude/skills/dev-conventions/references/testing.md
Normal file
92
.claude/skills/dev-conventions/references/testing.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# E2E Testing Conventions
|
||||
|
||||
## Running e2e tests
|
||||
|
||||
Always run `build:e2e` before `test:e2e`. The webServer in `playwright.config.ts` starts `wrangler dev` directly — it does **not** build; `dist/` must exist first:
|
||||
|
||||
```bash
|
||||
pnpm build:e2e # build with test-safe env vars
|
||||
pnpm test:e2e # must complete within 10 minutes (globalTimeout)
|
||||
```
|
||||
|
||||
`build:e2e` uses test-safe env vars (Turnstile test key, dummy GA ID). Do not use `pnpm build` for e2e — it may embed production keys.
|
||||
|
||||
---
|
||||
|
||||
## When adding a new feature
|
||||
|
||||
Draft test cases first. Present them to the user and ask which priority level each belongs to — **wait for confirmation before writing any test files**:
|
||||
|
||||
| Level | Directory | Meaning |
|
||||
|---|---|---|
|
||||
| **p0** | `tests/e2e/p0/` | Smoke — critical path, always runs in CI |
|
||||
| **p1** | `tests/e2e/p1/` | Functional — main feature behaviour |
|
||||
| **p2** | `tests/e2e/p2/` | Admin / auth flows |
|
||||
| **p3** | `tests/e2e/p3/` | Edge cases, secondary flows (default for new tests) |
|
||||
|
||||
## File & test naming
|
||||
|
||||
```
|
||||
tests/e2e/p{N}/p{N}_{id}-{description}.spec.ts
|
||||
```
|
||||
|
||||
`{id}` is the next sequential number in that folder. Test function names mirror it:
|
||||
```ts
|
||||
test("p1_007 tool stats should increment on visit", async ({ page }) => { ... });
|
||||
```
|
||||
|
||||
Use `test.step()` to group sub-checks within one test.
|
||||
|
||||
## `data-testid` rules
|
||||
|
||||
**Static single elements** — plain kebab-case:
|
||||
```astro
|
||||
<button data-testid="search-button">
|
||||
```
|
||||
|
||||
**Repeated elements in a list** — suffix with a unique id/slug:
|
||||
```astro
|
||||
<!-- Astro -->
|
||||
<article data-testid={`blog-post-card-${post.id}`}>
|
||||
```
|
||||
```tsx
|
||||
// React
|
||||
<li data-testid={`comment-item-${comment.id}`}>
|
||||
```
|
||||
|
||||
**Duplicate test-id problem**: a component used in multiple places (e.g. `PostCardGrid` on home and category pages) must not emit clashing ids. Accept a `testId` prop on the wrapper and let the caller scope it:
|
||||
```astro
|
||||
---
|
||||
interface Props { testId?: string; }
|
||||
const { testId } = Astro.props;
|
||||
---
|
||||
<div data-testid={testId}>
|
||||
```
|
||||
|
||||
In tests, use prefix selectors to get all items in a list, then narrow:
|
||||
```ts
|
||||
const cards = page.locator('[data-testid^="blog-post-card-"]');
|
||||
await expect(cards.first()).toBeVisible();
|
||||
```
|
||||
|
||||
## React hydration
|
||||
|
||||
React components lazy-load via `IntersectionObserver`. Always wait for hydration before interacting.
|
||||
|
||||
**In the component** — emit `data-hydrated` using `useMounted`:
|
||||
```tsx
|
||||
import { useMounted } from "@/hooks/useMounted";
|
||||
const mounted = useMounted();
|
||||
<div data-hydrated={mounted ? "true" : "false"} data-testid="my-component">
|
||||
```
|
||||
|
||||
**In the test** — use `waitForHydration` from `tests/e2e/helpers.ts`:
|
||||
```ts
|
||||
import { waitForHydration } from "../helpers";
|
||||
|
||||
const el = page.getByTestId("my-component");
|
||||
await waitForHydration(el); // scrolls into view + waits for data-hydrated="true"
|
||||
await el.click();
|
||||
```
|
||||
|
||||
Never click, fill, or assert on interactive state before hydration — the element exists in the DOM but event listeners aren't attached yet.
|
||||
104
.claude/skills/dev-conventions/references/tool-pages.md
Normal file
104
.claude/skills/dev-conventions/references/tool-pages.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Tool Pages Architecture
|
||||
|
||||
All tool pages under `src/apps/tools/` MUST use the shared `ToolLayout` component for consistency.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { ToolLayout } from "@/apps/tools/shared/components/ToolLayout";
|
||||
|
||||
<ToolLayout
|
||||
lang={lang}
|
||||
slug="tool/tool-name"
|
||||
title={t("tool.title")}
|
||||
subtitle={t("tool.subtitle")}
|
||||
aboutTitle={t("tool.aboutTitle")}
|
||||
aboutContent={t("tool.aboutDesc")}
|
||||
maxWidth="md" // "sm" | "md" | "lg" | "xl"
|
||||
>
|
||||
{/* Tool content */}
|
||||
</ToolLayout>
|
||||
```
|
||||
|
||||
## What ToolLayout Provides
|
||||
|
||||
- **Consistent header**: Title + subtitle with unified styling
|
||||
- **ToolStats component**: Views/likes counter in top-left corner
|
||||
- **About/info card**: Collapsible info section at bottom
|
||||
- **PoweredBy footer**: Branding footer
|
||||
- **Navigation arrows**: Left/right arrows to switch between tools (desktop only)
|
||||
- **Responsive container**: Design token-based max-widths
|
||||
|
||||
## Container Width Guidelines
|
||||
|
||||
Choose `maxWidth` based on content complexity:
|
||||
|
||||
| Width | Size | Use Case | Example |
|
||||
|-------|------|----------|---------|
|
||||
| `sm` | 640px | Simple UI, minimal controls | screen-checker |
|
||||
| `md` | 768px | Default for most tools | muyu-timer, screen-ruler |
|
||||
| `lg` | 1024px | Wide layouts, side-by-side content | shizai-calendar |
|
||||
| `xl` | 1280px | Reserved for future complex tools | - |
|
||||
|
||||
## Embed Mode
|
||||
|
||||
For tools that support embedding, return content directly without ToolLayout:
|
||||
|
||||
```tsx
|
||||
const MyTool = ({ isEmbed = false }) => {
|
||||
const content = (
|
||||
<div>
|
||||
{/* Tool UI */}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmbed) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolLayout {...props}>
|
||||
{content}
|
||||
</ToolLayout>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Do NOT
|
||||
|
||||
- ❌ Add custom headers, titles inside tool components
|
||||
- ❌ Add PoweredBy components manually
|
||||
- ❌ Create custom wrapper divs with max-width
|
||||
- ❌ Add ToolStats in `.astro` pages (ToolLayout handles it)
|
||||
- ❌ Duplicate navigation or layout elements
|
||||
|
||||
## Design Tokens
|
||||
|
||||
ToolLayout uses design tokens from `src/styles/tokens/_spacing.scss`:
|
||||
|
||||
- `--container-sm`: 40rem (640px)
|
||||
- `--container-md`: 48rem (768px)
|
||||
- `--container-lg`: 64rem (1024px)
|
||||
- `--container-xl`: 80rem (1280px)
|
||||
|
||||
## Tool Navigation
|
||||
|
||||
Tools are automatically linked in a circular navigation order defined in `src/apps/tools/shared/toolsConfig.ts`.
|
||||
|
||||
**IMPORTANT**: When creating a new tool, you MUST add it to `toolsConfig.ts`:
|
||||
|
||||
1. Add the tool entry to the `TOOLS` array in `src/apps/tools/shared/toolsConfig.ts`
|
||||
2. The navigation arrows will automatically include it in the left/right tool navigation
|
||||
|
||||
## Checklist for New Tools
|
||||
|
||||
When creating a new tool:
|
||||
|
||||
- [ ] Use ToolLayout component with appropriate `maxWidth`
|
||||
- [ ] Add i18n keys for title, subtitle, aboutTitle, aboutDesc
|
||||
- [ ] Do NOT add ToolStats to `.astro` page (ToolLayout handles it)
|
||||
- [ ] Do NOT add custom header/footer inside tool component
|
||||
- [ ] **MUST add tool to `src/apps/tools/shared/toolsConfig.ts` for navigation arrows**
|
||||
- [ ] Support embed mode if tool will be embedded elsewhere
|
||||
- [ ] Use SCSS modules for styling (no Tailwind)
|
||||
- [ ] Use design tokens for spacing, colors, typography
|
||||
71
.claude/skills/dev-conventions/references/tools-images.md
Normal file
71
.claude/skills/dev-conventions/references/tools-images.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Tools (Mini-Apps), Images & Content Collections
|
||||
|
||||
## Mini-Apps / Tools
|
||||
|
||||
New tool structure:
|
||||
```
|
||||
src/apps/tools/<tool-name>/
|
||||
components/
|
||||
ToolName.tsx
|
||||
ToolName.module.scss
|
||||
src/pages/tools/<tool-name>/index.astro
|
||||
src/pages/en/tools/<tool-name>/index.astro
|
||||
```
|
||||
|
||||
**Every tool page must mount `ToolStats`** from `src/apps/tools/shared/components/ToolStats.tsx` — this handles view/like tracking consistent with existing tools.
|
||||
|
||||
Tool pages use `export const prerender = true`. Because of this, **locale detection must be client-side**:
|
||||
```ts
|
||||
const isEn = window.location.pathname.startsWith('/en/');
|
||||
```
|
||||
`Astro.currentLocale` always returns `zh-tw` at build time for prerendered pages — never rely on it.
|
||||
|
||||
## Asset Management
|
||||
|
||||
### SVG Files
|
||||
- **NEVER inline SVG content directly in code** (TSX/JSX files, template literals, or data URIs in CSS).
|
||||
- **Custom/project-specific SVGs**: Store in `public/assets/` and reference via file paths: `/assets/path/to/file.svg`
|
||||
- **External library SVGs** (e.g., from icon libraries, CDNs): Keep as external references, do NOT download locally.
|
||||
- Use `<image href="...">` inside `<svg>` containers, or `<img src="...">` for standalone images.
|
||||
- Exception: Small utility icons (< 100 bytes) in shared component libraries may use inline SVG for performance.
|
||||
|
||||
### Audio/Media Files
|
||||
- Store all audio files in `public/assets/` (never use base64 encoding in code).
|
||||
- Load via `fetch()` or direct `<audio src="...">` references.
|
||||
- Organize by feature: `public/assets/tools/[tool-name]/audio/`
|
||||
|
||||
### File Organization
|
||||
```
|
||||
public/assets/
|
||||
├── tools/
|
||||
│ └── [tool-name]/
|
||||
│ ├── audio/
|
||||
│ └── images/
|
||||
└── [other-categories]/
|
||||
```
|
||||
|
||||
## Images
|
||||
|
||||
**Use `<Image>` from `astro:assets`** — not bare `<img>` tags:
|
||||
```astro
|
||||
import { Image } from "astro:assets";
|
||||
<Image src={heroImage} alt="..." width={800} height={450} />
|
||||
```
|
||||
|
||||
**For hero images in non-template code** (RSS, OG tags, API responses) use `resolveImage()` from `src/lib/images.ts`. It handles both local `ImageMetadata` and remote URL strings and returns an absolute URL.
|
||||
|
||||
**CSS aspect-ratio gotcha**: `<Image>` emits explicit `width`/`height` attrs that can override CSS `aspect-ratio`. Fix with a scoped global:
|
||||
```scss
|
||||
:global(.your-img-class) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
```
|
||||
|
||||
## Content Collections
|
||||
|
||||
**Use `getFilteredPosts({ lang })` from `src/lib/posts.ts`** — not raw `getCollection('blog')`. The helper excludes `draft: true` and test posts. Using `getCollection` directly risks leaking drafts into production.
|
||||
|
||||
For per-post lookups and URL generation use helpers from `src/lib/posts.ts`: `getPostHref`, `getSortedPosts`, `getPostSlug`.
|
||||
135
.github/workflows/auto-release.yml
vendored
Normal file
135
.github/workflows/auto-release.yml
vendored
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
name: Auto Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
default: 'patch'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Bump version
|
||||
id: version
|
||||
run: |
|
||||
# Bump version based on input
|
||||
npm version ${{ github.event.inputs.version_type }} --no-git-tag-version
|
||||
|
||||
# Get the new version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Update manifest.json
|
||||
node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='$NEW_VERSION'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\t'));"
|
||||
|
||||
# Update versions.json
|
||||
node -e "const fs=require('fs'); const v=JSON.parse(fs.readFileSync('versions.json')); const m=JSON.parse(fs.readFileSync('manifest.json')); v['$NEW_VERSION']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\t'));"
|
||||
|
||||
- name: Build plugin
|
||||
run: npm run build
|
||||
|
||||
- name: Update CHANGELOG
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.new_version }}
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Create new changelog entry
|
||||
cat > /tmp/new_entry.md << EOF
|
||||
## [$VERSION] - $DATE
|
||||
|
||||
### Added
|
||||
- New features and improvements
|
||||
|
||||
### Fixed
|
||||
- Bug fixes
|
||||
|
||||
### Changed
|
||||
- Changes and updates
|
||||
|
||||
EOF
|
||||
|
||||
# Insert new entry after the header
|
||||
sed -i "/^## \[/r /tmp/new_entry.md" CHANGELOG.md
|
||||
|
||||
# Add link at the bottom
|
||||
echo "[$VERSION]: https://github.com/${{ github.repository }}/releases/tag/$VERSION" >> CHANGELOG.md
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git add .
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
git push origin ${{ github.ref_name }}
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git tag ${{ steps.version.outputs.new_version }}
|
||||
git push origin ${{ steps.version.outputs.new_version }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.new_version }}
|
||||
release_name: Release ${{ steps.version.outputs.new_version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
body: |
|
||||
## Changes in this Release
|
||||
|
||||
See the [CHANGELOG](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download `main.js`, `manifest.json`, and `styles.css`
|
||||
2. Create a folder in your vault: `.obsidian/plugins/git-file-push/`
|
||||
3. Copy the downloaded files into this folder
|
||||
4. Reload Obsidian
|
||||
5. Enable the plugin in Settings → Community Plugins
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.new_version }}
|
||||
files: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
104
.github/workflows/check.yml
vendored
Normal file
104
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test -- --coverage
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npm run build
|
||||
|
||||
- name: Check build artifacts
|
||||
run: |
|
||||
if [ ! -f main.js ]; then
|
||||
echo "Error: main.js not found"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f manifest.json ]; then
|
||||
echo "Error: manifest.json not found"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f styles.css ]; then
|
||||
echo "Error: styles.css not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ All build artifacts present"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plugin-build
|
||||
path: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
retention-days: 7
|
||||
34
.github/workflows/release.yml
vendored
Normal file
34
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
44
.github/workflows/semantic-release.yml
vendored
Normal file
44
.github/workflows/semantic-release.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: Release with Semantic Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Build plugin
|
||||
run: npm run build
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx semantic-release
|
||||
81
.releaserc.json
Normal file
81
.releaserc.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"branches": ["main", "master"],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "revert", "release": "patch" },
|
||||
{ "type": "docs", "release": false },
|
||||
{ "type": "style", "release": false },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "type": "test", "release": false },
|
||||
{ "type": "build", "release": false },
|
||||
{ "type": "ci", "release": false },
|
||||
{ "type": "chore", "release": false },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"presetConfig": {
|
||||
"types": [
|
||||
{ "type": "feat", "section": "Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance Improvements" },
|
||||
{ "type": "revert", "section": "Reverts" },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||
{ "type": "style", "section": "Styles", "hidden": true },
|
||||
{ "type": "refactor", "section": "Code Refactoring" },
|
||||
{ "type": "test", "section": "Tests", "hidden": true },
|
||||
{ "type": "build", "section": "Build System", "hidden": true },
|
||||
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
|
||||
{ "type": "chore", "section": "Chores", "hidden": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "node -e \"const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='${nextRelease.version}'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\\t')); const v=JSON.parse(fs.readFileSync('versions.json')); v['${nextRelease.version}']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\\t'));\""
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": ["package.json", "package-lock.json", "CHANGELOG.md", "manifest.json", "versions.json"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"assets": [
|
||||
{ "path": "main.js", "label": "main.js" },
|
||||
{ "path": "manifest.json", "label": "manifest.json" },
|
||||
{ "path": "styles.css", "label": "styles.css" }
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
39
CHANGELOG.md
Normal file
39
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- Support for both GitLab and GitHub (user selectable)
|
||||
- Push and pull individual files
|
||||
- Batch operations (push/pull all modified files)
|
||||
- Sync status view with visual dashboard
|
||||
- Complete git diff display for modified files
|
||||
- Conflict resolution with visual diff viewer
|
||||
- Vault folder filter to sync only specific folders
|
||||
- File rename detection with automatic handling
|
||||
- Cross-platform support (desktop and mobile)
|
||||
- Sync metadata tracking (SHA and timestamp)
|
||||
- Automatic conflict detection
|
||||
- Ribbon icons for quick access
|
||||
- Command palette integration
|
||||
- Context menu integration (right-click files)
|
||||
- Settings UI for GitLab and GitHub configuration
|
||||
- Root path configuration for repository subfolders
|
||||
- Branch selection
|
||||
- Connection testing
|
||||
|
||||
### Features
|
||||
- **Multiple Git Services**: Choose between GitLab or GitHub
|
||||
- **Sync Status View**: See all files' sync status at a glance
|
||||
- **Batch Operations**: Push or pull multiple files at once
|
||||
- **Conflict Resolution**: Visual diff viewer to resolve conflicts
|
||||
- **Smart Rename**: Automatically detects file renames
|
||||
- **Flexible Sync**: Sync entire vault or specific folders
|
||||
|
||||
[1.0.0]: https://github.com/firstsun-dev/git-files-push/releases/tag/1.0.0
|
||||
164
COMPLIANCE.md
Normal file
164
COMPLIANCE.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# Obsidian Plugin Development Compliance Checklist
|
||||
|
||||
This document verifies that the Git File Push plugin follows Obsidian's plugin development guidelines.
|
||||
|
||||
## ✅ Required Files
|
||||
|
||||
- [x] **manifest.json** - Plugin metadata
|
||||
- [x] `id`: "git-file-push"
|
||||
- [x] `name`: "Git File Push"
|
||||
- [x] `version`: "1.0.0"
|
||||
- [x] `minAppVersion`: "0.15.0"
|
||||
- [x] `description`: Clear description of plugin functionality
|
||||
- [x] `author`: "tianyao"
|
||||
- [x] `authorUrl`: GitHub profile URL
|
||||
- [x] `isDesktopOnly`: false (supports mobile)
|
||||
|
||||
- [x] **main.js** - Compiled plugin code
|
||||
- [x] Generated by esbuild
|
||||
- [x] Not tracked in git (in .gitignore)
|
||||
- [x] Uploaded to GitHub releases
|
||||
|
||||
- [x] **styles.css** - Plugin styles (optional but included)
|
||||
|
||||
- [x] **versions.json** - Version compatibility mapping
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **LICENSE** - 0-BSD License
|
||||
|
||||
## ✅ Build Configuration
|
||||
|
||||
### esbuild.config.mjs
|
||||
- [x] Entry point: `src/main.ts`
|
||||
- [x] Output: `main.js`
|
||||
- [x] Format: CommonJS (`cjs`)
|
||||
- [x] Target: ES2018
|
||||
- [x] External dependencies properly configured:
|
||||
- [x] `obsidian`
|
||||
- [x] `electron`
|
||||
- [x] All `@codemirror/*` packages
|
||||
- [x] All `@lezer/*` packages
|
||||
- [x] Node.js built-in modules
|
||||
- [x] Source maps: inline for dev, disabled for production
|
||||
- [x] Minification: enabled for production
|
||||
- [x] Tree shaking: enabled
|
||||
|
||||
### tsconfig.json
|
||||
- [x] Module: ESNext
|
||||
- [x] Target: ES6
|
||||
- [x] Strict type checking enabled
|
||||
- [x] Source maps: inline
|
||||
- [x] Library: DOM, ES5, ES6, ES7
|
||||
|
||||
## ✅ Project Structure
|
||||
|
||||
```
|
||||
git-file-push/
|
||||
├── .github/
|
||||
│ └── workflows/ # CI/CD workflows
|
||||
├── src/
|
||||
│ ├── main.ts # Plugin entry point
|
||||
│ ├── settings.ts # Settings interface
|
||||
│ ├── logic/ # Business logic
|
||||
│ ├── services/ # API services
|
||||
│ └── ui/ # UI components
|
||||
├── tests/ # Test files
|
||||
├── manifest.json # Plugin metadata
|
||||
├── versions.json # Version mapping
|
||||
├── styles.css # Plugin styles
|
||||
├── package.json # NPM configuration
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── esbuild.config.mjs # Build config
|
||||
├── .gitignore # Git ignore rules
|
||||
├── LICENSE # License file
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
## ✅ Plugin Class
|
||||
|
||||
- [x] Main class extends `Plugin` from Obsidian API
|
||||
- [x] Implements `onload()` method
|
||||
- [x] Implements `onunload()` method
|
||||
- [x] Properly registers:
|
||||
- [x] Commands via `addCommand()`
|
||||
- [x] Ribbon icons via `addRibbonIcon()`
|
||||
- [x] Settings tab via `addSettingTab()`
|
||||
- [x] View types via `registerView()`
|
||||
- [x] Event handlers via `registerEvent()`
|
||||
- [x] Context menu items via `registerEvent()`
|
||||
|
||||
## ✅ Settings
|
||||
|
||||
- [x] Settings interface defined
|
||||
- [x] Default settings provided
|
||||
- [x] Settings loaded in `onload()` via `loadData()`
|
||||
- [x] Settings saved via `saveData()`
|
||||
- [x] Settings tab UI implemented
|
||||
|
||||
## ✅ Development Scripts
|
||||
|
||||
- [x] `npm run dev` - Development mode with watch
|
||||
- [x] `npm run build` - Production build
|
||||
- [x] `npm run version` - Version bump script
|
||||
- [x] `npm run lint` - Code linting
|
||||
- [x] `npm run test` - Run tests
|
||||
|
||||
## ✅ Git Configuration
|
||||
|
||||
- [x] `.gitignore` properly configured:
|
||||
- [x] `node_modules/` excluded
|
||||
- [x] `main.js` excluded (built file)
|
||||
- [x] `*.map` excluded (source maps)
|
||||
- [x] IDE files excluded
|
||||
- [x] `data.json` excluded (user data)
|
||||
|
||||
## ✅ Release Process
|
||||
|
||||
- [x] GitHub Actions workflow for releases
|
||||
- [x] Automated version bumping
|
||||
- [x] Release assets include:
|
||||
- [x] `main.js`
|
||||
- [x] `manifest.json`
|
||||
- [x] `styles.css`
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
- [x] TypeScript with strict mode
|
||||
- [x] ESLint configuration
|
||||
- [x] Unit tests with Vitest
|
||||
- [x] Proper error handling
|
||||
- [x] User notifications via `Notice`
|
||||
- [x] Async operations properly handled
|
||||
- [x] No blocking operations in main thread
|
||||
- [x] Mobile compatibility (`isDesktopOnly: false`)
|
||||
- [x] Proper cleanup in `onunload()`
|
||||
|
||||
## ✅ API Usage
|
||||
|
||||
- [x] Uses official Obsidian API
|
||||
- [x] No direct DOM manipulation outside Obsidian API
|
||||
- [x] Proper use of `App`, `Plugin`, `PluginSettingTab`
|
||||
- [x] Proper use of `TFile`, `Vault` APIs
|
||||
- [x] Proper use of `Modal`, `Notice` for UI
|
||||
- [x] Proper use of `WorkspaceLeaf`, `ItemView` for custom views
|
||||
|
||||
## ✅ Documentation
|
||||
|
||||
- [x] README.md with:
|
||||
- [x] Plugin description
|
||||
- [x] Features list
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Configuration guide
|
||||
- [x] CHANGELOG.md for version history
|
||||
- [x] LICENSE file
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **The Git File Push plugin fully complies with Obsidian plugin development guidelines.**
|
||||
|
||||
All required files are present, build configuration is correct, and the plugin follows best practices for Obsidian plugin development.
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,4 +1,4 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
Copyright (C) 2026 by tianyao
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
|
|
|
|||
177
README.md
177
README.md
|
|
@ -1,90 +1,139 @@
|
|||
# Obsidian Sample Plugin
|
||||
# Git File Push
|
||||
|
||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
||||
An Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories across mobile and desktop platforms.
|
||||
|
||||
This project uses TypeScript to provide type checking and documentation.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
|
||||
## Features
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open modal (simple)" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
- Registers a global click event and output 'click' to the console.
|
||||
- Registers a global interval which logs 'setInterval' to the console.
|
||||
- **Multiple Git Services**: Support for both GitLab and GitHub (user selectable)
|
||||
- **Push to Remote**: Upload individual notes to your Git repository
|
||||
- **Pull from Remote**: Download and sync notes from your repository
|
||||
- **Batch Operations**: Push or pull all modified files at once
|
||||
- **Sync Status View**: Visual dashboard showing all files' sync status with complete git diff
|
||||
- **Conflict Resolution**: Visual diff viewer to compare local and remote versions when conflicts occur
|
||||
- **Vault Folder Filter**: Optionally sync only files within a specific vault folder
|
||||
- **Ribbon Icon**: Quick access button in the left sidebar for pushing the current note
|
||||
- **Command Palette**: Commands for pushing and pulling files (single or batch)
|
||||
- **Context Menu**: Right-click any file to push or pull directly from the file menu
|
||||
- **Cross-Platform**: Works on both desktop and mobile versions of Obsidian
|
||||
- **Sync Tracking**: Maintains metadata to track last synced SHA and timestamp for each file
|
||||
- **Conflict Detection**: Automatically detects conflicts and prompts for resolution
|
||||
|
||||
## First time developing plugins?
|
||||
## Setup
|
||||
|
||||
Quick starting guide for new plugin devs:
|
||||
1. Install the plugin in Obsidian
|
||||
2. Open Settings → Git File Push
|
||||
3. Select your preferred Git service (GitLab or GitHub)
|
||||
4. Configure the settings based on your choice:
|
||||
|
||||
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
|
||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
||||
- Reload Obsidian to load the new version of your plugin.
|
||||
- Enable plugin in settings window.
|
||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
||||
### GitLab Configuration
|
||||
- **GitLab Personal Access Token**: Create a token in GitLab (User Settings → Access Tokens) with "API" scope
|
||||
- **GitLab Base URL**: Defaults to `https://gitlab.com` (change if using self-hosted GitLab)
|
||||
- **Project ID**: Found in your GitLab project's overview page
|
||||
|
||||
## Releasing new releases
|
||||
### GitHub Configuration
|
||||
- **GitHub Personal Access Token**: Create a token in GitHub (Settings → Developer Settings → Personal Access Tokens) with "repo" scope
|
||||
- **Repository Owner**: Your GitHub username or organization name
|
||||
- **Repository Name**: Name of the GitHub repository
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
|
||||
- Publish the release.
|
||||
### Common Settings
|
||||
- **Branch**: The branch to sync with (defaults to `main`)
|
||||
- **Root Path**: Optional path prefix in the repository (e.g., "notes" to store files in a notes/ folder)
|
||||
- **Vault Folder**: Optional vault folder to sync (e.g., "sync" to only sync files in the sync/ folder, leave empty to sync all files)
|
||||
|
||||
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
|
||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
||||
## Usage
|
||||
|
||||
## Adding your plugin to the community plugin list
|
||||
### View Sync Status
|
||||
- Click the list-checks icon in the left ribbon, or
|
||||
- Use Command Palette: "Open sync status view"
|
||||
- Click "Refresh Status" to check all files against the remote repository
|
||||
- View complete git diff for modified files
|
||||
- Push or pull individual files directly from the status view
|
||||
- Use "Push All Modified" or "Pull All Modified" for batch operations
|
||||
|
||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
||||
- Publish an initial version.
|
||||
- Make sure you have a `README.md` file in the root of your repo.
|
||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
||||
### Push Files
|
||||
**Single file:**
|
||||
- Click the cloud upload icon in the left ribbon, or
|
||||
- Use Command Palette: "Push current file to GitLab/GitHub", or
|
||||
- Right-click a file and select "Push to GitLab/GitHub"
|
||||
|
||||
## How to use
|
||||
**All files:**
|
||||
- Use Command Palette: "Push all markdown files"
|
||||
- Or use "Push All Modified" button in the sync status view
|
||||
|
||||
- Clone this repo.
|
||||
- Make sure your NodeJS is at least v16 (`node --version`).
|
||||
- `npm i` or `yarn` to install dependencies.
|
||||
- `npm run dev` to start compilation in watch mode.
|
||||
### Pull Files
|
||||
**Single file:**
|
||||
- Use Command Palette: "Pull current file from GitLab/GitHub", or
|
||||
- Right-click a file and select "Pull from GitLab/GitHub"
|
||||
|
||||
## Manually installing the plugin
|
||||
**All files:**
|
||||
- Use Command Palette: "Pull all markdown files"
|
||||
- Or use "Pull All Modified" button in the sync status view
|
||||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
### Conflict Resolution
|
||||
When a conflict is detected:
|
||||
1. A modal will appear showing both local and remote versions
|
||||
2. Review the differences in the diff viewer
|
||||
3. Choose to keep either the local or remote version
|
||||
4. The chosen version will be synced
|
||||
|
||||
## Improve code quality with eslint
|
||||
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
|
||||
- This project already has eslint preconfigured, you can invoke a check by running`npm run lint`
|
||||
- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines.
|
||||
- A GitHub action is preconfigured to automatically lint every commit on all branches.
|
||||
## Development
|
||||
|
||||
## Funding URL
|
||||
### Prerequisites
|
||||
- Node.js v16 or higher
|
||||
- npm or yarn
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
### Setup
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/tianyao/gitlab-files-push.git
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
# Start development mode (watch mode)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
### Available Commands
|
||||
- `npm run dev` - Build in watch mode using esbuild
|
||||
- `npm run build` - Type check and build for production
|
||||
- `npm run lint` - Run ESLint checks
|
||||
- `npm run test` - Run Vitest test suite
|
||||
- `npm run test:ui` - Run tests with UI
|
||||
- `npm run version` - Bump version in manifest.json and versions.json
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
### Manual Installation
|
||||
|
||||
Copy `main.js`, `manifest.json`, and `styles.css` (if exists) to your vault:
|
||||
```
|
||||
VaultFolder/.obsidian/plugins/git-file-push/
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
## Project Structure
|
||||
|
||||
See https://docs.obsidian.md
|
||||
- `src/main.ts` - Main plugin class and command registration
|
||||
- `src/settings.ts` - Settings interface and configuration UI
|
||||
- `src/services/gitlab-service.ts` - GitLab API integration
|
||||
- `src/logic/sync-manager.ts` - Sync logic and conflict handling
|
||||
- `esbuild.config.mjs` - Build configuration
|
||||
|
||||
## Releasing
|
||||
|
||||
1. Update `minAppVersion` in `manifest.json` if needed
|
||||
2. Run `npm run version` to bump version numbers
|
||||
3. Create a GitHub release with tag matching the version
|
||||
4. Upload `manifest.json`, `main.js`, and `styles.css` as release assets
|
||||
|
||||
## Code Quality
|
||||
|
||||
- ESLint is configured with Obsidian-specific rules
|
||||
- Run `npm run lint` to check for issues
|
||||
- Husky pre-commit hooks ensure code quality
|
||||
|
||||
## License
|
||||
|
||||
0-BSD
|
||||
|
||||
## Author
|
||||
|
||||
[tianyao](https://github.com/tianyao)
|
||||
|
|
|
|||
138
RELEASE.md
Normal file
138
RELEASE.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Release Guide
|
||||
|
||||
This guide explains how to create a new release for the Git File Push plugin.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ensure all changes are committed and pushed to the main branch
|
||||
- All tests pass (`npm run test`)
|
||||
- Build succeeds (`npm run build`)
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Update Version
|
||||
|
||||
Run the version bump script:
|
||||
|
||||
```bash
|
||||
# For patch version (1.0.0 -> 1.0.1)
|
||||
npm version patch
|
||||
|
||||
# For minor version (1.0.0 -> 1.1.0)
|
||||
npm version minor
|
||||
|
||||
# For major version (1.0.0 -> 2.0.0)
|
||||
npm version major
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `package.json` version
|
||||
- Update `manifest.json` version
|
||||
- Update `versions.json` with the new version
|
||||
- Create a git commit with the version bump
|
||||
|
||||
### 2. Update CHANGELOG.md
|
||||
|
||||
Edit `CHANGELOG.md` and add a new section for the version:
|
||||
|
||||
```markdown
|
||||
## [1.0.1] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- New feature description
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
|
||||
### Changed
|
||||
- Change description
|
||||
|
||||
[1.0.1]: https://github.com/tianyao/gitlab-files-push/releases/tag/1.0.1
|
||||
```
|
||||
|
||||
Commit the changelog:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: update changelog for v1.0.1"
|
||||
```
|
||||
|
||||
### 3. Create and Push Tag
|
||||
|
||||
```bash
|
||||
# Create a tag matching the version
|
||||
git tag 1.0.1
|
||||
|
||||
# Push the tag to trigger the release workflow
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
### 4. Automated Release
|
||||
|
||||
The GitHub Actions workflow will automatically:
|
||||
1. Run tests
|
||||
2. Build the plugin
|
||||
3. Create a GitHub release
|
||||
4. Upload `main.js`, `manifest.json`, and `styles.css` as release assets
|
||||
|
||||
### 5. Verify Release
|
||||
|
||||
1. Go to https://github.com/tianyao/git-file-push/releases
|
||||
2. Verify the new release is created
|
||||
3. Check that all three files are attached
|
||||
4. Review the release notes
|
||||
|
||||
## Manual Release (if needed)
|
||||
|
||||
If the automated workflow fails, you can create a release manually:
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Go to GitHub → Releases → Draft a new release
|
||||
3. Choose the tag you created
|
||||
4. Add release notes from CHANGELOG.md
|
||||
5. Upload `main.js`, `manifest.json`, and `styles.css`
|
||||
6. Publish the release
|
||||
|
||||
## Version Numbering
|
||||
|
||||
Follow [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking changes
|
||||
- **MINOR** (0.x.0): New features, backwards compatible
|
||||
- **PATCH** (0.0.x): Bug fixes, backwards compatible
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow fails on test
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Fix any failing tests before creating the release.
|
||||
|
||||
### Workflow fails on build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Ensure the build succeeds locally.
|
||||
|
||||
### Tag already exists
|
||||
|
||||
```bash
|
||||
# Delete local tag
|
||||
git tag -d 1.0.1
|
||||
|
||||
# Delete remote tag
|
||||
git push origin :refs/tags/1.0.1
|
||||
|
||||
# Create new tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
191
RELEASE_GUIDE.md
Normal file
191
RELEASE_GUIDE.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# 发布新版本指南
|
||||
|
||||
本项目提供两种发布方式:
|
||||
|
||||
## 方式一:自动版本管理(推荐)
|
||||
|
||||
使用 GitHub Actions 自动更新版本号并发布。
|
||||
|
||||
### 步骤:
|
||||
|
||||
1. 进入 GitHub 仓库
|
||||
2. 点击 **Actions** 标签
|
||||
3. 选择 **Auto Release** workflow
|
||||
4. 点击 **Run workflow** 按钮
|
||||
5. 选择版本类型:
|
||||
- **patch**: 修复 bug (1.0.0 → 1.0.1)
|
||||
- **minor**: 新功能 (1.0.0 → 1.1.0)
|
||||
- **major**: 重大变更 (1.0.0 → 2.0.0)
|
||||
6. 点击 **Run workflow**
|
||||
|
||||
### 自动执行的操作:
|
||||
|
||||
✅ 运行测试
|
||||
✅ 更新 package.json 版本号
|
||||
✅ 更新 manifest.json 版本号
|
||||
✅ 更新 versions.json
|
||||
✅ 更新 CHANGELOG.md
|
||||
✅ 提交并推送更改
|
||||
✅ 创建 git tag
|
||||
✅ 创建 GitHub Release
|
||||
✅ 上传 main.js, manifest.json, styles.css
|
||||
|
||||
### 优点:
|
||||
|
||||
- 一键完成所有操作
|
||||
- 不会遗漏任何步骤
|
||||
- 版本号自动同步
|
||||
- 减少人为错误
|
||||
|
||||
---
|
||||
|
||||
## 方式二:手动发布
|
||||
|
||||
适合需要更多控制的情况。
|
||||
|
||||
### 步骤:
|
||||
|
||||
1. **更新版本号**
|
||||
```bash
|
||||
# 修复 bug
|
||||
npm version patch
|
||||
|
||||
# 新功能
|
||||
npm version minor
|
||||
|
||||
# 重大变更
|
||||
npm version major
|
||||
```
|
||||
|
||||
2. **手动更新 manifest.json**
|
||||
```bash
|
||||
# 编辑 manifest.json,更新 version 字段
|
||||
```
|
||||
|
||||
3. **手动更新 versions.json**
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0",
|
||||
"1.0.1": "0.15.0" // 添加新版本
|
||||
}
|
||||
```
|
||||
|
||||
4. **更新 CHANGELOG.md**
|
||||
```markdown
|
||||
## [1.0.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
```
|
||||
|
||||
5. **提交更改**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
```
|
||||
|
||||
6. **创建并推送 tag**
|
||||
```bash
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
7. **GitHub Actions 自动创建 Release**
|
||||
- release.yml workflow 会自动触发
|
||||
- 构建插件
|
||||
- 创建 GitHub Release(草稿)
|
||||
- 上传文件
|
||||
|
||||
8. **编辑 Release**
|
||||
- 进入 GitHub Releases
|
||||
- 编辑草稿 release
|
||||
- 添加发布说明
|
||||
- 发布
|
||||
|
||||
---
|
||||
|
||||
## 版本号规范
|
||||
|
||||
遵循 [语义化版本](https://semver.org/lang/zh-CN/):
|
||||
|
||||
- **MAJOR (x.0.0)**: 不兼容的 API 变更
|
||||
- **MINOR (0.x.0)**: 向后兼容的新功能
|
||||
- **PATCH (0.0.x)**: 向后兼容的 bug 修复
|
||||
|
||||
### 示例:
|
||||
|
||||
- `1.0.0` → `1.0.1`: 修复了一个 bug
|
||||
- `1.0.1` → `1.1.0`: 添加了新功能
|
||||
- `1.1.0` → `2.0.0`: 重大变更,可能不兼容旧版本
|
||||
|
||||
---
|
||||
|
||||
## 发布检查清单
|
||||
|
||||
在发布前确认:
|
||||
|
||||
- [ ] 所有测试通过 (`npm run test`)
|
||||
- [ ] 构建成功 (`npm run build`)
|
||||
- [ ] Lint 检查通过 (`npm run lint`)
|
||||
- [ ] 在 Obsidian 中测试过
|
||||
- [ ] 更新了 CHANGELOG.md
|
||||
- [ ] README.md 是最新的
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### Workflow 失败
|
||||
|
||||
**测试失败:**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
修复失败的测试后重试。
|
||||
|
||||
**构建失败:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
确保本地构建成功。
|
||||
|
||||
**权限错误:**
|
||||
- 检查仓库设置 → Actions → General → Workflow permissions
|
||||
- 选择 "Read and write permissions"
|
||||
|
||||
### Tag 已存在
|
||||
|
||||
```bash
|
||||
# 删除本地 tag
|
||||
git tag -d 1.0.1
|
||||
|
||||
# 删除远程 tag
|
||||
git push origin :refs/tags/1.0.1
|
||||
|
||||
# 重新创建
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 首次发布到 Obsidian 社区
|
||||
|
||||
完成首次 release 后:
|
||||
|
||||
1. Fork [obsidian-releases](https://github.com/obsidianmd/obsidian-releases)
|
||||
2. 编辑 `community-plugins.json`,添加:
|
||||
```json
|
||||
{
|
||||
"id": "git-file-push",
|
||||
"name": "Git File Push",
|
||||
"author": "tianyao",
|
||||
"description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.",
|
||||
"repo": "tianyao/git-file-push"
|
||||
}
|
||||
```
|
||||
3. 创建 Pull Request
|
||||
4. 等待 Obsidian 团队审核
|
||||
|
||||
详见 [SUBMISSION.md](./SUBMISSION.md)
|
||||
191
RELEASE_OPTIONS.md
Normal file
191
RELEASE_OPTIONS.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# 发布方式总览
|
||||
|
||||
本项目提供三种发布方式,根据你的需求选择:
|
||||
|
||||
## 🚀 方式一:Semantic Release(推荐)
|
||||
|
||||
**完全自动化,基于 commit message**
|
||||
|
||||
### 优点
|
||||
- ✅ 完全自动化,无需手动操作
|
||||
- ✅ 强制规范的 commit message
|
||||
- ✅ 自动生成 CHANGELOG
|
||||
- ✅ 版本号由代码变更决定,更合理
|
||||
- ✅ 不会遗漏任何步骤
|
||||
|
||||
### 使用方法
|
||||
```bash
|
||||
# 1. 按规范提交代码
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# 2. 推送到主分支
|
||||
git push origin main
|
||||
|
||||
# 3. 自动发布!
|
||||
```
|
||||
|
||||
### Commit 规范
|
||||
- `feat:` → 新功能 → MINOR 版本 (1.0.0 → 1.1.0)
|
||||
- `fix:` → Bug 修复 → PATCH 版本 (1.0.0 → 1.0.1)
|
||||
- `BREAKING CHANGE:` → 重大变更 → MAJOR 版本 (1.0.0 → 2.0.0)
|
||||
|
||||
### 配置文件
|
||||
- `.releaserc.json` - Semantic Release 配置
|
||||
- `.github/workflows/semantic-release.yml` - GitHub Actions workflow
|
||||
|
||||
### 详细文档
|
||||
参见 [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 方式二:Auto Release
|
||||
|
||||
**手动触发,选择版本类型**
|
||||
|
||||
### 优点
|
||||
- ✅ 可以手动控制发布时机
|
||||
- ✅ 简单的版本选择(patch/minor/major)
|
||||
- ✅ 自动更新所有文件
|
||||
|
||||
### 使用方法
|
||||
1. 进入 GitHub → Actions → Auto Release
|
||||
2. 点击 "Run workflow"
|
||||
3. 选择版本类型:
|
||||
- **patch**: 1.0.0 → 1.0.1
|
||||
- **minor**: 1.0.0 → 1.1.0
|
||||
- **major**: 1.0.0 → 2.0.0
|
||||
4. 点击 "Run workflow"
|
||||
|
||||
### 自动执行
|
||||
- 运行测试
|
||||
- 更新版本号(package.json, manifest.json, versions.json)
|
||||
- 更新 CHANGELOG
|
||||
- 创建 commit 和 tag
|
||||
- 构建插件
|
||||
- 创建 GitHub Release
|
||||
- 上传文件
|
||||
|
||||
### 配置文件
|
||||
- `.github/workflows/auto-release.yml`
|
||||
|
||||
---
|
||||
|
||||
## 📦 方式三:Manual Release
|
||||
|
||||
**完全手动控制**
|
||||
|
||||
### 优点
|
||||
- ✅ 完全控制每个步骤
|
||||
- ✅ 适合特殊情况
|
||||
|
||||
### 使用方法
|
||||
```bash
|
||||
# 1. 更新版本号
|
||||
npm version patch # 或 minor, major
|
||||
|
||||
# 2. 手动更新 manifest.json 和 versions.json
|
||||
|
||||
# 3. 更新 CHANGELOG.md
|
||||
|
||||
# 4. 提交更改
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
|
||||
# 5. 创建并推送 tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
|
||||
# 6. GitHub Actions 自动创建 Release(草稿)
|
||||
|
||||
# 7. 手动编辑并发布 Release
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
- `.github/workflows/release.yml`
|
||||
|
||||
---
|
||||
|
||||
## 📊 对比表
|
||||
|
||||
| 特性 | Semantic Release | Auto Release | Manual Release |
|
||||
|------|-----------------|--------------|----------------|
|
||||
| 自动化程度 | 🟢 完全自动 | 🟡 半自动 | 🔴 手动 |
|
||||
| 版本决策 | 🟢 基于代码变更 | 🟡 手动选择 | 🟡 手动选择 |
|
||||
| CHANGELOG | 🟢 自动生成 | 🟡 需要手动编辑 | 🔴 完全手动 |
|
||||
| Commit 规范 | 🟢 强制规范 | 🔴 无要求 | 🔴 无要求 |
|
||||
| 出错风险 | 🟢 低 | 🟡 中 | 🔴 高 |
|
||||
| 学习成本 | 🟡 需要学习规范 | 🟢 简单 | 🟢 简单 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐使用场景
|
||||
|
||||
### 使用 Semantic Release(推荐)
|
||||
- ✅ 日常开发和发布
|
||||
- ✅ 团队协作项目
|
||||
- ✅ 需要规范的 commit history
|
||||
- ✅ 希望完全自动化
|
||||
|
||||
### 使用 Auto Release
|
||||
- ✅ 不想学习 commit 规范
|
||||
- ✅ 需要手动控制发布时机
|
||||
- ✅ 快速发布
|
||||
|
||||
### 使用 Manual Release
|
||||
- ✅ 特殊情况需要完全控制
|
||||
- ✅ 调试发布流程
|
||||
- ✅ 紧急修复
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 启用 Semantic Release
|
||||
|
||||
如果你选择使用 Semantic Release,建议禁用其他两个 workflow 以避免冲突:
|
||||
|
||||
```bash
|
||||
# 重命名或删除其他 workflows
|
||||
mv .github/workflows/auto-release.yml .github/workflows/auto-release.yml.disabled
|
||||
mv .github/workflows/release.yml .github/workflows/release.yml.disabled
|
||||
```
|
||||
|
||||
### 同时使用多个方式
|
||||
|
||||
如果你想保留多个选项:
|
||||
- Semantic Release 用于日常自动发布
|
||||
- Auto Release 用于紧急手动发布
|
||||
- Manual Release 作为备用
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md) - Semantic Release 详细指南
|
||||
- [RELEASE_GUIDE.md](./RELEASE_GUIDE.md) - 手动发布指南
|
||||
- [WORKFLOWS.md](./WORKFLOWS.md) - GitHub Actions 说明
|
||||
- [SUBMISSION.md](./SUBMISSION.md) - Obsidian 插件提交指南
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 推荐:使用 Semantic Release
|
||||
|
||||
1. **学习 commit 规范**(5 分钟)
|
||||
- `feat:` 新功能
|
||||
- `fix:` Bug 修复
|
||||
- `docs:` 文档更新
|
||||
|
||||
2. **正常开发**
|
||||
```bash
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **自动发布!**
|
||||
- 无需其他操作
|
||||
- 检查 GitHub Releases 查看结果
|
||||
|
||||
就这么简单!🎉
|
||||
236
SEMANTIC_RELEASE.md
Normal file
236
SEMANTIC_RELEASE.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# Semantic Release 使用指南
|
||||
|
||||
本项目使用 [semantic-release](https://semantic-release.gitbook.io/) 自动管理版本号和发布。
|
||||
|
||||
## 工作原理
|
||||
|
||||
Semantic Release 根据 **commit message** 自动决定版本号:
|
||||
|
||||
- `feat:` → **MINOR** 版本 (1.0.0 → 1.1.0)
|
||||
- `fix:` → **PATCH** 版本 (1.0.0 → 1.0.1)
|
||||
- `BREAKING CHANGE:` → **MAJOR** 版本 (1.0.0 → 2.0.0)
|
||||
|
||||
## Commit Message 规范
|
||||
|
||||
使用 [Conventional Commits](https://www.conventionalcommits.org/) 格式:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Type 类型
|
||||
|
||||
| Type | 说明 | 版本影响 |
|
||||
|------|------|---------|
|
||||
| `feat` | 新功能 | MINOR |
|
||||
| `fix` | Bug 修复 | PATCH |
|
||||
| `perf` | 性能优化 | PATCH |
|
||||
| `refactor` | 代码重构 | PATCH |
|
||||
| `docs` | 文档更新 | 无 |
|
||||
| `style` | 代码格式 | 无 |
|
||||
| `test` | 测试相关 | 无 |
|
||||
| `build` | 构建系统 | 无 |
|
||||
| `ci` | CI 配置 | 无 |
|
||||
| `chore` | 其他杂项 | 无 |
|
||||
|
||||
### 示例
|
||||
|
||||
**新功能 (MINOR):**
|
||||
```bash
|
||||
git commit -m "feat: add GitHub support"
|
||||
git commit -m "feat(sync): add batch pull operation"
|
||||
```
|
||||
|
||||
**Bug 修复 (PATCH):**
|
||||
```bash
|
||||
git commit -m "fix: resolve conflict detection issue"
|
||||
git commit -m "fix(ui): correct button alignment"
|
||||
```
|
||||
|
||||
**重大变更 (MAJOR):**
|
||||
```bash
|
||||
git commit -m "feat: redesign settings API
|
||||
|
||||
BREAKING CHANGE: settings structure has changed"
|
||||
```
|
||||
|
||||
**不触发发布:**
|
||||
```bash
|
||||
git commit -m "docs: update README"
|
||||
git commit -m "chore: update dependencies"
|
||||
git commit -m "test: add unit tests"
|
||||
```
|
||||
|
||||
## 自动发布流程
|
||||
|
||||
### 触发条件
|
||||
|
||||
当你推送到 `main` 或 `master` 分支时,semantic-release 会自动:
|
||||
|
||||
1. ✅ 分析所有 commit messages
|
||||
2. ✅ 决定新版本号
|
||||
3. ✅ 更新 package.json
|
||||
4. ✅ 更新 manifest.json
|
||||
5. ✅ 更新 versions.json
|
||||
6. ✅ 生成 CHANGELOG.md
|
||||
7. ✅ 创建 git commit 和 tag
|
||||
8. ✅ 构建插件
|
||||
9. ✅ 创建 GitHub Release
|
||||
10. ✅ 上传 main.js, manifest.json, styles.css
|
||||
|
||||
### 使用步骤
|
||||
|
||||
1. **开发功能并提交**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new sync feature"
|
||||
```
|
||||
|
||||
2. **推送到主分支**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **自动发布**
|
||||
- GitHub Actions 自动运行
|
||||
- 检查 commit messages
|
||||
- 如果有 `feat` 或 `fix`,自动创建新版本
|
||||
- 自动发布到 GitHub Releases
|
||||
|
||||
## 配置文件
|
||||
|
||||
### .releaserc.json
|
||||
|
||||
```json
|
||||
{
|
||||
"branches": ["main", "master"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/exec",
|
||||
"@semantic-release/git",
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
`.github/workflows/semantic-release.yml` 会在推送到 main/master 时自动运行。
|
||||
|
||||
## 版本号示例
|
||||
|
||||
假设当前版本是 `1.0.0`:
|
||||
|
||||
| Commits | 新版本 |
|
||||
|---------|--------|
|
||||
| `fix: bug fix` | 1.0.1 |
|
||||
| `feat: new feature` | 1.1.0 |
|
||||
| `feat: feature with BREAKING CHANGE` | 2.0.0 |
|
||||
| `fix: bug` + `feat: feature` | 1.1.0 |
|
||||
| `docs: update` | 无发布 |
|
||||
|
||||
## 跳过发布
|
||||
|
||||
如果你不想触发发布,在 commit message 中添加 `[skip ci]`:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README [skip ci]"
|
||||
```
|
||||
|
||||
## 手动触发发布
|
||||
|
||||
如果需要手动触发:
|
||||
|
||||
```bash
|
||||
npm run semantic-release
|
||||
```
|
||||
|
||||
## 查看发布历史
|
||||
|
||||
所有发布都会记录在:
|
||||
- GitHub Releases: https://github.com/tianyao/git-file-push/releases
|
||||
- CHANGELOG.md: 自动生成的变更日志
|
||||
|
||||
## 与其他 Workflows 的关系
|
||||
|
||||
现在项目有三个发布方式:
|
||||
|
||||
1. **semantic-release.yml** (推荐) - 自动根据 commit 发布
|
||||
2. **auto-release.yml** - 手动触发,选择版本类型
|
||||
3. **release.yml** - 手动创建 tag 触发
|
||||
|
||||
**推荐使用 semantic-release**,因为它:
|
||||
- ✅ 完全自动化
|
||||
- ✅ 强制规范的 commit message
|
||||
- ✅ 自动生成 CHANGELOG
|
||||
- ✅ 不会遗漏步骤
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **每个 commit 只做一件事**
|
||||
```bash
|
||||
# 好
|
||||
git commit -m "feat: add GitHub support"
|
||||
git commit -m "fix: resolve sync conflict"
|
||||
|
||||
# 不好
|
||||
git commit -m "feat: add GitHub support and fix sync conflict"
|
||||
```
|
||||
|
||||
2. **使用 scope 组织 commits**
|
||||
```bash
|
||||
git commit -m "feat(sync): add batch operations"
|
||||
git commit -m "fix(ui): correct modal layout"
|
||||
git commit -m "docs(readme): update installation guide"
|
||||
```
|
||||
|
||||
3. **重大变更要明确说明**
|
||||
```bash
|
||||
git commit -m "feat: redesign API
|
||||
|
||||
BREAKING CHANGE: The settings API has been completely redesigned.
|
||||
Old settings will need to be migrated manually."
|
||||
```
|
||||
|
||||
4. **开发时使用 feature branch**
|
||||
```bash
|
||||
git checkout -b feature/github-support
|
||||
# 开发...
|
||||
git commit -m "feat: add GitHub support"
|
||||
git push origin feature/github-support
|
||||
# 创建 PR 合并到 main
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 没有触发发布
|
||||
|
||||
检查:
|
||||
- Commit message 格式是否正确
|
||||
- 是否推送到 main/master 分支
|
||||
- GitHub Actions 是否有权限
|
||||
|
||||
### 版本号不符合预期
|
||||
|
||||
检查:
|
||||
- Commit message 的 type 是否正确
|
||||
- 是否有 BREAKING CHANGE
|
||||
|
||||
### 构建失败
|
||||
|
||||
检查:
|
||||
- 测试是否通过 (`npm run test`)
|
||||
- 构建是否成功 (`npm run build`)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Semantic Release 文档](https://semantic-release.gitbook.io/)
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [Commit Message 规范](https://www.conventionalcommits.org/zh-hans/)
|
||||
199
SUBMISSION.md
Normal file
199
SUBMISSION.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Obsidian Plugin Submission Checklist
|
||||
|
||||
This checklist ensures the Git File Push plugin meets all requirements for submission to the Obsidian Community Plugins directory.
|
||||
|
||||
## ✅ Required Files
|
||||
|
||||
- [x] **README.md**
|
||||
- [x] Clear description of what the plugin does
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Screenshots or examples (optional but recommended)
|
||||
|
||||
- [x] **manifest.json**
|
||||
- [x] `id`: "git-file-push" (lowercase, hyphens only)
|
||||
- [x] `name`: "Git File Push"
|
||||
- [x] `version`: "1.0.0" (semantic versioning)
|
||||
- [x] `minAppVersion`: "0.15.0"
|
||||
- [x] `description`: Clear, concise description
|
||||
- [x] `author`: "tianyao"
|
||||
- [x] `authorUrl`: Valid GitHub profile URL
|
||||
- [x] `isDesktopOnly`: false
|
||||
|
||||
- [x] **versions.json**
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **LICENSE**
|
||||
- [x] Open source license (0-BSD)
|
||||
- [x] Compatible with Obsidian's requirements
|
||||
|
||||
- [x] **main.js** (in releases, not in repo)
|
||||
- [x] Built and minified
|
||||
- [x] Uploaded to GitHub releases
|
||||
|
||||
- [x] **styles.css** (optional)
|
||||
- [x] Plugin-specific styles
|
||||
|
||||
## ✅ GitHub Repository Requirements
|
||||
|
||||
- [x] **Public repository**
|
||||
- [x] **GitHub releases**
|
||||
- [x] Release tagged with version number (e.g., "1.0.0", NOT "v1.0.0")
|
||||
- [x] Release includes: main.js, manifest.json, styles.css
|
||||
- [x] **.gitignore**
|
||||
- [x] Excludes node_modules
|
||||
- [x] Excludes main.js (built file)
|
||||
- [x] Excludes *.map files
|
||||
|
||||
## ✅ Code Quality
|
||||
|
||||
- [x] **TypeScript**
|
||||
- [x] Properly typed
|
||||
- [x] No critical type errors
|
||||
- [x] Compiles successfully
|
||||
|
||||
- [x] **Plugin Class**
|
||||
- [x] Extends `Plugin` from Obsidian API
|
||||
- [x] Implements `onload()`
|
||||
- [x] Implements `onunload()`
|
||||
- [x] Proper cleanup in `onunload()`
|
||||
|
||||
- [x] **API Usage**
|
||||
- [x] Uses official Obsidian API only
|
||||
- [x] No private API usage
|
||||
- [x] No direct DOM manipulation outside API
|
||||
|
||||
- [x] **Error Handling**
|
||||
- [x] Proper try-catch blocks
|
||||
- [x] User-friendly error messages
|
||||
- [x] No unhandled promise rejections
|
||||
|
||||
## ✅ Build Configuration
|
||||
|
||||
- [x] **esbuild or similar bundler**
|
||||
- [x] Bundles to single main.js
|
||||
- [x] External: obsidian, electron, @codemirror/*, @lezer/*
|
||||
- [x] Format: CommonJS (cjs)
|
||||
- [x] Target: ES2018 or later
|
||||
|
||||
- [x] **package.json**
|
||||
- [x] Build script defined
|
||||
- [x] Version script defined (optional)
|
||||
- [x] Dependencies properly listed
|
||||
|
||||
## ✅ Release Process
|
||||
|
||||
- [x] **GitHub Actions** (optional but recommended)
|
||||
- [x] Automated release workflow
|
||||
- [x] Triggers on tag push
|
||||
- [x] Builds and uploads assets
|
||||
|
||||
- [x] **Version Management**
|
||||
- [x] Semantic versioning (MAJOR.MINOR.PATCH)
|
||||
- [x] manifest.json version matches tag
|
||||
- [x] versions.json updated with minAppVersion
|
||||
|
||||
## ✅ Documentation
|
||||
|
||||
- [x] **README.md includes:**
|
||||
- [x] Plugin name and description
|
||||
- [x] Features list
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Configuration options
|
||||
|
||||
- [x] **CHANGELOG.md** (recommended)
|
||||
- [x] Version history
|
||||
- [x] Changes for each version
|
||||
|
||||
## ✅ Testing
|
||||
|
||||
- [x] **Manual Testing**
|
||||
- [x] Tested on desktop
|
||||
- [x] Tested on mobile (if not desktop-only)
|
||||
- [x] No console errors
|
||||
- [x] No performance issues
|
||||
|
||||
- [x] **Automated Tests** (optional but recommended)
|
||||
- [x] Unit tests
|
||||
- [x] Tests pass in CI
|
||||
|
||||
## ✅ Security & Privacy
|
||||
|
||||
- [x] **No malicious code**
|
||||
- [x] **No data collection without consent**
|
||||
- [x] **No external API calls without user configuration**
|
||||
- [x] **Secure credential storage**
|
||||
- [x] Uses Obsidian's data storage
|
||||
- [x] No credentials in code
|
||||
|
||||
## ✅ Mobile Compatibility
|
||||
|
||||
- [x] **isDesktopOnly: false**
|
||||
- [x] **No Node.js-specific APIs** (unless desktop-only)
|
||||
- [x] **Responsive UI**
|
||||
- [x] **Touch-friendly controls**
|
||||
|
||||
## 📋 Submission Steps
|
||||
|
||||
1. **Create GitHub Release**
|
||||
```bash
|
||||
git tag 1.0.0
|
||||
git push origin 1.0.0
|
||||
```
|
||||
- Release will be created automatically by GitHub Actions
|
||||
- Ensure main.js, manifest.json, and styles.css are attached
|
||||
|
||||
2. **Fork obsidian-releases**
|
||||
- Fork: https://github.com/obsidianmd/obsidian-releases
|
||||
|
||||
3. **Add Plugin to community-plugins.json**
|
||||
```json
|
||||
{
|
||||
"id": "git-file-push",
|
||||
"name": "Git File Push",
|
||||
"author": "tianyao",
|
||||
"description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.",
|
||||
"repo": "tianyao/git-file-push"
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create Pull Request**
|
||||
- Title: "Add Git File Push plugin"
|
||||
- Description: Brief description of the plugin
|
||||
- Link to your repository
|
||||
- Link to your first release
|
||||
|
||||
5. **Wait for Review**
|
||||
- Obsidian team will review
|
||||
- May request changes
|
||||
- Once approved, plugin will be available in Community Plugins
|
||||
|
||||
## 🔍 Pre-Submission Checklist
|
||||
|
||||
Before submitting, verify:
|
||||
|
||||
- [ ] Plugin works correctly in Obsidian
|
||||
- [ ] No console errors
|
||||
- [ ] README is clear and complete
|
||||
- [ ] GitHub release exists with correct assets
|
||||
- [ ] manifest.json version matches release tag
|
||||
- [ ] License is included
|
||||
- [ ] Code is clean and well-documented
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [Obsidian Plugin Developer Docs](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin)
|
||||
- [Release your plugin with GitHub Actions](https://docs.obsidian.md/Plugins/Releasing/Release+your+plugin+with+GitHub+Actions)
|
||||
- [Submit your plugin](https://docs.obsidian.md/Plugins/Releasing/Submit+your+plugin)
|
||||
- [Community Plugins Repository](https://github.com/obsidianmd/obsidian-releases)
|
||||
|
||||
## ✅ Status
|
||||
|
||||
**The Git File Push plugin is ready for submission to the Obsidian Community Plugins directory.**
|
||||
|
||||
All requirements are met. Follow the submission steps above to submit the plugin.
|
||||
122
WORKFLOWS.md
Normal file
122
WORKFLOWS.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# GitHub Actions Workflows
|
||||
|
||||
This project includes automated workflows for continuous integration and releases.
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Check (`.github/workflows/check.yml`)
|
||||
|
||||
Runs on every push and pull request to main/master/develop branches.
|
||||
|
||||
**Jobs:**
|
||||
- **Lint**: Runs ESLint to check code quality
|
||||
- **Test**: Runs the test suite with coverage
|
||||
- **Build**: Builds the plugin and uploads artifacts
|
||||
|
||||
**Triggers:**
|
||||
- Push to main, master, or develop branches
|
||||
- Pull requests to main, master, or develop branches
|
||||
|
||||
### 2. Auto Release (`.github/workflows/auto-release.yml`)
|
||||
|
||||
Automatically bumps version, creates a tag, and publishes a release.
|
||||
|
||||
**How to use:**
|
||||
1. Go to GitHub → Actions → Auto Release
|
||||
2. Click "Run workflow"
|
||||
3. Select version bump type:
|
||||
- **patch**: Bug fixes (1.0.0 → 1.0.1)
|
||||
- **minor**: New features (1.0.0 → 1.1.0)
|
||||
- **major**: Breaking changes (1.0.0 → 2.0.0)
|
||||
4. Click "Run workflow"
|
||||
|
||||
**What it does:**
|
||||
1. Runs tests
|
||||
2. Bumps version in package.json, manifest.json, and versions.json
|
||||
3. Updates CHANGELOG.md
|
||||
4. Commits and pushes changes
|
||||
5. Creates and pushes a git tag
|
||||
6. Creates a GitHub release
|
||||
7. Uploads main.js, manifest.json, and styles.css
|
||||
|
||||
### 3. Release (`.github/workflows/release.yml`)
|
||||
|
||||
Triggered when you manually push a tag.
|
||||
|
||||
**How to use:**
|
||||
```bash
|
||||
# Create and push a tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Runs tests
|
||||
2. Builds the plugin
|
||||
3. Creates a GitHub release
|
||||
4. Uploads release assets
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
### For Regular Development
|
||||
|
||||
1. Make changes and commit to a feature branch
|
||||
2. Create a pull request to main/master
|
||||
3. The Check workflow will run automatically
|
||||
4. Merge when all checks pass
|
||||
|
||||
### For Releases
|
||||
|
||||
**Option A: Automatic (Recommended)**
|
||||
1. Go to GitHub Actions → Auto Release
|
||||
2. Select version bump type
|
||||
3. Click "Run workflow"
|
||||
4. Done! The release is created automatically
|
||||
|
||||
**Option B: Manual**
|
||||
1. Update version manually:
|
||||
```bash
|
||||
npm run version
|
||||
```
|
||||
2. Update CHANGELOG.md
|
||||
3. Commit changes:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
```
|
||||
4. Create and push tag:
|
||||
```bash
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
5. The Release workflow will create the GitHub release
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
After each successful build in the Check workflow, artifacts are uploaded and available for 7 days:
|
||||
- main.js
|
||||
- manifest.json
|
||||
- styles.css
|
||||
|
||||
You can download these from the Actions tab → Select a workflow run → Artifacts section.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow fails on test
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
Fix any failing tests before releasing.
|
||||
|
||||
### Workflow fails on build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Ensure the build succeeds locally.
|
||||
|
||||
### Permission denied errors
|
||||
Make sure the repository has the correct permissions:
|
||||
- Settings → Actions → General → Workflow permissions
|
||||
- Select "Read and write permissions"
|
||||
- Check "Allow GitHub Actions to create and approve pull requests"
|
||||
|
|
@ -21,8 +21,17 @@ export default tseslint.config(
|
|||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-alert': 'off', // Allow confirm dialogs for user confirmation
|
||||
'@typescript-eslint/await-thenable': 'off', // Settings methods may not return promises
|
||||
}
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'obsidianmd/ui/sentence-case': 'off',
|
||||
}
|
||||
},
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "gitlab-files-push",
|
||||
"name": "GitLab Files Push",
|
||||
"id": "git-file-push",
|
||||
"name": "Git File Push",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Push and pull individual notes to GitLab across mobile and desktop.",
|
||||
"description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.",
|
||||
"author": "tianyao",
|
||||
"authorUrl": "https://github.com/tianyao",
|
||||
"isDesktopOnly": false
|
||||
|
|
|
|||
6747
package-lock.json
generated
6747
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gitlab-files-push",
|
||||
"name": "git-file-push",
|
||||
"version": "1.0.0",
|
||||
"description": "Push and pull individual notes to GitLab across mobile and desktop.",
|
||||
"description": "Push and pull individual notes to GitLab or GitHub across mobile and desktop.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -11,13 +11,18 @@
|
|||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"semantic-release": "semantic-release"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@eslint/json": "0.14.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^7.1.0",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"esbuild": "0.25.5",
|
||||
|
|
@ -26,6 +31,7 @@
|
|||
"husky": "^9.1.7",
|
||||
"jiti": "2.6.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"semantic-release": "^25.0.3",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
|
|
|
|||
|
|
@ -1,29 +1,41 @@
|
|||
import { TFile, App, Notice } from 'obsidian';
|
||||
import { GitLabService } from '../services/gitlab-service';
|
||||
import { GitServiceInterface } from '../services/git-service-interface';
|
||||
import { GitLabFilesPushSettings, SyncMetadata } from '../settings';
|
||||
import { SyncConflictModal } from '../ui/SyncConflictModal';
|
||||
|
||||
export class SyncManager {
|
||||
private app: App;
|
||||
private gitlab: GitLabService;
|
||||
private gitService: GitServiceInterface;
|
||||
private settings: GitLabFilesPushSettings;
|
||||
private metadata: Record<string, SyncMetadata> = {};
|
||||
|
||||
constructor(app: App, gitlab: GitLabService, settings: GitLabFilesPushSettings) {
|
||||
constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings) {
|
||||
this.app = app;
|
||||
this.gitlab = gitlab;
|
||||
this.gitService = gitService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
updateGitService(gitService: GitServiceInterface): void {
|
||||
this.gitService = gitService;
|
||||
}
|
||||
|
||||
async pushFile(file: TFile) {
|
||||
if (!this.app.vault.getFileByPath(file.path)) {
|
||||
new Notice(`File ${file.name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
const content = await this.app.vault.read(file);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
try {
|
||||
// Check if this is a renamed file
|
||||
const renamedFrom = this.detectRename(file);
|
||||
if (renamedFrom) {
|
||||
await this.handleRename(file, renamedFrom, content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict detection
|
||||
const remote = await this.gitlab.getFile(file.path, this.settings.branch);
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const lastSynced = this.settings.syncMetadata[file.path];
|
||||
|
||||
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
|
||||
|
|
@ -47,12 +59,67 @@ export class SyncManager {
|
|||
await this.performPush(file, content, remote.sha);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to push ${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to push ${file.name} to ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private detectRename(file: TFile): string | null {
|
||||
// Check if there's a metadata entry with the same SHA but different path
|
||||
const metadataEntries = Object.keys(this.settings.syncMetadata);
|
||||
for (const oldPath of metadataEntries) {
|
||||
const metadata = this.settings.syncMetadata[oldPath];
|
||||
if (!metadata) continue;
|
||||
|
||||
if (oldPath !== file.path && metadata.lastKnownPath === oldPath) {
|
||||
// Check if the old file no longer exists
|
||||
if (!this.app.vault.getFileByPath(oldPath)) {
|
||||
// This might be a rename
|
||||
return oldPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async handleRename(file: TFile, oldPath: string, content: string): Promise<void> {
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
try {
|
||||
// Push the file to the new location
|
||||
await this.gitService.pushFile(
|
||||
file.path,
|
||||
content,
|
||||
this.settings.branch,
|
||||
`Rename ${oldPath} to ${file.path}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
// Delete the old file from remote
|
||||
// Note: GitLab and GitHub APIs handle this differently
|
||||
// For now, we'll just update metadata and let the user manually delete if needed
|
||||
|
||||
// Update metadata
|
||||
const newRemote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
lastSyncedSha: newRemote.sha,
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
};
|
||||
|
||||
// Remove old metadata
|
||||
delete this.settings.syncMetadata[oldPath];
|
||||
|
||||
await this.saveSettings();
|
||||
new Notice(`Renamed and pushed ${file.name} to ${serviceName}\nNote: Old file at ${oldPath} may need manual deletion from remote`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to handle rename: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async performPush(file: TFile, content: string, existingSha?: string) {
|
||||
await this.gitlab.pushFile(
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
await this.gitService.pushFile(
|
||||
file.path,
|
||||
content,
|
||||
this.settings.branch,
|
||||
|
|
@ -61,14 +128,15 @@ export class SyncManager {
|
|||
);
|
||||
|
||||
// Update metadata
|
||||
const newRemote = await this.gitlab.getFile(file.path, this.settings.branch);
|
||||
const newRemote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
lastSyncedSha: newRemote.sha,
|
||||
lastSyncedAt: Date.now()
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
};
|
||||
|
||||
await this.saveSettings();
|
||||
new Notice(`Pushed ${file.name} to GitLab`);
|
||||
new Notice(`Pushed ${file.name} to ${serviceName}`);
|
||||
}
|
||||
|
||||
async pullFile(file: TFile) {
|
||||
|
|
@ -76,8 +144,9 @@ export class SyncManager {
|
|||
new Notice(`File ${file.name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
try {
|
||||
const remote = await this.gitlab.getFile(file.path, this.settings.branch);
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const localContent = await this.app.vault.read(file);
|
||||
const lastSynced = this.settings.syncMetadata[file.path];
|
||||
|
||||
|
|
@ -93,11 +162,7 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
// Conflict detection for pull
|
||||
// If remote has changed since last sync AND local has also changed since last sync
|
||||
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
|
||||
// Check if local content is different from what we think was last synced
|
||||
// (This is a bit simplified as we don't store the last synced content, only the SHA)
|
||||
// In a real scenario, we might want more robust check, but for now we follow the plan.
|
||||
new SyncConflictModal(this.app, file, localContent, remote.content, (choice) => {
|
||||
void (async () => {
|
||||
try {
|
||||
|
|
@ -118,30 +183,152 @@ export class SyncManager {
|
|||
await this.performPull(file, remote.content, remote.sha);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to pull ${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to pull ${file.name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async performPull(file: TFile, remoteContent: string, remoteSha: string) {
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
await this.app.vault.modify(file, remoteContent);
|
||||
|
||||
// Update metadata
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
lastSyncedSha: remoteSha,
|
||||
lastSyncedAt: Date.now()
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
};
|
||||
|
||||
await this.saveSettings();
|
||||
new Notice(`Pulled ${file.name} from GitLab`);
|
||||
new Notice(`Pulled ${file.name} from ${serviceName}`);
|
||||
}
|
||||
|
||||
private async saveSettings() {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */
|
||||
// @ts-ignore - access private method to save settings
|
||||
const plugin = (this.app as any).plugins?.plugins?.['gitlab-files-push'];
|
||||
const plugin = (this.app as any).plugins?.plugins?.['git-file-push'];
|
||||
if (plugin) {
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
async pushAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> };
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, files.length, file.name);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFile = this.app.vault.getFileByPath(file.path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(existingFile);
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
|
||||
await this.gitService.pushFile(
|
||||
file.path,
|
||||
content,
|
||||
this.settings.branch,
|
||||
`Update ${file.name} from Obsidian`,
|
||||
remote.sha || undefined
|
||||
);
|
||||
|
||||
const newRemote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
lastSyncedSha: newRemote.sha,
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
};
|
||||
|
||||
results.success++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to push ${file.path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
file: file.path,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSettings();
|
||||
|
||||
if (results.success > 0) {
|
||||
new Notice(`Pushed ${results.success} file(s) to ${serviceName}`);
|
||||
}
|
||||
if (results.failed > 0) {
|
||||
new Notice(`Failed to push ${results.failed} file(s). Check console for details.`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async pullAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> };
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, files.length, file.name);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFile = this.app.vault.getFileByPath(file.path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
|
||||
if (!remote.sha) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File not found in remote' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.app.vault.modify(existingFile, remote.content);
|
||||
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
lastSyncedSha: remote.sha,
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
};
|
||||
|
||||
results.success++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to pull ${file.path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
file: file.path,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSettings();
|
||||
|
||||
if (results.success > 0) {
|
||||
new Notice(`Pulled ${results.success} file(s) from ${serviceName}`);
|
||||
}
|
||||
if (results.failed > 0) {
|
||||
new Notice(`Failed to pull ${results.failed} file(s). Check console for details.`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
src/main.ts
196
src/main.ts
|
|
@ -1,38 +1,54 @@
|
|||
import { Plugin, TFile, MarkdownView, Notice } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab } from "./settings";
|
||||
import { GitLabService } from './services/gitlab-service';
|
||||
import { GitHubService } from './services/github-service';
|
||||
import { GitServiceInterface } from './services/git-service-interface';
|
||||
import { SyncManager } from './logic/sync-manager';
|
||||
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
|
||||
|
||||
export default class GitLabFilesPush extends Plugin {
|
||||
settings: GitLabFilesPushSettings;
|
||||
gitlab: GitLabService;
|
||||
gitService: GitServiceInterface;
|
||||
sync: SyncManager;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new GitLabSyncSettingTab(this.app, this));
|
||||
|
||||
this.gitlab = new GitLabService(
|
||||
this.settings.gitlabBaseUrl,
|
||||
this.settings.gitlabToken,
|
||||
this.settings.projectId,
|
||||
this.settings.rootPath
|
||||
this.registerView(
|
||||
SYNC_STATUS_VIEW_TYPE,
|
||||
(leaf) => new SyncStatusView(leaf, this)
|
||||
);
|
||||
|
||||
this.sync = new SyncManager(this.app, this.gitlab, this.settings);
|
||||
this.addRibbonIcon('list-checks', 'Open sync status', () => {
|
||||
void this.activateSyncStatusView();
|
||||
});
|
||||
|
||||
this.addRibbonIcon('upload-cloud', 'Push to GitLab', (evt: MouseEvent) => {
|
||||
this.addCommand({
|
||||
id: 'open-sync-status',
|
||||
name: 'Open sync status',
|
||||
callback: () => {
|
||||
void this.activateSyncStatusView();
|
||||
}
|
||||
});
|
||||
|
||||
this.initializeGitService();
|
||||
this.sync = new SyncManager(this.app, this.gitService, this.settings);
|
||||
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
this.addRibbonIcon('upload-cloud', `Push to ${serviceName}`, (evt: MouseEvent) => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
void this.sync.pushFile(activeView.file);
|
||||
} else {
|
||||
new Notice('No active note to push.');
|
||||
new Notice('No active note to push');
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'push-current-file',
|
||||
name: 'Push current file to GitLab',
|
||||
name: `Push current file to ${serviceName}`,
|
||||
callback: () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -43,7 +59,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-current-file',
|
||||
name: 'Pull current file from GitLab',
|
||||
name: `Pull current file from ${serviceName}`,
|
||||
callback: () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -52,16 +68,32 @@ export default class GitLabFilesPush extends Plugin {
|
|||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'push-all-files',
|
||||
name: 'Push all markdown files',
|
||||
callback: () => {
|
||||
void this.pushAllFiles();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'pull-all-files',
|
||||
name: 'Pull all markdown files',
|
||||
callback: () => {
|
||||
void this.pullAllFiles();
|
||||
}
|
||||
});
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-menu', (menu, file) => {
|
||||
if (file instanceof TFile) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Push to GitLab')
|
||||
item.setTitle(`Push to ${serviceName}`)
|
||||
.setIcon('upload-cloud')
|
||||
.onClick(() => { void this.sync.pushFile(file); });
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Pull from GitLab')
|
||||
item.setTitle(`Pull from ${serviceName}`)
|
||||
.setIcon('download-cloud')
|
||||
.onClick(() => { void this.sync.pullFile(file); });
|
||||
});
|
||||
|
|
@ -69,10 +101,9 @@ export default class GitLabFilesPush extends Plugin {
|
|||
})
|
||||
);
|
||||
|
||||
// Phase 4: Pull-on-open logic (Trigger on file-open)
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-open', (file) => {
|
||||
if (file instanceof TFile && this.settings.gitlabToken) {
|
||||
if (file instanceof TFile && (this.settings.gitlabToken || this.settings.githubToken)) {
|
||||
// Optional: Check for updates automatically
|
||||
// this.sync.pullFile(file);
|
||||
}
|
||||
|
|
@ -80,8 +111,132 @@ export default class GitLabFilesPush extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
async activateSyncStatusView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
|
||||
let leaf = workspace.getLeavesOfType(SYNC_STATUS_VIEW_TYPE)[0];
|
||||
|
||||
if (!leaf) {
|
||||
const rightLeaf = workspace.getRightLeaf(false);
|
||||
if (rightLeaf) {
|
||||
await rightLeaf.setViewState({
|
||||
type: SYNC_STATUS_VIEW_TYPE,
|
||||
active: true,
|
||||
});
|
||||
leaf = rightLeaf;
|
||||
}
|
||||
}
|
||||
|
||||
if (leaf) {
|
||||
void workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
async pushAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to push in the configured vault folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.showConfirmDialog(`Push ${files.length} markdown file(s) to ${serviceName}?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pushing 0/${files.length} files...`, 0);
|
||||
|
||||
try {
|
||||
const results = await this.sync.pushAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`);
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error('Push errors:', results.errors);
|
||||
}
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
console.error(e);
|
||||
new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async pullAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to pull in the configured vault folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.showConfirmDialog(`Pull ${files.length} markdown file(s) from ${serviceName}? This will overwrite local changes.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pulling 0/${files.length} files...`, 0);
|
||||
|
||||
try {
|
||||
const results = await this.sync.pullAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`);
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error('Pull errors:', results.errors);
|
||||
}
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
console.error(e);
|
||||
new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
filterFilesByVaultFolder(files: TFile[]): TFile[] {
|
||||
if (!this.settings.vaultFolder) {
|
||||
return files;
|
||||
}
|
||||
|
||||
const folderPath = this.settings.vaultFolder + '/';
|
||||
return files.filter(file => file.path.startsWith(folderPath) || file.path === this.settings.vaultFolder);
|
||||
}
|
||||
|
||||
initializeGitService(): void {
|
||||
if (this.settings.serviceType === 'gitlab') {
|
||||
this.gitService = new GitLabService(
|
||||
this.settings.gitlabBaseUrl,
|
||||
this.settings.gitlabToken,
|
||||
this.settings.projectId,
|
||||
this.settings.rootPath
|
||||
);
|
||||
} else {
|
||||
this.gitService = new GitHubService(
|
||||
this.settings.githubToken,
|
||||
this.settings.githubOwner,
|
||||
this.settings.githubRepo,
|
||||
this.settings.rootPath
|
||||
);
|
||||
}
|
||||
|
||||
if (this.sync) {
|
||||
this.sync.updateGitService(this.gitService);
|
||||
}
|
||||
}
|
||||
|
||||
private showConfirmDialog(message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
const confirmed = confirm(message);
|
||||
resolve(confirmed);
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Clean up resources if needed
|
||||
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
|
|
@ -90,13 +245,6 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
if (this.gitlab) {
|
||||
this.gitlab.updateConfig(
|
||||
this.settings.gitlabBaseUrl,
|
||||
this.settings.gitlabToken,
|
||||
this.settings.projectId,
|
||||
this.settings.rootPath
|
||||
);
|
||||
}
|
||||
this.initializeGitService();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/services/git-service-interface.ts
Normal file
6
src/services/git-service-interface.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export interface GitServiceInterface {
|
||||
updateConfig(...args: unknown[]): void;
|
||||
getFile(path: string, branch: string): Promise<{ content: string; sha: string }>;
|
||||
pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string>;
|
||||
testConnection(): Promise<void>;
|
||||
}
|
||||
163
src/services/github-service.ts
Normal file
163
src/services/github-service.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
|
||||
import { GitServiceInterface } from './git-service-interface';
|
||||
|
||||
interface GitHubFileResponse {
|
||||
content: string;
|
||||
sha: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ObsidianErrorResponse {
|
||||
headers: Record<string, string>;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ObsidianResponseError {
|
||||
status: number;
|
||||
response?: ObsidianErrorResponse;
|
||||
}
|
||||
|
||||
export class GitHubService implements GitServiceInterface {
|
||||
private token: string;
|
||||
private owner: string;
|
||||
private repo: string;
|
||||
private rootPath: string;
|
||||
|
||||
constructor(token: string, owner: string, repo: string, rootPath: string = '') {
|
||||
this.updateConfig(token, owner, repo, rootPath);
|
||||
}
|
||||
|
||||
updateConfig(token: string, owner: string, repo: string, rootPath: string = '') {
|
||||
this.token = token;
|
||||
this.owner = owner;
|
||||
this.repo = repo;
|
||||
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
|
||||
}
|
||||
|
||||
private getApiUrl(path: string): string {
|
||||
const fullPath = this.rootPath ? `${this.rootPath}/${path}` : path;
|
||||
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
||||
}
|
||||
|
||||
private async safeRequest(params: RequestUrlParam): Promise<RequestUrlResponse> {
|
||||
try {
|
||||
return await requestUrl(params);
|
||||
} catch (e) {
|
||||
if (typeof e === 'object' && e !== null && 'status' in e) {
|
||||
const error = e as ObsidianResponseError;
|
||||
const status = error.status || 0;
|
||||
const responseData = error.response;
|
||||
const text = responseData?.text || (responseData?.json ? JSON.stringify(responseData.json) : '');
|
||||
|
||||
if (status) {
|
||||
return {
|
||||
status,
|
||||
headers: responseData?.headers || {},
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
json: responseData?.json || {},
|
||||
text: text
|
||||
};
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async getFile(path: string, branch: string): Promise<{ content: string; sha: string }> {
|
||||
const url = `${this.getApiUrl(path)}?ref=${branch}`;
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return { content: '', sha: '' };
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to fetch file: ${response.status} from ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
|
||||
const data = (response.json as unknown) as GitHubFileResponse;
|
||||
const content = atob(data.content);
|
||||
let decodedContent = '';
|
||||
try {
|
||||
decodedContent = decodeURIComponent(content.split('').map(c => {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
} catch {
|
||||
decodedContent = content;
|
||||
}
|
||||
|
||||
return {
|
||||
content: decodedContent,
|
||||
sha: data.sha
|
||||
};
|
||||
}
|
||||
|
||||
async pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string> {
|
||||
const url = this.getApiUrl(path);
|
||||
|
||||
const sha = existingSha !== undefined ? existingSha : (await this.getFile(path, branch)).sha;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
message: commitMessage,
|
||||
content: btoa(encodeURIComponent(content).replace(/%([0-9A-F]{2})/g, (_match, p1: string) => {
|
||||
return String.fromCharCode(parseInt(p1, 16));
|
||||
})),
|
||||
branch
|
||||
};
|
||||
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
}
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to push file: ${response.status} PUT ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
|
||||
return ((response.json as { content: GitHubFileResponse }).content).path;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
if (!this.token) throw new Error('Token is missing');
|
||||
if (!this.owner) throw new Error('Owner is missing');
|
||||
if (!this.repo) throw new Error('Repository is missing');
|
||||
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
|
||||
import { GitServiceInterface } from './git-service-interface';
|
||||
|
||||
interface GitLabFileResponse {
|
||||
content: string;
|
||||
|
|
@ -17,7 +18,7 @@ interface ObsidianResponseError {
|
|||
response?: ObsidianErrorResponse;
|
||||
}
|
||||
|
||||
export class GitLabService {
|
||||
export class GitLabService implements GitServiceInterface {
|
||||
private baseUrl: string;
|
||||
private token: string;
|
||||
private projectId: string;
|
||||
|
|
|
|||
164
src/settings.ts
164
src/settings.ts
|
|
@ -4,24 +4,37 @@ import GitLabFilesPush from "./main";
|
|||
export interface SyncMetadata {
|
||||
lastSyncedSha: string;
|
||||
lastSyncedAt: number;
|
||||
lastKnownPath?: string;
|
||||
}
|
||||
|
||||
export type GitServiceType = 'gitlab' | 'github';
|
||||
|
||||
export interface GitLabFilesPushSettings {
|
||||
serviceType: GitServiceType;
|
||||
gitlabToken: string;
|
||||
gitlabBaseUrl: string;
|
||||
projectId: string;
|
||||
githubToken: string;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
branch: string;
|
||||
syncMetadata: Record<string, SyncMetadata>;
|
||||
rootPath: string;
|
||||
vaultFolder: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
|
||||
serviceType: 'gitlab',
|
||||
gitlabToken: '',
|
||||
gitlabBaseUrl: 'https://gitlab.com',
|
||||
projectId: '',
|
||||
githubToken: '',
|
||||
githubOwner: '',
|
||||
githubRepo: '',
|
||||
rootPath: "",
|
||||
branch: 'main',
|
||||
syncMetadata: {}
|
||||
syncMetadata: {},
|
||||
vaultFolder: ''
|
||||
}
|
||||
|
||||
export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||
|
|
@ -38,37 +51,26 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab personal access token')
|
||||
.setDesc('Create a token in GitLab user settings > access tokens with "API" scope')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your token')
|
||||
.setValue(this.plugin.settings.gitlabToken)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gitlabToken = value;
|
||||
await this.plugin.saveSettings();
|
||||
.setName('Git service')
|
||||
.setDesc('Choose between GitLab or GitHub')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('gitlab', 'GitLab')
|
||||
.addOption('github', 'GitHub')
|
||||
.setValue(this.plugin.settings.serviceType)
|
||||
.onChange((value: string) => {
|
||||
this.plugin.settings.serviceType = value as GitServiceType;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab base URL')
|
||||
.setDesc('Defaults to https://gitlab.com')
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitlab.com')
|
||||
.setValue(this.plugin.settings.gitlabBaseUrl)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl).setName('').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Project ID')
|
||||
.setDesc('Found in GitLab project overview')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter numeric project ID')
|
||||
.setValue(this.plugin.settings.projectId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.projectId = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
if (this.plugin.settings.serviceType === 'gitlab') {
|
||||
this.displayGitLabSettings(containerEl);
|
||||
} else {
|
||||
this.displayGitHubSettings(containerEl);
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Branch')
|
||||
|
|
@ -76,9 +78,9 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('Main')
|
||||
.setValue(this.plugin.settings.branch)
|
||||
.onChange(async (value) => {
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.branch = value || 'main';
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -87,24 +89,112 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('Enter subfolder path')
|
||||
.setValue(this.plugin.settings.rootPath)
|
||||
.onChange(async (value) => {
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
|
||||
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)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Leave empty to sync all files')
|
||||
.setValue(this.plugin.settings.vaultFolder)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, '');
|
||||
void this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Test connection')
|
||||
.setDesc('Verify your GitLab settings')
|
||||
.setDesc(`Verify your ${this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'} settings`)
|
||||
.addButton(button => button
|
||||
.setButtonText('Test connection')
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await this.plugin.gitlab.testConnection();
|
||||
new Notice('GitLab connection successful!');
|
||||
await this.plugin.gitService.testConnection();
|
||||
new Notice(`${this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'} connection successful!`);
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
new Notice(`GitLab connection failed: ${message}`);
|
||||
new Notice(`Connection failed: ${message}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private displayGitLabSettings(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab personal access token')
|
||||
.setDesc('Create a token in GitLab user settings > access tokens with "API" scope')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your token')
|
||||
.setValue(this.plugin.settings.gitlabToken)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.gitlabToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab base URL')
|
||||
.setDesc('Defaults to https://gitlab.com')
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitlab.com')
|
||||
.setValue(this.plugin.settings.gitlabBaseUrl)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Project ID')
|
||||
.setDesc('Found in GitLab project overview')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter numeric project ID')
|
||||
.setValue(this.plugin.settings.projectId)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.projectId = value;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
}
|
||||
|
||||
private displayGitHubSettings(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl)
|
||||
.setName('GitHub personal access token')
|
||||
.setDesc('Create a token in GitHub settings > Developer settings > Personal access tokens with "repo" scope')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your token')
|
||||
.setValue(this.plugin.settings.githubToken)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository owner')
|
||||
.setDesc('GitHub username or organization name')
|
||||
.addText(text => text
|
||||
.setPlaceholder('username')
|
||||
.setValue(this.plugin.settings.githubOwner)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubOwner = value;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository name')
|
||||
.setDesc('Name of the GitHub repository')
|
||||
.addText(text => text
|
||||
.setPlaceholder('my-notes')
|
||||
.setValue(this.plugin.settings.githubRepo)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubRepo = value;
|
||||
void this.plugin.saveSettings();
|
||||
void this.plugin.initializeGitService();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,31 +16,87 @@ export class SyncConflictModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.file.name}` });
|
||||
contentEl.createEl('p', { text: 'The remote file has different content. Which version do you want to keep?' });
|
||||
contentEl.addClass('sync-conflict-modal');
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Keep local')
|
||||
.setDesc('Overwrite remote with your local content')
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.file.name}` });
|
||||
contentEl.createEl('p', {
|
||||
text: 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
cls: 'conflict-description'
|
||||
});
|
||||
|
||||
const diffContainer = contentEl.createDiv({ cls: 'conflict-diff-container' });
|
||||
|
||||
const localSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
||||
localSection.createEl('h3', { text: 'Local version' });
|
||||
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
||||
localPre.createEl('code', { text: this.localContent });
|
||||
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
||||
remoteSection.createEl('h3', { text: 'Remote version' });
|
||||
const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' });
|
||||
remotePre.createEl('code', { text: this.remoteContent });
|
||||
|
||||
const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
diffPre.createEl('code', { text: this.generateDiff() });
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||
|
||||
new Setting(buttonContainer)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Use local')
|
||||
.setButtonText('Keep local')
|
||||
.setTooltip('Overwrite remote with your local content')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onChoose('local');
|
||||
this.close();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Keep remote')
|
||||
.setDesc('Overwrite local with GitLab content')
|
||||
}))
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Use remote')
|
||||
.setButtonText('Keep remote')
|
||||
.setTooltip('Overwrite local with remote content')
|
||||
.setWarning()
|
||||
.onClick(() => {
|
||||
this.onChoose('remote');
|
||||
this.close();
|
||||
}))
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Cancel')
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
}));
|
||||
}
|
||||
|
||||
private generateDiff(): string {
|
||||
const localLines = this.localContent.split('\n');
|
||||
const remoteLines = this.remoteContent.split('\n');
|
||||
|
||||
const diff: string[] = [];
|
||||
diff.push('--- Remote');
|
||||
diff.push('+++ Local');
|
||||
diff.push('');
|
||||
|
||||
const maxLines = Math.max(localLines.length, remoteLines.length);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const remoteLine = remoteLines[i];
|
||||
const localLine = localLines[i];
|
||||
|
||||
if (remoteLine !== localLine) {
|
||||
if (remoteLine !== undefined) {
|
||||
diff.push(`- ${remoteLine}`);
|
||||
}
|
||||
if (localLine !== undefined) {
|
||||
diff.push(`+ ${localLine}`);
|
||||
}
|
||||
} else if (remoteLine !== undefined) {
|
||||
diff.push(` ${remoteLine}`);
|
||||
}
|
||||
}
|
||||
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
|
|
|||
370
src/ui/SyncStatusView.ts
Normal file
370
src/ui/SyncStatusView.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile, Notice } from 'obsidian';
|
||||
import GitLabFilesPush from '../main';
|
||||
|
||||
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
|
||||
|
||||
interface FileStatus {
|
||||
file: TFile;
|
||||
status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking';
|
||||
localContent?: string;
|
||||
remoteContent?: string;
|
||||
remoteSha?: string;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
export class SyncStatusView extends ItemView {
|
||||
plugin: GitLabFilesPush;
|
||||
private fileStatuses: Map<string, FileStatus> = new Map();
|
||||
private isRefreshing = false;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return SYNC_STATUS_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return 'Sync status';
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return 'git-compare';
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
if (!container) return;
|
||||
container.empty();
|
||||
container.addClass('sync-status-view');
|
||||
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
private renderView(): void {
|
||||
const container = this.containerEl.children[1];
|
||||
if (!container) return;
|
||||
container.empty();
|
||||
|
||||
const headerEl = container.createDiv({ cls: 'sync-status-header' });
|
||||
headerEl.createEl('h4', { text: 'Repository sync status' });
|
||||
|
||||
const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
const serviceInfo = container.createDiv({ cls: 'sync-status-info' });
|
||||
serviceInfo.createEl('div', { text: `Service: ${serviceName}` });
|
||||
serviceInfo.createEl('div', { text: `Branch: ${this.plugin.settings.branch}` });
|
||||
if (this.plugin.settings.vaultFolder) {
|
||||
serviceInfo.createEl('div', { text: `Vault Folder: ${this.plugin.settings.vaultFolder}` });
|
||||
}
|
||||
|
||||
const buttonContainer = container.createDiv({ cls: 'sync-status-buttons' });
|
||||
const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status' });
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
void this.refreshAllStatuses();
|
||||
});
|
||||
|
||||
const pushAllBtn = buttonContainer.createEl('button', { text: 'Push all modified', cls: 'push-all-btn' });
|
||||
pushAllBtn.addEventListener('click', () => {
|
||||
void this.pushAllModified();
|
||||
});
|
||||
|
||||
const pullAllBtn = buttonContainer.createEl('button', { text: 'Pull all modified', cls: 'pull-all-btn' });
|
||||
pullAllBtn.addEventListener('click', () => {
|
||||
void this.pullAllModified();
|
||||
});
|
||||
|
||||
const statusContainer = container.createDiv({ cls: 'sync-status-list' });
|
||||
|
||||
if (this.fileStatuses.size === 0) {
|
||||
statusContainer.createEl('div', {
|
||||
text: 'Click "Refresh status" to check all files',
|
||||
cls: 'sync-status-empty'
|
||||
});
|
||||
} else {
|
||||
this.renderFileStatuses(statusContainer);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileStatuses(container: HTMLElement): void {
|
||||
const statuses = Array.from(this.fileStatuses.values());
|
||||
|
||||
const summary = container.createDiv({ cls: 'sync-status-summary' });
|
||||
const synced = statuses.filter(s => s.status === 'synced').length;
|
||||
const modified = statuses.filter(s => s.status === 'modified').length;
|
||||
const unsynced = statuses.filter(s => s.status === 'unsynced').length;
|
||||
|
||||
summary.createEl('div', { text: `✓ Synced: ${synced}` });
|
||||
summary.createEl('div', { text: `⚠ Modified: ${modified}` });
|
||||
summary.createEl('div', { text: `✗ Unsynced: ${unsynced}` });
|
||||
|
||||
for (const fileStatus of statuses) {
|
||||
this.renderFileStatus(container, fileStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileStatus(container: HTMLElement, fileStatus: FileStatus): void {
|
||||
const fileEl = container.createDiv({ cls: 'sync-status-file' });
|
||||
|
||||
const headerEl = fileEl.createDiv({ cls: 'sync-status-file-header' });
|
||||
|
||||
let icon = '○';
|
||||
let statusText = '';
|
||||
let statusClass = '';
|
||||
|
||||
switch (fileStatus.status) {
|
||||
case 'synced':
|
||||
icon = '✓';
|
||||
statusText = 'Synced';
|
||||
statusClass = 'status-synced';
|
||||
break;
|
||||
case 'modified':
|
||||
icon = '⚠';
|
||||
statusText = 'Modified';
|
||||
statusClass = 'status-modified';
|
||||
break;
|
||||
case 'unsynced':
|
||||
icon = '✗';
|
||||
statusText = 'Not in remote';
|
||||
statusClass = 'status-unsynced';
|
||||
break;
|
||||
case 'checking':
|
||||
icon = '⟳';
|
||||
statusText = 'Checking...';
|
||||
statusClass = 'status-checking';
|
||||
break;
|
||||
}
|
||||
|
||||
headerEl.createSpan({ text: `${icon} `, cls: `status-icon ${statusClass}` });
|
||||
headerEl.createSpan({ text: fileStatus.file.path, cls: 'file-path' });
|
||||
headerEl.createSpan({ text: ` (${statusText})`, cls: `status-text ${statusClass}` });
|
||||
|
||||
if (fileStatus.status === 'modified' && fileStatus.diff) {
|
||||
const diffToggle = headerEl.createEl('button', {
|
||||
text: 'Show diff',
|
||||
cls: 'diff-toggle'
|
||||
});
|
||||
|
||||
const diffContainer = fileEl.createDiv({ cls: 'sync-status-diff' });
|
||||
diffContainer.addClass('hidden');
|
||||
|
||||
const diffPre = diffContainer.createEl('pre');
|
||||
diffPre.createEl('code', { text: fileStatus.diff });
|
||||
|
||||
diffToggle.addEventListener('click', () => {
|
||||
if (diffContainer.hasClass('hidden')) {
|
||||
diffContainer.removeClass('hidden');
|
||||
diffToggle.setText('Hide diff');
|
||||
} else {
|
||||
diffContainer.addClass('hidden');
|
||||
diffToggle.setText('Show diff');
|
||||
}
|
||||
});
|
||||
|
||||
const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' });
|
||||
const pushBtn = actionsEl.createEl('button', { text: 'Push' });
|
||||
const pullBtn = actionsEl.createEl('button', { text: 'Pull' });
|
||||
|
||||
pushBtn.addEventListener('click', () => {
|
||||
void this.plugin.sync.pushFile(fileStatus.file);
|
||||
setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000);
|
||||
});
|
||||
|
||||
pullBtn.addEventListener('click', () => {
|
||||
void this.plugin.sync.pullFile(fileStatus.file);
|
||||
setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllStatuses(): Promise<void> {
|
||||
if (this.isRefreshing) {
|
||||
new Notice('Already refreshing...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
this.fileStatuses.clear();
|
||||
|
||||
const container = this.containerEl.children[1];
|
||||
if (container) {
|
||||
const statusContainer = container.querySelector('.sync-status-list') as HTMLElement;
|
||||
if (statusContainer) {
|
||||
statusContainer.empty();
|
||||
statusContainer.createEl('div', { text: 'Checking files...', cls: 'sync-status-loading' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.plugin.filterFilesByVaultFolder(allFiles);
|
||||
|
||||
for (const file of files) {
|
||||
this.fileStatuses.set(file.path, {
|
||||
file,
|
||||
status: 'checking'
|
||||
});
|
||||
}
|
||||
|
||||
this.renderView();
|
||||
|
||||
for (const file of files) {
|
||||
await this.refreshFileStatus(file);
|
||||
}
|
||||
|
||||
this.renderView();
|
||||
new Notice(`Checked ${files.length} files`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshFileStatus(file: TFile): Promise<void> {
|
||||
try {
|
||||
const localContent = await this.app.vault.read(file);
|
||||
const remote = await this.plugin.gitService.getFile(file.path, this.plugin.settings.branch);
|
||||
|
||||
let status: FileStatus['status'];
|
||||
let diff: string | undefined;
|
||||
|
||||
if (!remote.sha) {
|
||||
status = 'unsynced';
|
||||
} else if (localContent === remote.content) {
|
||||
status = 'synced';
|
||||
} else {
|
||||
status = 'modified';
|
||||
diff = this.generateDiff(remote.content, localContent);
|
||||
}
|
||||
|
||||
this.fileStatuses.set(file.path, {
|
||||
file,
|
||||
status,
|
||||
localContent,
|
||||
remoteContent: remote.content,
|
||||
remoteSha: remote.sha,
|
||||
diff
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Error checking ${file.path}:`, e);
|
||||
this.fileStatuses.set(file.path, {
|
||||
file,
|
||||
status: 'unsynced'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private generateDiff(oldContent: string, newContent: string): string {
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
|
||||
const diff: string[] = [];
|
||||
diff.push('--- Remote');
|
||||
diff.push('+++ Local');
|
||||
diff.push('');
|
||||
|
||||
const maxLines = Math.max(oldLines.length, newLines.length);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const oldLine = oldLines[i];
|
||||
const newLine = newLines[i];
|
||||
|
||||
if (oldLine !== newLine) {
|
||||
if (oldLine !== undefined) {
|
||||
diff.push(`- ${oldLine}`);
|
||||
}
|
||||
if (newLine !== undefined) {
|
||||
diff.push(`+ ${newLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
async pushAllModified(): Promise<void> {
|
||||
const modifiedFiles = Array.from(this.fileStatuses.values())
|
||||
.filter(s => s.status === 'modified' || s.status === 'unsynced')
|
||||
.map(s => s.file);
|
||||
|
||||
if (modifiedFiles.length === 0) {
|
||||
new Notice('No modified files to push');
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
const confirmed = await this.showConfirmDialog(`Push ${modifiedFiles.length} file(s) to ${serviceName}?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pushing 0/${modifiedFiles.length} files...`, 0);
|
||||
|
||||
try {
|
||||
const results = await this.plugin.sync.pushAllFiles(modifiedFiles, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`);
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error('Push errors:', results.errors);
|
||||
}
|
||||
|
||||
await this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
console.error(e);
|
||||
new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async pullAllModified(): Promise<void> {
|
||||
const modifiedFiles = Array.from(this.fileStatuses.values())
|
||||
.filter(s => s.status === 'modified')
|
||||
.map(s => s.file);
|
||||
|
||||
if (modifiedFiles.length === 0) {
|
||||
new Notice('No modified files to pull');
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
const confirmed = await this.showConfirmDialog(`Pull ${modifiedFiles.length} file(s) from ${serviceName}? This will overwrite local changes.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pulling 0/${modifiedFiles.length} files...`, 0);
|
||||
|
||||
try {
|
||||
const results = await this.plugin.sync.pullAllFiles(modifiedFiles, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`);
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error('Pull errors:', results.errors);
|
||||
}
|
||||
|
||||
await this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
console.error(e);
|
||||
new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
// Cleanup
|
||||
}
|
||||
|
||||
private showConfirmDialog(message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
const confirmed = confirm(message);
|
||||
resolve(confirmed);
|
||||
});
|
||||
}
|
||||
}
|
||||
265
styles.css
265
styles.css
|
|
@ -6,3 +6,268 @@ available in the app when your plugin is enabled.
|
|||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
|
||||
.sync-status-view {
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sync-status-header {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.sync-status-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.sync-status-info {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.sync-status-info div {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.sync-status-buttons {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sync-status-buttons button {
|
||||
padding: 8px 16px;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sync-status-buttons button:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.sync-status-buttons .push-all-btn {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
.sync-status-buttons .push-all-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sync-status-buttons .pull-all-btn {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.sync-status-buttons .pull-all-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sync-status-summary {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-status-empty,
|
||||
.sync-status-loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sync-status-file {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.sync-status-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.status-synced {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.status-modified {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.status-unsynced {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.status-checking {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
flex: 1;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diff-toggle {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.85em;
|
||||
background: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diff-toggle:hover {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.sync-status-diff {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sync-status-diff pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.sync-status-diff code {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.sync-status-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sync-status-actions button {
|
||||
padding: 5px 12px;
|
||||
font-size: 0.85em;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.sync-status-actions button:hover {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
/* Conflict Modal Styles */
|
||||
.sync-conflict-modal {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.conflict-description {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.conflict-diff-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.conflict-section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conflict-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
margin: 0;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.conflict-diff-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.conflict-diff-section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conflict-diff {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
margin: 0;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.conflict-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.conflict-buttons .setting-item {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.conflict-buttons .setting-item-control {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,17 @@ const mockGitLab = {
|
|||
} as unknown as GitLabService;
|
||||
|
||||
const mockSettings: GitLabFilesPushSettings = {
|
||||
serviceType: 'gitlab',
|
||||
gitlabToken: 'token',
|
||||
gitlabBaseUrl: 'https://gitlab.com',
|
||||
projectId: '123',
|
||||
githubToken: '',
|
||||
githubOwner: '',
|
||||
githubRepo: '',
|
||||
branch: 'main',
|
||||
rootPath: '',
|
||||
syncMetadata: {}
|
||||
syncMetadata: {},
|
||||
vaultFolder: ''
|
||||
};
|
||||
|
||||
describe('SyncManager', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue