This commit is contained in:
Xheldon 2025-07-25 19:53:04 +08:00
commit 33b4284577
16 changed files with 5387 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# compiled output
main.js
*.js.map
# dependencies
node_modules/
# logs
*.log
npm-debug.log*
# editor
.vscode/
.idea/
# system files
.DS_Store
Thumbs.db
# test
coverage/
.nyc_output/
# temporary files
*.tmp
*.temp

164
README.md Normal file
View file

@ -0,0 +1,164 @@
# Git 同步 Obsidian 插件
一个支持与 GitHub 仓库同步的 Obsidian 插件,使用 React 构建用户界面,支持热重载开发。
## 功能特性
- 🔄 **双向同步**: 支持将笔记同步到 GitHub 或从 GitHub 拉取笔记
- 🎯 **单文件操作**: 可以单独同步当前编辑的文件
- 📁 **批量操作**: 支持整个 Vault 的批量同步
- 🔧 **可视化配置**: 使用 React 构建的现代化配置界面
- 🚀 **热重载开发**: 支持开发时的热重载
- 📂 **递归文件夹**: 完整支持文件夹结构的递归同步
## 安装
### 手动安装
1. 下载最新的 release 文件
2. 将文件解压到你的 Obsidian 插件目录:`{vault}/.obsidian/plugins/git-sync/`
3. 重启 Obsidian
4. 在设置中启用"Git Sync"插件
### 开发安装
1. 克隆此仓库到你的插件目录:
```bash
cd {vault}/.obsidian/plugins/
git clone https://github.com/你的用户名/obsidian-git-sync git-sync
cd git-sync
```
2. 安装依赖:
```bash
npm install
```
3. 开发模式(支持热重载):
```bash
npm run dev
```
4. 构建生产版本:
```bash
npm run build
```
## 配置
### 1. GitHub Personal Access Token
首先需要创建一个 GitHub Personal Access Token
1. 访问 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. 点击"Generate new token (classic)"
3. 选择以下权限:
- `repo` (完整的仓库访问权限)
4. 复制生成的 token
### 2. 仓库路径配置
仓库路径格式:`@https://github.com/用户名/仓库名/路径/到/文件夹`
示例:
- `@https://github.com/Xheldon/git-sync/data/_post`
- `@https://github.com/username/notes/obsidian-vault`
## 使用方法
### 编辑器按钮
点击左侧边栏的设置图标打开配置界面。
### 笔记同步菜单
在编辑笔记时,可以通过以下方式访问同步菜单:
1. 使用命令面板:`Ctrl/Cmd + P` → 搜索"显示同步菜单"
2. 右键点击编辑器 → 选择"Git 同步"
菜单选项:
- **同步当前文件到远端**: 将当前文件上传到 GitHub
- **拉取远端覆盖当前文件**: 从 GitHub 下载文件覆盖本地
### 配置界面功能
#### 基本设置
- **GitHub Personal Token**: 输入你的访问令牌
- **GitHub 仓库路径**: 配置目标仓库和路径
#### 批量操作
- **初始化仓库**: 当 Vault 为空时,从远端下载所有文件
- **强制同步远端到本地**: 将远端文件同步到本地(会覆盖同名文件)
- **强制同步本地到远端**: 将本地文件同步到远端
## 开发
### 项目结构
```
├── main.ts # 插件主文件
├── types.ts # 类型定义
├── github-service.ts # GitHub API服务
├── settings-modal.tsx # React配置界面
├── styles.css # 样式文件
├── manifest.json # 插件清单
├── package.json # 项目配置
├── tsconfig.json # TypeScript配置
├── esbuild.config.mjs # 构建配置
└── version-bump.mjs # 版本管理脚本
```
### 开发命令
```bash
# 安装依赖
npm install
# 开发模式(热重载)
npm run dev
# 构建生产版本
npm run build
# 版本管理
npm run version
```
## 技术栈
- **TypeScript**: 主要开发语言
- **React**: 用户界面框架
- **Obsidian API**: 插件核心 API
- **GitHub API**: 通过@octokit/rest 进行仓库操作
- **esbuild**: 快速构建工具
## 注意事项
1. **权限要求**: 需要 GitHub 仓库的写入权限
2. **文件冲突**: 强制同步会覆盖现有文件,请谨慎使用
3. **网络要求**: 需要稳定的网络连接访问 GitHub API
4. **Token 安全**: 请妥善保管你的 GitHub Personal Access Token
## 贡献
欢迎提交 Issue 和 Pull Request
## 赞助
如果这个插件对你有帮助,欢迎请我喝杯咖啡 ☕
[![PayPal](https://img.shields.io/badge/PayPal-支持赞助-blue?style=for-the-badge&logo=paypal)](https://paypal.me/xheldoncao)
你的支持是我继续开发和维护这个项目的动力!
## 许可证
MIT License

7
data.json Normal file
View file

@ -0,0 +1,7 @@
{
"githubToken": "",
"repositoryUrl": "",
"lastSyncTime": 0,
"showRibbonIcon": true,
"language": "auto"
}

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === 'production');
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins],
format: 'cjs',
target: 'es2018',
logLevel: "info",
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
jsx: 'automatic',
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

196
file-cache.ts Normal file
View file

@ -0,0 +1,196 @@
import { FileCache, FileCacheManager } from './types';
export class FileCacheService implements FileCacheManager {
private cache: Map<string, FileCache> = new Map();
private readonly CACHE_KEY = 'git-sync-file-cache';
private readonly DEFAULT_CACHE_AGE = 5 * 60 * 1000; // 5 minutes cache validity period
constructor() {
this.loadCacheFromStorage();
}
/**
* Load cache from local storage
*/
private loadCacheFromStorage(): void {
try {
const stored = localStorage.getItem(this.CACHE_KEY);
if (stored) {
const cacheData = JSON.parse(stored);
this.cache = new Map(Object.entries(cacheData));
console.log(`Loaded ${this.cache.size} file caches`);
}
} catch (error) {
console.error('Failed to load file cache:', error);
this.cache.clear();
}
}
/**
* Save cache to local storage
*/
private saveCacheToStorage(): void {
try {
const cacheObj = Object.fromEntries(this.cache);
localStorage.setItem(this.CACHE_KEY, JSON.stringify(cacheObj));
} catch (error) {
console.error('Failed to save file cache:', error);
}
}
/**
* Get file cache
*/
getFileCache(filePath: string): FileCache | null {
const cache = this.cache.get(filePath);
if (!cache) {
console.log(`File ${filePath} has no cache`);
return null;
}
if (!this.isCacheValid(cache)) {
console.log(`File ${filePath} cache has expired`);
this.removeFileCache(filePath);
return null;
}
console.log(`File ${filePath} using cache`);
return cache;
}
/**
* Set file cache
*/
setFileCache(filePath: string, cache: FileCache): void {
this.cache.set(filePath, {
...cache,
cacheTime: Date.now()
});
this.saveCacheToStorage();
console.log(`File ${filePath} cache updated`);
}
/**
* Remove file cache
*/
removeFileCache(filePath: string): void {
if (this.cache.delete(filePath)) {
this.saveCacheToStorage();
console.log(`File ${filePath} cache removed`);
}
}
/**
* Clear all cache
*/
clearCache(): void {
this.cache.clear();
localStorage.removeItem(this.CACHE_KEY);
console.log('All file caches cleared');
}
/**
* Check if cache is valid
*/
isCacheValid(cache: FileCache, maxAge?: number): boolean {
const age = maxAge || this.DEFAULT_CACHE_AGE;
return (Date.now() - cache.cacheTime) < age;
}
/**
* Get all cached files
*/
getAllCachedFiles(): FileCache[] {
return Array.from(this.cache.values());
}
/**
* Calculate file content hash
*/
static calculateContentHash(content: string): string {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
/**
* Check if file has been modified locally
*/
async checkLocalFileModified(filePath: string, vault: any): Promise<boolean> {
try {
const cache = this.getFileCache(filePath);
if (!cache) return true; // Consider modified when no cache exists
const file = vault.getAbstractFileByPath(filePath);
if (!file) return true; // Consider modified when file doesn't exist
const content = await vault.read(file);
const currentHash = FileCacheService.calculateContentHash(content);
return currentHash !== cache.contentHash;
} catch (error) {
console.error('Failed to check file modification status:', error);
return true; // Consider modified when error occurs
}
}
/**
* Update file cache status
*/
async updateFileCache(
filePath: string,
githubPath: string,
lastModified: string,
sha: string,
isPublished: boolean,
vault: any
): Promise<void> {
try {
const file = vault.getAbstractFileByPath(filePath);
if (!file) return;
const content = await vault.read(file);
const contentHash = FileCacheService.calculateContentHash(content);
const cache: FileCache = {
filePath,
githubPath,
lastModified,
sha,
isPublished,
isSynced: true, // Newly updated cache is considered synced
cacheTime: Date.now(),
fileSize: content.length,
contentHash
};
this.setFileCache(filePath, cache);
} catch (error) {
console.error('Failed to update file cache:', error);
}
}
/**
* Get cache statistics
*/
getCacheStats(): {
totalFiles: number;
publishedFiles: number;
syncedFiles: number;
expiredCaches: number;
} {
const allCaches = this.getAllCachedFiles();
const expiredCaches = allCaches.filter(cache => !this.isCacheValid(cache));
return {
totalFiles: allCaches.length,
publishedFiles: allCaches.filter(cache => cache.isPublished).length,
syncedFiles: allCaches.filter(cache => cache.isSynced).length,
expiredCaches: expiredCaches.length
};
}
}

418
github-service.ts Normal file
View file

@ -0,0 +1,418 @@
import { Octokit } from '@octokit/rest';
import { GitHubFile, SyncResult } from './types';
import { Notice } from 'obsidian';
import { t } from './i18n-simple';
export class GitHubService {
private octokit: Octokit;
private rateLimitInfo: { remaining: number; resetTime: number } | null = null;
constructor(token: string) {
this.octokit = new Octokit({
auth: token,
});
}
updateToken(token: string) {
this.octokit = new Octokit({
auth: token,
});
// Reset rate limit information
this.rateLimitInfo = null;
}
// Check rate limit status
async checkRateLimit(): Promise<{ canProceed: boolean; waitMinutes?: number; message?: string }> {
try {
const { data } = await this.octokit.rest.rateLimit.get();
const core = data.resources.core;
this.rateLimitInfo = {
remaining: core.remaining,
resetTime: core.reset
};
if (core.remaining === 0) {
const resetDate = new Date(core.reset * 1000);
const waitMinutes = Math.ceil((resetDate.getTime() - Date.now()) / (1000 * 60));
return {
canProceed: false,
waitMinutes,
message: t('github.api.rate.limit.exceeded', { minutes: waitMinutes })
};
}
// Give warning if remaining requests are low
if (core.remaining < 10) {
return {
canProceed: true,
message: t('github.api.rate.limit.warning', { remaining: core.remaining })
};
}
return { canProceed: true };
} catch (error) {
// If unable to get rate limit info, allow to continue (might be network issue)
return { canProceed: true };
}
}
// Check rate limit before each API call
private async beforeApiCall(): Promise<boolean> {
const rateCheck = await this.checkRateLimit();
if (!rateCheck.canProceed) {
new Notice(rateCheck.message || t('github.api.rate.limit.notice'), 8000);
return false;
}
if (rateCheck.message && rateCheck.message.includes('⚠️')) {
new Notice(rateCheck.message, 5000);
}
return true;
}
private handleGitHubError(error: any): { message: string; isRetryable: boolean } {
// Check if it's a GitHub API error
if (error.status) {
switch (error.status) {
case 401:
return {
message: t('github.api.token.invalid'),
isRetryable: false
};
case 403:
// Check if it's rate limit
if (error.response?.headers && error.response.headers['x-ratelimit-remaining'] === '0') {
const resetTime = error.response.headers['x-ratelimit-reset'];
const resetDate = new Date(parseInt(resetTime) * 1000);
const waitMinutes = Math.ceil((resetDate.getTime() - Date.now()) / (1000 * 60));
return {
message: t('github.api.rate.limit.hourly', { minutes: waitMinutes }),
isRetryable: true
};
} else {
return {
message: t('github.api.access.denied'),
isRetryable: false
};
}
case 404:
return {
message: t('github.api.resource.not.found'),
isRetryable: false
};
case 409:
return {
message: t('github.api.file.conflict.detailed'),
isRetryable: false
};
case 422:
return {
message: t('github.api.invalid.request'),
isRetryable: false
};
case 500:
case 502:
case 503:
case 504:
return {
message: t('github.api.server.error.detailed'),
isRetryable: true
};
default:
return {
message: t('github.api.error.with.status', { status: error.status, message: error.message || 'Unknown error' }),
isRetryable: false
};
}
}
// Network error
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
return {
message: t('github.api.network.failed'),
isRetryable: true
};
}
// Timeout error
if (error.code === 'ETIMEDOUT') {
return {
message: t('github.api.timeout.detailed'),
isRetryable: true
};
}
// Other errors
return {
message: t('github.api.operation.failed', { message: error.message || 'Unknown error' }),
isRetryable: false
};
}
private showErrorNotice(errorInfo: { message: string; isRetryable: boolean }) {
const notice = new Notice(errorInfo.message, errorInfo.isRetryable ? 8000 : 5000);
// If it's a retryable error, add retry hint
if (errorInfo.isRetryable) {
setTimeout(() => {
new Notice(t('github.api.retry.hint'), 3000);
}, 1000);
}
}
public parseRepositoryUrl(url: string): { owner: string; repo: string; path: string } | null {
console.log('Parsing repository URL:', url);
if (!url) {
console.error('URL is empty');
return null;
}
// Remove leading and trailing spaces
const cleanUrl = url.trim();
console.log('Cleaned URL:', cleanUrl);
// Support two formats:
// 1. https://github.com/username/repo/path (standard GitHub URL)
// 2. username/repo/path (short format)
let match;
// Try full GitHub URL format
match = cleanUrl.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)(?:\/(.*))?$/);
if (match) {
const result = {
owner: match[1],
repo: match[2],
path: match[3] || '',
};
console.log('Parse result (standard GitHub URL):', result);
return result;
}
// Try short format username/repo/path
match = cleanUrl.match(/^([^\/]+)\/([^\/]+)(?:\/(.*))?$/);
if (match) {
const result = {
owner: match[1],
repo: match[2],
path: match[3] || '',
};
console.log('Parse result (short format):', result);
return result;
}
console.error('Unable to parse URL format:', cleanUrl);
console.error('Supported formats:');
console.error('1. https://github.com/username/repo/path (standard format)');
console.error('2. username/repo/path (short format)');
return null;
}
async uploadFile(repositoryUrl: string, filePath: string, content: string): Promise<SyncResult> {
const repoInfo = this.parseRepositoryUrl(repositoryUrl);
if (!repoInfo) {
return { success: false, message: t('github.api.invalid.url.format') };
}
// Check rate limit
if (!(await this.beforeApiCall())) {
return { success: false, message: t('github.api.rate.limit.exceeded.short') };
}
try {
const fullPath = repoInfo.path ? `${repoInfo.path}/${filePath}` : filePath;
// First try to get the current SHA of the file (if it exists)
let sha: string | undefined;
try {
const { data } = await this.octokit.rest.repos.getContent({
owner: repoInfo.owner,
repo: repoInfo.repo,
path: fullPath,
});
if ('sha' in data) {
sha = data.sha;
}
} catch (error) {
// File doesn't exist, this is normal
}
// Upload or update file
await this.octokit.rest.repos.createOrUpdateFileContents({
owner: repoInfo.owner,
repo: repoInfo.repo,
path: fullPath,
message: `Update ${filePath}`,
content: Buffer.from(content, 'utf8').toString('base64'),
sha: sha,
});
return { success: true, message: t('github.api.file.upload.success') };
} catch (error) {
console.error('File upload failed:', error);
const errorInfo = this.handleGitHubError(error);
this.showErrorNotice(errorInfo);
return { success: false, message: errorInfo.message };
}
}
async downloadFile(repositoryUrl: string, filePath: string): Promise<SyncResult & { content?: string }> {
const repoInfo = this.parseRepositoryUrl(repositoryUrl);
if (!repoInfo) {
return { success: false, message: t('github.api.invalid.url.format') };
}
// Check rate limit
if (!(await this.beforeApiCall())) {
return { success: false, message: t('github.api.rate.limit.exceeded.short') };
}
try {
const fullPath = repoInfo.path ? `${repoInfo.path}/${filePath}` : filePath;
const { data } = await this.octokit.rest.repos.getContent({
owner: repoInfo.owner,
repo: repoInfo.repo,
path: fullPath,
});
if ('content' in data) {
const content = Buffer.from(data.content, 'base64').toString('utf8');
return { success: true, message: '文件下载成功', content };
} else {
return { success: false, message: '指定路径不是文件' };
}
} catch (error) {
console.error('File download failed:', error);
const errorInfo = this.handleGitHubError(error);
this.showErrorNotice(errorInfo);
return { success: false, message: errorInfo.message };
}
}
async downloadAllFiles(repositoryUrl: string): Promise<SyncResult & { files?: Array<{ path: string; content: string }> }> {
const repoInfo = this.parseRepositoryUrl(repositoryUrl);
if (!repoInfo) {
return { success: false, message: t('github.api.invalid.url.format') };
}
// Check rate limit
if (!(await this.beforeApiCall())) {
return { success: false, message: t('github.api.rate.limit.exceeded.short') };
}
try {
const files = await this.getAllFilesRecursively(repoInfo.owner, repoInfo.repo, repoInfo.path);
const downloadedFiles: Array<{ path: string; content: string }> = [];
for (const file of files) {
if (file.type === 'file') {
try {
const { data } = await this.octokit.rest.repos.getContent({
owner: repoInfo.owner,
repo: repoInfo.repo,
path: file.path,
});
if ('content' in data) {
const content = Buffer.from(data.content, 'base64').toString('utf8');
// Remove repository path prefix, keep only relative path
const relativePath = repoInfo.path ? file.path.replace(`${repoInfo.path}/`, '') : file.path;
downloadedFiles.push({ path: relativePath, content });
}
} catch (error) {
console.warn(`跳过文件 ${file.path}:`, error);
}
}
}
return { success: true, message: t('github.api.all.files.download.success'), files: downloadedFiles };
} catch (error) {
console.error('Download all files failed:', error);
const errorInfo = this.handleGitHubError(error);
this.showErrorNotice(errorInfo);
return { success: false, message: errorInfo.message };
}
}
private async getAllFilesRecursively(owner: string, repo: string, path: string): Promise<GitHubFile[]> {
const allFiles: GitHubFile[] = [];
try {
const { data } = await this.octokit.rest.repos.getContent({
owner,
repo,
path,
});
if (Array.isArray(data)) {
for (const item of data) {
if (item.type === 'file') {
allFiles.push(item as GitHubFile);
} else if (item.type === 'dir') {
const subFiles = await this.getAllFilesRecursively(owner, repo, item.path);
allFiles.push(...subFiles);
}
}
}
} catch (error) {
console.error(`Failed to get directory content ${path}:`, error);
}
return allFiles;
}
async getFileLastModified(repositoryUrl: string, filePath: string): Promise<{ success: boolean; lastModified?: string; exists: boolean }> {
const repoInfo = this.parseRepositoryUrl(repositoryUrl);
if (!repoInfo) {
return { success: false, exists: false };
}
// For status bar queries, we do lightweight rate limit checking
if (this.rateLimitInfo && this.rateLimitInfo.remaining === 0) {
return { success: false, exists: false };
}
try {
const fullPath = repoInfo.path ? `${repoInfo.path}/${filePath}` : filePath;
// Get file's commit history, only get the latest one
const { data } = await this.octokit.rest.repos.listCommits({
owner: repoInfo.owner,
repo: repoInfo.repo,
path: fullPath,
per_page: 1,
});
if (data.length > 0) {
const lastCommit = data[0];
return {
success: true,
exists: true,
lastModified: lastCommit.commit.committer?.date || lastCommit.commit.author?.date
};
} else {
return { success: true, exists: false };
}
} catch (error) {
console.error('Failed to get file last modified time:', error);
// For status bar queries, we don't show error notifications, only log in console
const errorInfo = this.handleGitHubError(error);
return { success: false, exists: false };
}
}
}

366
i18n-simple.ts Normal file
View file

@ -0,0 +1,366 @@
// Simplified internationalization system
export type Language = 'zh' | 'en' | 'auto';
// Translation texts
const translations = {
zh: {
// Settings interface
'settings.title': 'Git同步设置',
'settings.language.name': '界面语言',
'settings.language.desc': '选择插件界面显示语言',
'settings.language.auto': '跟随Obsidian',
'settings.github.token.name': 'GitHub Personal Token',
'settings.github.token.desc': '输入你的GitHub Personal Access Token',
'settings.github.token.placeholder': 'ghp_xxxxxxxxxxxx',
'settings.github.repo.name': 'GitHub仓库路径',
'settings.github.repo.desc': '支持两种格式:\n1. https://github.com/用户名/仓库名/路径\n2. 用户名/仓库名/路径',
'settings.github.repo.placeholder': 'https://github.com/Xheldon/git-sync/data/_post',
'settings.ribbon.name': '将插件按钮显示在侧边栏',
'settings.ribbon.desc': '开启后,在左侧边栏显示插件按钮,点击可快速打开设置界面',
'settings.save.name': '保存设置',
'settings.save.desc': '保存当前的配置设置',
'settings.save.button': '保存设置',
'settings.diagnose.name': '诊断Vault文件',
'settings.diagnose.desc': '检查当前Vault中有哪些文件可以同步',
'settings.diagnose.button': '诊断',
'settings.test.url.name': '测试仓库URL',
'settings.test.url.desc': '验证GitHub仓库路径格式是否正确',
'settings.test.url.button': '测试URL',
'settings.sponsor.name': '💖 支持开发者',
'settings.sponsor.desc': '如果这个插件对你有帮助,欢迎请我喝杯咖啡!你的支持是我继续开发的动力。',
'settings.sponsor.button': '💝 PayPal 赞助',
'settings.sponsor.china.label': '🇨🇳 中国大陆捐赠渠道',
'settings.sponsor.section.title': '赞助',
'settings.danger.zone.title': '危险区域',
'settings.init.name': '初始化仓库',
'settings.init.desc': '当本Vault为空的时候可用将远端文件同步到本地',
'settings.init.button': '初始化仓库',
'settings.init.loading': '初始化中...',
'settings.sync.remote.to.local.name': '强制同步远端路径到本地',
'settings.sync.remote.to.local.desc': '将远端的文件一股脑的同步到本地的Vault中同名文件会被覆盖',
'settings.sync.remote.to.local.button': '强制同步到本地',
'settings.sync.remote.to.local.loading': '同步中...',
'settings.sync.local.to.remote.name': '强制同步本地文件到远端',
'settings.sync.local.to.remote.desc': '将本地的Vault中的所有文件同步到远端配置的路径中同名文件会被覆盖',
'settings.sync.local.to.remote.button': '强制同步到远端',
'settings.sync.local.to.remote.loading': '同步中...',
'settings.clear.cache.name': '清空文件缓存',
'settings.clear.cache.desc': '清空所有文件状态缓存下次查看时将重新从GitHub获取',
'settings.clear.cache.button': '清空缓存',
// Notification messages
'notice.settings.saved': '设置已保存',
'notice.config.required': '请先配置GitHub Token和仓库地址',
'notice.url.required': '请先输入GitHub仓库路径',
'notice.url.test.success': 'URL格式正确',
'notice.url.test.failed': 'URL格式不正确请检查格式。详情请查看控制台。',
'notice.init.error': '初始化仓库时发生错误',
'notice.sync.error': '强制同步时发生错误',
'notice.diagnose.result': 'Vault中共有 {total} 个文件,其中 {syncable} 个可同步。详情请查看控制台。',
'notice.result.with.count': '{message},处理了 {count} 个文件',
'notice.cache.cleared': '已清空 {count} 个文件的缓存',
'notice.cache.clear.error': '清空缓存时发生错误',
// Sync operations
'sync.success': '同步成功: {filename}',
'sync.failed': '同步失败: {message}',
'sync.error': '同步时发生错误',
'pull.success': '拉取成功: {filename}',
'pull.failed': '拉取失败: {message}',
'pull.error': '拉取时发生错误',
// Repository operations
'vault.not.empty': 'Vault不为空无法初始化',
'repo.init.success': '仓库初始化成功',
'repo.init.failed': '仓库初始化失败: {error}',
'force.sync.remote.to.local.success': '强制同步远端到本地成功',
'force.sync.remote.to.local.failed': '强制同步远端到本地失败: {error}',
'no.syncable.files.in.vault': 'Vault中没有可同步的文件',
'force.sync.local.to.remote.success.no.failures': '强制同步本地到远端成功',
'force.sync.local.to.remote.success': '强制同步本地到远端完成,成功: {processed},失败: {failed}',
'force.sync.local.to.remote.failed': '强制同步本地到远端失败: {error}',
// Actions
'actions.sync.current.to.remote': '同步当前文件到远端',
'actions.pull.remote.to.current': '从远端拉取到当前文件',
// Date
'date.today': '今天',
'date.yesterday': '昨天',
// GitHub API Error
'github.api.rate.limit.exceeded': 'GitHub API调用次数已达上限。请等待 {minutes} 分钟后重试。',
'github.api.rate.limit.warning': '⚠️ GitHub API调用次数剩余 {remaining} 次,请谨慎使用。',
'github.api.rate.limit.notice': '速率限制超出',
'github.api.token.invalid': 'GitHub Token无效或已过期。请检查\n1. Token是否正确\n2. Token是否有足够的权限\n3. Token是否已过期',
'github.api.rate.limit.hourly': 'GitHub API调用次数已达上限每小时5000次。\n请等待 {minutes} 分钟后重试或使用GitHub Pro账号获得更高限额。',
'github.api.repo.not.found': '仓库不存在或无访问权限。请检查:\n1. 仓库地址是否正确\n2. Token是否有访问该仓库的权限\n3. 仓库是否为私有仓库',
'github.api.file.conflict': '文件冲突。远程文件已被其他人修改。\n请先同步远程更改然后重试。',
'github.api.validation.failed': 'GitHub API验证失败。请检查\n1. 文件路径是否正确\n2. 文件内容是否符合要求\n3. 仓库权限设置',
'github.api.server.error': 'GitHub服务器错误{status})。请稍后重试。',
'github.api.network.error': '网络连接错误。请检查:\n1. 网络连接是否正常\n2. 防火墙设置\n3. 代理配置',
'github.api.timeout.error': '请求超时。请检查网络连接或稍后重试。',
'github.api.unknown.error': '未知错误:{message}',
'github.api.access.denied': 'GitHub访问被拒绝。可能原因\n1. Token权限不足\n2. 仓库为私有且Token无访问权限\n3. 仓库不存在',
'github.api.resource.not.found': '资源未找到。请检查:\n1. 仓库路径是否正确\n2. 文件路径是否存在\n3. Token是否有访问该仓库的权限',
'github.api.file.conflict.detailed': '文件冲突。可能原因:\n1. 文件在远程已被修改\n2. 请先从远端同步最新版本',
'github.api.invalid.request': '请求参数无效。请检查:\n1. 文件内容是否过大(>100MB\n2. 文件路径格式是否正确',
'github.api.server.error.detailed': 'GitHub服务器错误请稍后重试。如果问题持续存在请检查GitHub服务状态。',
'github.api.error.with.status': 'GitHub API错误 ({status}): {message}',
'github.api.network.failed': '网络连接失败。请检查:\n1. 网络连接是否正常\n2. 是否能访问github.com\n3. 防火墙或代理设置',
'github.api.timeout.detailed': '请求超时。请检查网络连接或稍后重试。',
'github.api.operation.failed': '操作失败: {message}',
'github.api.retry.hint': '💡 提示:这是临时性错误,稍后可以重试',
'github.api.invalid.url.format': '无效的仓库URL格式',
'github.api.rate.limit.exceeded.short': 'GitHub API调用次数已达上限请稍后重试',
'github.api.file.upload.success': '文件上传成功',
'github.api.all.files.download.success': '所有文件下载成功',
// Status bar
'status.bar.checking': '检查中...',
'status.bar.sync': '同步',
'status.bar.rate.limit': 'API限制: 等待{minutes}分钟',
'status.bar.last.modified': '上次更新: {date}',
'status.bar.not.published': '未发布',
'status.bar.check.failed': '检查失败',
'status.bar.not.configured': '未配置',
'status.bar.local.modified': '本地已修改',
'status.bar.synced': '已同步',
// Plugin info
'plugin.name': 'Git同步',
'command.show.sync.menu': '显示同步菜单',
// Test URL results
'test.url.user': '用户',
'test.url.repo': '仓库',
'test.url.path': '路径',
'test.url.root': '根目录',
// Path info
'path.info.empty': '请输入GitHub仓库路径以查看同步目标',
'path.info.sync.to': '当前 Vault 内容将同步到 {owner}/{repo} 仓库',
'path.info.folder': ' 的 {path} 文件夹下',
'path.info.root': ' 的根目录下',
'path.info.error': '路径格式不正确,请使用: https://github.com/用户名/仓库名/路径 或 用户名/仓库名/路径',
},
en: {
// Settings interface
'settings.title': 'Git Sync Settings',
'settings.language.name': 'Interface Language',
'settings.language.desc': 'Select the display language for the plugin interface',
'settings.language.auto': 'Follow Obsidian',
'settings.github.token.name': 'GitHub Personal Token',
'settings.github.token.desc': 'Enter your GitHub Personal Access Token',
'settings.github.token.placeholder': 'ghp_xxxxxxxxxxxx',
'settings.github.repo.name': 'GitHub Repository Path',
'settings.github.repo.desc': 'Supports two formats:\n1. https://github.com/username/repo/path\n2. username/repo/path',
'settings.github.repo.placeholder': 'https://github.com/Xheldon/git-sync/data/_post',
'settings.ribbon.name': 'Show plugin button in sidebar',
'settings.ribbon.desc': 'When enabled, display plugin button in left sidebar for quick access to settings',
'settings.save.name': 'Save Settings',
'settings.save.desc': 'Save current configuration settings',
'settings.save.button': 'Save Settings',
'settings.diagnose.name': 'Diagnose Vault Files',
'settings.diagnose.desc': 'Check which files in current Vault can be synchronized',
'settings.diagnose.button': 'Diagnose',
'settings.test.url.name': 'Test Repository URL',
'settings.test.url.desc': 'Verify if GitHub repository path format is correct',
'settings.test.url.button': 'Test URL',
'settings.sponsor.name': '💖 Support Developer',
'settings.sponsor.desc': 'If this plugin helps you, consider buying me a coffee! Your support motivates me to continue development.',
'settings.sponsor.button': '💝 PayPal Sponsor',
'settings.sponsor.section.title': 'Sponsor',
'settings.danger.zone.title': 'Danger Zone',
'settings.init.name': 'Initialize Repository',
'settings.init.desc': 'Available when Vault is empty, sync remote files to local',
'settings.init.button': 'Initialize Repository',
'settings.init.loading': 'Initializing...',
'settings.sync.remote.to.local.name': 'Force Sync Remote to Local',
'settings.sync.remote.to.local.desc': 'Sync all remote files to local Vault, existing files will be overwritten',
'settings.sync.remote.to.local.button': 'Force Sync to Local',
'settings.sync.remote.to.local.loading': 'Syncing...',
'settings.sync.local.to.remote.name': 'Force Sync Local to Remote',
'settings.sync.local.to.remote.desc': 'Sync all local Vault files to remote repository, existing files will be overwritten',
'settings.sync.local.to.remote.button': 'Force Sync to Remote',
'settings.sync.local.to.remote.loading': 'Syncing...',
'settings.clear.cache.name': 'Clear File Cache',
'settings.clear.cache.desc': 'Clear all file status cache, will fetch from GitHub on next view',
'settings.clear.cache.button': 'Clear Cache',
// Notification messages
'notice.settings.saved': 'Settings saved',
'notice.config.required': 'Please configure GitHub Token and repository address first',
'notice.url.required': 'Please enter GitHub repository path first',
'notice.url.test.success': 'URL format is correct!',
'notice.url.test.failed': 'URL format is incorrect, please check format. See console for details.',
'notice.init.error': 'Error occurred during repository initialization',
'notice.sync.error': 'Error occurred during force sync',
'notice.diagnose.result': 'Vault has {total} files in total, {syncable} can be synced. See console for details.',
'notice.result.with.count': '{message}, processed {count} files',
'notice.cache.cleared': 'Cleared cache for {count} files',
'notice.cache.clear.error': 'Error occurred while clearing cache',
// Sync operations
'sync.success': 'Sync success: {filename}',
'sync.failed': 'Sync failed: {message}',
'sync.error': 'Error occurred during sync',
'pull.success': 'Pull success: {filename}',
'pull.failed': 'Pull failed: {message}',
'pull.error': 'Error occurred during pull',
// Repository operations
'vault.not.empty': 'Vault is not empty, cannot initialize',
'repo.init.success': 'Repository initialized successfully',
'repo.init.failed': 'Repository initialization failed: {error}',
'force.sync.remote.to.local.success': 'Force sync remote to local success',
'force.sync.remote.to.local.failed': 'Force sync remote to local failed: {error}',
'no.syncable.files.in.vault': 'No syncable files in vault',
'force.sync.local.to.remote.success.no.failures': 'Force sync local to remote success',
'force.sync.local.to.remote.success': 'Force sync local to remote completed, success: {processed}, failed: {failed}',
'force.sync.local.to.remote.failed': 'Force sync local to remote failed: {error}',
// Actions
'actions.sync.current.to.remote': 'Sync current file to remote',
'actions.pull.remote.to.current': 'Pull remote to current file',
// Date
'date.today': 'Today',
'date.yesterday': 'Yesterday',
// GitHub API errors
'github.api.rate.limit.exceeded': 'GitHub API rate limit exceeded. Please wait {minutes} minutes before retrying.',
'github.api.rate.limit.warning': '⚠️ GitHub API calls remaining: {remaining}. Please use carefully.',
'github.api.rate.limit.notice': 'Rate limit exceeded',
'github.api.token.invalid': 'GitHub Token is invalid or expired. Please check:\n1. Token is correct\n2. Token has sufficient permissions\n3. Token has not expired',
'github.api.rate.limit.hourly': 'GitHub API hourly rate limit exceeded (5000 per hour).\nPlease wait {minutes} minutes or use GitHub Pro for higher limits.',
'github.api.repo.not.found': 'Repository not found or no access permission. Please check:\n1. Repository URL is correct\n2. Token has access to this repository\n3. Repository is not private',
'github.api.file.conflict': 'File conflict. Remote file has been modified by others.\nPlease sync remote changes first, then retry.',
'github.api.validation.failed': 'GitHub API validation failed. Please check:\n1. File path is correct\n2. File content meets requirements\n3. Repository permission settings',
'github.api.server.error': 'GitHub server error ({status}). Please retry later.',
'github.api.network.error': 'Network connection error. Please check:\n1. Network connection is normal\n2. Firewall settings\n3. Proxy configuration',
'github.api.timeout.error': 'Request timeout. Please check network connection or retry later.',
'github.api.unknown.error': 'Unknown error: {message}',
'github.api.access.denied': 'GitHub access denied. Possible reasons:\n1. Insufficient token permissions\n2. Repository is private and token has no access\n3. Repository does not exist',
'github.api.resource.not.found': 'Resource not found. Please check:\n1. Repository path is correct\n2. File path exists\n3. Token has access to this repository',
'github.api.file.conflict.detailed': 'File conflict. Possible reasons:\n1. File has been modified remotely\n2. Please sync from remote first',
'github.api.invalid.request': 'Invalid request parameters. Please check:\n1. File content is not too large (>100MB)\n2. File path format is correct',
'github.api.server.error.detailed': 'GitHub server error, please retry later. If problem persists, check GitHub service status.',
'github.api.error.with.status': 'GitHub API error ({status}): {message}',
'github.api.network.failed': 'Network connection failed. Please check:\n1. Network connection is normal\n2. Can access github.com\n3. Firewall or proxy settings',
'github.api.timeout.detailed': 'Request timeout. Please check network connection or retry later.',
'github.api.operation.failed': 'Operation failed: {message}',
'github.api.retry.hint': '💡 Tip: This is a temporary error, you can retry later',
'github.api.invalid.url.format': 'Invalid repository URL format',
'github.api.rate.limit.exceeded.short': 'GitHub API rate limit exceeded, please retry later',
'github.api.file.upload.success': 'File uploaded successfully',
'github.api.all.files.download.success': 'All files downloaded successfully',
// Status bar
'status.bar.checking': 'Checking...',
'status.bar.sync': 'Sync',
'status.bar.rate.limit': 'API limit: wait {minutes} minutes',
'status.bar.last.modified': 'Last modified: {date}',
'status.bar.not.published': 'Not published',
'status.bar.check.failed': 'Check failed',
'status.bar.not.configured': 'Not configured',
'status.bar.local.modified': 'Local modified',
'status.bar.synced': 'Synced',
// Plugin info
'plugin.name': 'Git Sync',
'command.show.sync.menu': 'Show Sync Menu',
// Test URL results
'test.url.user': 'User',
'test.url.repo': 'Repo',
'test.url.path': 'Path',
'test.url.root': 'Root',
// Path info
'path.info.empty': 'Enter GitHub repository path to view sync target',
'path.info.sync.to': 'Current Vault content will sync to {owner}/{repo} repository',
'path.info.folder': ' under {path} folder',
'path.info.root': ' under root directory',
'path.info.error': 'Path format is incorrect, please use: https://github.com/username/repo/path or username/repo/path',
}
};
// Current language
let currentLanguage: Language = 'auto';
// Detect Obsidian's language settings
function detectObsidianLanguage(): 'zh' | 'en' {
// Check Obsidian's language settings - more accurate method
const obsidianLang = localStorage.getItem('language') ||
(window as any).app?.vault?.adapter?.path?.locale ||
(window as any).moment?.locale();
// Check browser language
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
// If it's Chinese-related language code, return Chinese
if (obsidianLang?.includes('zh') || browserLang.startsWith('zh')) {
return 'zh';
}
// Default return English
return 'en';
}
// Get the actual language used
export function getActualLanguage(): 'zh' | 'en' {
if (currentLanguage === 'auto') {
return detectObsidianLanguage();
}
return currentLanguage as 'zh' | 'en';
}
// Set current language
export function setLanguage(language: Language): void {
currentLanguage = language;
}
// Get current language
export function getCurrentLanguage(): Language {
return currentLanguage;
}
// Translation function
export function t(key: string, params?: Record<string, string | number>): string {
const actualLang = getActualLanguage();
const translation = translations[actualLang][key as keyof typeof translations['zh']];
if (!translation) {
console.warn(`Translation missing for key: ${key}`);
return key;
}
// If there are parameters, perform replacement
if (params) {
return Object.entries(params).reduce((text, [paramKey, paramValue]) => {
return text.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue));
}, translation);
}
return translation;
}
// Get all supported languages
export function getSupportedLanguages(): Array<{ value: Language; label: string }> {
// Detect system language for displaying content in parentheses
const detectedLang = detectObsidianLanguage();
const detectedLangText = detectedLang === 'zh' ? '中文' : 'English';
// Determine the display text for "Follow" based on current plugin language
const currentLang = getActualLanguage();
const followText = currentLang === 'zh' ? '跟随Obsidian' : 'Follow Obsidian';
const autoLabel = `${followText} (${detectedLangText})`;
return [
{ value: 'auto', label: autoLabel },
{ value: 'zh', label: '中文' },
{ value: 'en', label: 'English' },
];
}

953
main.ts Normal file
View file

@ -0,0 +1,953 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, Menu, TFolder } from 'obsidian';
import { GitSyncSettings, DEFAULT_SETTINGS, SyncResult } from './types';
import { GitHubService } from './github-service';
import { setLanguage, t, getSupportedLanguages, getActualLanguage } from './i18n-simple';
import { FileCacheService } from './file-cache';
export default class GitSyncPlugin extends Plugin {
settings: GitSyncSettings;
githubService: GitHubService;
fileCacheService: FileCacheService;
ribbonIconEl: HTMLElement | null = null;
statusBarEl: HTMLElement | null = null;
currentFile: TFile | null = null;
private fileModifyTimeout: NodeJS.Timeout | null = null;
async onload() {
await this.loadSettings();
// Initialize language settings
setLanguage(this.settings.language);
this.githubService = new GitHubService(this.settings.githubToken);
this.fileCacheService = new FileCacheService();
// Add ribbon icon based on settings
this.updateRibbonIcon();
// Add status bar
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass('git-sync-status-bar');
// Listen for file switching events
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
this.updateStatusBar();
})
);
// Listen for file open events
this.registerEvent(
this.app.workspace.on('file-open', (file) => {
this.currentFile = file;
this.updateStatusBar();
})
);
// Listen for file content modification events
this.registerEvent(
this.app.vault.on('modify', (file) => {
// Only handle markdown files that are currently open
if (file.path.endsWith('.md') && this.currentFile && file.path === this.currentFile.path) {
console.log(`Detected file modification: ${file.path}`);
// Type conversion to TFile
if (file instanceof TFile) {
this.onFileContentModified(file);
}
}
})
);
// Add sync button to note editing interface
this.addCommand({
id: 'show-sync-menu',
name: t('command.show.sync.menu'),
editorCallback: (editor: Editor, view: MarkdownView) => {
this.showSyncMenu(editor, view);
}
});
// Add editor right-click menu
this.registerEvent(
this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: MarkdownView) => {
menu.addItem((item) => {
item
.setTitle(t('plugin.name'))
.setIcon('sync')
.onClick(async () => {
this.showSyncMenu(editor, view);
});
});
})
);
// Add settings page
this.addSettingTab(new GitSyncSettingTab(this.app, this));
// Initial status bar update
this.updateStatusBar();
}
onunload() {
// Clean up timers
if (this.fileModifyTimeout) {
clearTimeout(this.fileModifyTimeout);
this.fileModifyTimeout = null;
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.githubService.updateToken(this.settings.githubToken);
// Update language settings
setLanguage(this.settings.language);
// Update ribbon icon display state
this.updateRibbonIcon();
}
private updateRibbonIcon() {
// Remove existing button
if (this.ribbonIconEl) {
this.ribbonIconEl.remove();
this.ribbonIconEl = null;
}
// Add button based on settings
if (this.settings.showRibbonIcon) {
this.ribbonIconEl = this.addRibbonIcon('settings', t('settings.title'), (evt: MouseEvent) => {
// Directly open plugin settings interface
(this.app as any).setting.open();
(this.app as any).setting.openTabById(this.manifest.id);
});
}
}
private showSyncMenu(editor: Editor, view: MarkdownView) {
const menu = new Menu();
menu.addItem((item) => {
item
.setTitle(t('actions.sync.current.to.remote'))
.setIcon('upload')
.onClick(async () => {
await this.syncCurrentFileToRemote(view.file);
});
});
menu.addItem((item) => {
item
.setTitle(t('actions.pull.remote.to.current'))
.setIcon('download')
.onClick(async () => {
await this.pullRemoteToCurrentFile(view.file);
});
});
const rect = (editor as any).containerEl?.getBoundingClientRect();
if (rect) {
menu.showAtPosition({ x: rect.right - 100, y: rect.top + 50 });
} else {
// If unable to get editor position, show at mouse position
menu.showAtMouseEvent(new MouseEvent('click'));
}
}
async syncCurrentFileToRemote(file: TFile): Promise<void> {
if (!this.settings.githubToken || !this.settings.repositoryUrl) {
new Notice(t('settings.github.token.or.repo.not.configured'));
return;
}
try {
const content = await this.app.vault.read(file);
const result = await this.githubService.uploadFile(
this.settings.repositoryUrl,
file.path,
content
);
if (result.success) {
new Notice(t('sync.success', { filename: file.name }));
// Update cache - file synced to remote
await this.fileCacheService.updateFileCache(
file.path,
file.path,
new Date().toISOString(), // Use current time as last modified time
'', // SHA value can be obtained from upload result later
true, // Published
this.app.vault
);
} else {
new Notice(t('sync.failed', { message: result.message }));
}
} catch (error) {
console.error('File sync failed:', error);
new Notice(t('sync.error'));
}
}
async pullRemoteToCurrentFile(file: TFile): Promise<void> {
if (!this.settings.githubToken || !this.settings.repositoryUrl) {
new Notice(t('settings.github.token.or.repo.not.configured'));
return;
}
try {
const result = await this.githubService.downloadFile(
this.settings.repositoryUrl,
file.path
);
if (result.success && result.content) {
await this.app.vault.modify(file, result.content);
new Notice(t('pull.success', { filename: file.name }));
// Update cache - file synced from remote
await this.fileCacheService.updateFileCache(
file.path,
file.path,
new Date().toISOString(), // Use current time as last modified time
'', // SHA value can be obtained from download result later
true, // Published
this.app.vault
);
} else {
new Notice(t('pull.failed', { message: result.message }));
}
} catch (error) {
console.error('File pull failed:', error);
new Notice(t('pull.error'));
}
}
async initializeRepository(): Promise<SyncResult> {
if (!this.isVaultEmpty()) {
return { success: false, message: t('vault.not.empty') };
}
try {
const result = await this.githubService.downloadAllFiles(this.settings.repositoryUrl);
if (result.success && result.files) {
for (const file of result.files) {
await this.createFileInVault(file.path, file.content || '');
// Update cache - file initialized from remote
await this.fileCacheService.updateFileCache(
file.path,
file.path,
new Date().toISOString(), // Use current time as last modified time
'', // SHA value can be obtained from download result later
true, // Published
this.app.vault
);
}
// Refresh status bar display
setTimeout(() => this.updateStatusBar(), 2000);
return { success: true, message: t('repo.init.success'), filesProcessed: result.files.length };
}
return { success: false, message: result.message };
} catch (error) {
return { success: false, message: t('repo.init.failed', { error: error.message }) };
}
}
async forceSyncRemoteToLocal(): Promise<SyncResult> {
try {
const result = await this.githubService.downloadAllFiles(this.settings.repositoryUrl);
if (result.success && result.files) {
for (const file of result.files) {
await this.createFileInVault(file.path, file.content || '');
// Update cache - file synced from remote
await this.fileCacheService.updateFileCache(
file.path,
file.path,
new Date().toISOString(), // Use current time as last modified time
'', // SHA value can be obtained from download result later
true, // Published
this.app.vault
);
}
// Refresh status bar display
setTimeout(() => this.updateStatusBar(), 2000);
return { success: true, message: t('force.sync.remote.to.local.success'), filesProcessed: result.files.length };
}
return { success: false, message: result.message };
} catch (error) {
return { success: false, message: t('force.sync.remote.to.local.failed', { error: error.message }) };
}
}
async forceSyncLocalToRemote(): Promise<SyncResult> {
try {
const files = this.getAllVaultFiles();
console.log('Found files:', files.map(f => f.path));
if (files.length === 0) {
return { success: false, message: t('no.syncable.files.in.vault') };
}
let processed = 0;
let failed = 0;
for (const file of files) {
try {
const content = await this.app.vault.read(file);
const result = await this.githubService.uploadFile(
this.settings.repositoryUrl,
file.path,
content
);
if (result.success) {
processed++;
console.log(`Successfully synced file: ${file.path}`);
// Update cache immediately - file synced to remote
await this.fileCacheService.updateFileCache(
file.path,
file.path,
new Date().toISOString(), // Use current time as last modified time
'', // SHA value can be obtained from upload result later
true, // Published
this.app.vault
);
} else {
failed++;
console.error(`File sync failed: ${file.path}`, result.message);
}
} catch (error) {
failed++;
console.error(`Failed to read or sync file: ${file.path}`, error);
}
}
const message = failed > 0
? t('force.sync.local.to.remote.success', { processed, failed })
: t('force.sync.local.to.remote.success.no.failures');
// Refresh status bar display
setTimeout(() => this.updateStatusBar(), 2000);
return { success: true, message, filesProcessed: processed };
} catch (error) {
console.error('Force sync failed:', error);
return { success: false, message: t('force.sync.local.to.remote.failed', { error: error.message }) };
}
}
private isVaultEmpty(): boolean {
const files = this.app.vault.getFiles();
return files.filter(file => !file.path.startsWith('.obsidian')).length === 0;
}
private getAllVaultFiles(): TFile[] {
const allFiles = this.app.vault.getFiles();
console.log('All files in vault:', allFiles.map(f => f.path));
// Filter out files in .obsidian folder
const filteredFiles = allFiles.filter(file => !file.path.startsWith('.obsidian'));
console.log('Filtered files:', filteredFiles.map(f => f.path));
return filteredFiles;
}
private async createFileInVault(path: string, content: string): Promise<void> {
const folderPath = path.substring(0, path.lastIndexOf('/'));
if (folderPath && !this.app.vault.getAbstractFileByPath(folderPath)) {
await this.app.vault.createFolder(folderPath);
}
const existingFile = this.app.vault.getAbstractFileByPath(path);
if (existingFile instanceof TFile) {
await this.app.vault.modify(existingFile, content);
} else {
await this.app.vault.create(path, content);
}
}
private async updateStatusBar() {
if (!this.statusBarEl) return;
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !activeFile.path.endsWith('.md')) {
this.statusBarEl.empty();
this.statusBarEl.style.display = 'none';
return;
}
this.currentFile = activeFile;
this.statusBarEl.style.display = 'flex';
this.statusBarEl.empty();
// Create status text
const statusText = this.statusBarEl.createEl('span', {
cls: 'git-sync-status-text',
text: t('status.bar.checking')
});
// Create sync button
const syncButton = this.statusBarEl.createEl('button', {
cls: 'git-sync-status-button',
text: t('status.bar.sync')
});
// Check file remote status
if (this.settings.githubToken && this.settings.repositoryUrl) {
try {
// First check cache
const cache = this.fileCacheService.getFileCache(activeFile.path);
if (cache) {
// Use cached data
console.log(`Using cached data to display file status: ${activeFile.path}`);
if (cache.isPublished) {
const date = new Date(cache.lastModified);
let statusMsg = t('status.bar.last.modified', { date: this.formatDate(date) });
// Use isSynced status from cache directly, no need to recalculate hash
if (!cache.isSynced) {
statusMsg += ` (${t('status.bar.local.modified')})`;
statusText.style.color = 'var(--color-orange)';
} else {
statusMsg += ` (${t('status.bar.synced')})`;
statusText.style.color = 'var(--color-green)';
}
statusText.textContent = statusMsg;
} else {
statusText.textContent = t('status.bar.not.published');
statusText.style.color = 'var(--text-muted)';
}
// Asynchronously update cache if it's about to expire
if (!this.fileCacheService.isCacheValid(cache, 4 * 60 * 1000)) { // Start async refresh after 4 minutes
this.refreshFileCache(activeFile.path).catch(console.error);
}
} else {
// No cache, need to fetch from GitHub
console.log(`No cache, fetching file status from GitHub: ${activeFile.path}`);
await this.fetchAndCacheFileStatus(activeFile.path, statusText);
}
} catch (error) {
console.error('Failed to update status bar:', error);
statusText.textContent = t('status.bar.check.failed');
}
} else {
statusText.textContent = t('status.bar.not.configured');
}
// Add button click event
syncButton.addEventListener('click', (e) => {
e.stopPropagation();
this.showSyncMenuForStatusBar(activeFile);
});
}
/**
* Handle file content modification events (with debounce)
*/
private async onFileContentModified(file: TFile) {
// Clear previous timer
if (this.fileModifyTimeout) {
clearTimeout(this.fileModifyTimeout);
}
// Set debounce, execute after 2000ms
this.fileModifyTimeout = setTimeout(async () => {
try {
// Get current file cache
const cache = this.fileCacheService.getFileCache(file.path);
if (!cache) {
// No cache, update status bar directly
this.updateStatusBar();
return;
}
// Check if file was actually modified
const content = await this.app.vault.read(file);
const currentHash = FileCacheService.calculateContentHash(content);
if (currentHash !== cache.contentHash) {
console.log(`File content modified: ${file.path}`);
// Update content hash and sync status in cache
const updatedCache = {
...cache,
contentHash: currentHash,
isSynced: false, // Mark as not synced
cacheTime: Date.now()
};
this.fileCacheService.setFileCache(file.path, updatedCache);
// Update status bar display immediately
this.updateStatusBar();
}
} catch (error) {
console.error('Failed to handle file modification event:', error);
}
}, 2000);
}
/**
* Fetch file status from GitHub and cache it
*/
private async fetchAndCacheFileStatus(filePath: string, statusText: HTMLElement) {
try {
// Check rate limit status first
const rateLimitCheck = await this.githubService.checkRateLimit();
if (!rateLimitCheck.canProceed) {
statusText.textContent = t('status.bar.rate.limit', { minutes: rateLimitCheck.waitMinutes });
return;
}
const result = await this.githubService.getFileLastModified(
this.settings.repositoryUrl,
filePath
);
if (result.success) {
if (result.exists && result.lastModified) {
const date = new Date(result.lastModified);
statusText.textContent = t('status.bar.last.modified', { date: this.formatDate(date) });
statusText.style.color = 'var(--text-normal)';
// Update cache
await this.fileCacheService.updateFileCache(
filePath,
filePath, // GitHub path same as local path
result.lastModified,
'', // SHA value temporarily empty, can be obtained from other APIs later
true,
this.app.vault
);
} else {
statusText.textContent = t('status.bar.not.published');
statusText.style.color = 'var(--text-muted)';
// Cache unpublished status
await this.fileCacheService.updateFileCache(
filePath,
filePath,
'',
'',
false,
this.app.vault
);
}
} else {
statusText.textContent = t('status.bar.check.failed');
}
} catch (error) {
console.error('Failed to fetch file status:', error);
statusText.textContent = t('status.bar.check.failed');
}
}
/**
* Asynchronously refresh file cache
*/
private async refreshFileCache(filePath: string) {
try {
console.log(`Asynchronously refreshing file cache: ${filePath}`);
// Lightweight rate limit check - temporarily skip, let GitHub API handle rate limits itself
const result = await this.githubService.getFileLastModified(
this.settings.repositoryUrl,
filePath
);
if (result.success) {
await this.fileCacheService.updateFileCache(
filePath,
filePath,
result.lastModified || '',
'', // SHA value temporarily empty
result.exists || false,
this.app.vault
);
console.log(`File cache refreshed: ${filePath}`);
}
} catch (error) {
console.error('Failed to refresh file cache:', error);
}
}
private showSyncMenuForStatusBar(file: TFile) {
const menu = new Menu();
menu.addItem((item) => {
item
.setTitle(t('actions.sync.current.to.remote'))
.setIcon('upload')
.onClick(async () => {
await this.syncCurrentFileToRemote(file);
// Update status bar
setTimeout(() => this.updateStatusBar(), 1000);
});
});
menu.addItem((item) => {
item
.setTitle(t('actions.pull.remote.to.current'))
.setIcon('download')
.onClick(async () => {
await this.pullRemoteToCurrentFile(file);
// Update status bar
setTimeout(() => this.updateStatusBar(), 1000);
});
});
// Show menu near status bar button
const rect = this.statusBarEl?.getBoundingClientRect();
if (rect) {
menu.showAtPosition({ x: rect.left, y: rect.top - 10 });
}
}
private formatDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return t('date.today');
} else if (diffDays === 1) {
return t('date.yesterday');
} else if (diffDays < 7) {
return t('date.days.ago', { days: diffDays });
} else {
return date.toLocaleDateString('zh-CN');
}
}
}
class GitSyncSettingTab extends PluginSettingTab {
plugin: GitSyncPlugin;
constructor(app: App, plugin: GitSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: t('settings.title') });
// Language setting - put at the beginning
new Setting(containerEl)
.setName(t('settings.language.name'))
.setDesc(t('settings.language.desc'))
.addDropdown(dropdown => {
const languages = getSupportedLanguages();
languages.forEach(lang => {
dropdown.addOption(lang.value, lang.label);
});
dropdown.setValue(this.plugin.settings.language);
dropdown.onChange(async (value) => {
this.plugin.settings.language = value as 'zh' | 'en' | 'auto';
await this.plugin.saveSettings();
// Re-render settings interface
this.display();
});
});
new Setting(containerEl)
.setName(t('settings.github.token.name'))
.setDesc(t('settings.github.token.desc'))
.addText(text => text
.setPlaceholder(t('settings.github.token.placeholder'))
.setValue(this.plugin.settings.githubToken)
.onChange(async (value) => {
this.plugin.settings.githubToken = value;
}));
let pathInfoEl: HTMLElement;
new Setting(containerEl)
.setName(t('settings.github.repo.name'))
.setDesc(t('settings.github.repo.desc'))
.addText(text => text
.setPlaceholder(t('settings.github.repo.placeholder'))
.setValue(this.plugin.settings.repositoryUrl)
.onChange(async (value) => {
this.plugin.settings.repositoryUrl = value;
this.updatePathInfo(pathInfoEl, value);
}));
// Add path description display area
pathInfoEl = containerEl.createEl('div', {
cls: 'setting-item-description git-sync-path-info',
text: this.getPathInfoText(this.plugin.settings.repositoryUrl)
});
// Test repository URL button - put below repository path
new Setting(containerEl)
.setName(t('settings.test.url.name'))
.setDesc(t('settings.test.url.desc'))
.addButton(button => button
.setButtonText(t('settings.test.url.button'))
.onClick(async () => {
if (!this.plugin.settings.repositoryUrl) {
new Notice(t('notice.url.required'));
return;
}
console.log('=== Testing Repository URL ===');
console.log('Input URL:', this.plugin.settings.repositoryUrl);
// Test URL parsing
const testResult = this.plugin.githubService.parseRepositoryUrl(this.plugin.settings.repositoryUrl);
if (testResult) {
console.log('URL parsing successful:', testResult);
new Notice(`${t('notice.url.test.success')}\n${t('test.url.user')}: ${testResult.owner}\n${t('test.url.repo')}: ${testResult.repo}\n${t('test.url.path')}: ${testResult.path || t('test.url.root')}`, 6000);
} else {
console.log('URL parsing failed');
new Notice(t('notice.url.test.failed'), 6000);
}
}));
// Sidebar button display toggle
new Setting(containerEl)
.setName(t('settings.ribbon.name'))
.setDesc(t('settings.ribbon.desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showRibbonIcon)
.onChange(async (value) => {
this.plugin.settings.showRibbonIcon = value;
await this.plugin.saveSettings();
}));
// Save settings button - put below basic settings area
const saveButtonContainer = containerEl.createEl('div', {
cls: 'git-sync-save-button-container'
});
const saveButton = saveButtonContainer.createEl('button', {
cls: 'mod-cta',
text: t('settings.save.button')
});
saveButton.addEventListener('click', async () => {
await this.plugin.saveSettings();
new Notice(t('notice.settings.saved'));
});
// Danger zone title
containerEl.createEl('h2', {
text: t('settings.danger.zone.title'),
cls: 'danger-zone'
});
// Initialize repository
new Setting(containerEl)
.setName(t('settings.init.name'))
.setDesc(t('settings.init.desc'))
.addButton(button => {
const isVaultEmpty = this.isVaultEmpty();
button
.setButtonText(t('settings.init.button'))
.setDisabled(!isVaultEmpty)
.onClick(async () => {
if (!this.plugin.settings.githubToken || !this.plugin.settings.repositoryUrl) {
new Notice(t('notice.config.required'));
return;
}
button.setButtonText(t('settings.init.loading'));
button.setDisabled(true);
try {
const result = await this.plugin.initializeRepository();
if (result.success) {
new Notice(t('notice.result.with.count', { message: result.message, count: result.filesProcessed }));
} else {
new Notice(result.message);
}
} catch (error) {
new Notice(t('notice.init.error'));
} finally {
button.setButtonText(t('settings.init.button'));
button.setDisabled(!this.isVaultEmpty());
}
});
if (isVaultEmpty) {
button.setCta();
}
});
// Force sync remote to local
new Setting(containerEl)
.setName(t('settings.sync.remote.to.local.name'))
.setDesc(t('settings.sync.remote.to.local.desc'))
.addButton(button => button
.setButtonText(t('settings.sync.remote.to.local.button'))
.setWarning()
.onClick(async () => {
if (!this.plugin.settings.githubToken || !this.plugin.settings.repositoryUrl) {
new Notice(t('notice.config.required'));
return;
}
button.setButtonText(t('settings.sync.remote.to.local.loading'));
button.setDisabled(true);
try {
const result = await this.plugin.forceSyncRemoteToLocal();
if (result.success) {
new Notice(t('notice.result.with.count', { message: result.message, count: result.filesProcessed }));
} else {
new Notice(result.message);
}
} catch (error) {
new Notice(t('notice.sync.error'));
} finally {
button.setButtonText(t('settings.sync.remote.to.local.button'));
button.setDisabled(false);
}
}));
// Force sync local to remote
new Setting(containerEl)
.setName(t('settings.sync.local.to.remote.name'))
.setDesc(t('settings.sync.local.to.remote.desc'))
.addButton(button => button
.setButtonText(t('settings.sync.local.to.remote.button'))
.setWarning()
.onClick(async () => {
if (!this.plugin.settings.githubToken || !this.plugin.settings.repositoryUrl) {
new Notice(t('notice.config.required'));
return;
}
button.setButtonText(t('settings.sync.local.to.remote.loading'));
button.setDisabled(true);
try {
const result = await this.plugin.forceSyncLocalToRemote();
if (result.success) {
new Notice(t('notice.result.with.count', { message: result.message, count: result.filesProcessed }));
} else {
new Notice(result.message);
}
} catch (error) {
new Notice(t('notice.sync.error'));
} finally {
button.setButtonText(t('settings.sync.local.to.remote.button'));
button.setDisabled(false);
}
}));
// Clear file cache
new Setting(containerEl)
.setName(t('settings.clear.cache.name'))
.setDesc(t('settings.clear.cache.desc'))
.addButton(button => button
.setButtonText(t('settings.clear.cache.button'))
.onClick(async () => {
try {
const stats = this.plugin.fileCacheService.getCacheStats();
this.plugin.fileCacheService.clearCache();
new Notice(t('notice.cache.cleared', { count: stats.totalFiles }));
} catch (error) {
new Notice(t('notice.cache.clear.error'));
}
}));
// Sponsor title
containerEl.createEl('h2', {
text: t('settings.sponsor.section.title'),
cls: 'sponsor'
});
// Sponsor section
const sponsorSection = containerEl.createEl('div', { cls: 'setting-item' });
const sponsorInfo = sponsorSection.createEl('div', { cls: 'setting-item-info' });
sponsorInfo.createEl('div', {
cls: 'setting-item-name',
text: t('settings.sponsor.name')
});
sponsorInfo.createEl('div', {
cls: 'setting-item-description',
text: t('settings.sponsor.desc')
});
const sponsorControl = sponsorSection.createEl('div', {
cls: getActualLanguage() === 'zh' ? 'setting-item-control git-sync-sponsor-control' : 'setting-item-control'
});
// PayPal sponsor button
const sponsorButton = sponsorControl.createEl('a', {
cls: 'mod-cta',
text: t('settings.sponsor.button'),
href: 'https://paypal.me/xheldoncao'
});
sponsorButton.style.textDecoration = 'none';
sponsorButton.style.padding = '6px 12px';
sponsorButton.style.borderRadius = '3px';
sponsorButton.style.display = 'inline-block';
// Show China mainland donation channel if Chinese interface
if (getActualLanguage() === 'zh') {
// Display China mainland donation channel on new line
const chinaSponsorContainer = sponsorControl.createEl('div', {
cls: 'git-sync-china-sponsor'
});
chinaSponsorContainer.createEl('span', {
text: t('settings.sponsor.china.label') + ': '
});
const chinaLink = chinaSponsorContainer.createEl('a', {
text: 'https://www.xheldon.com/donate/',
href: 'https://www.xheldon.com/donate/'
});
chinaLink.style.color = 'var(--text-accent)';
chinaLink.style.textDecoration = 'none';
}
}
private isVaultEmpty(): boolean {
const files = this.app.vault.getFiles();
return files.filter(file => !file.path.startsWith('.obsidian')).length === 0;
}
private updatePathInfo(el: HTMLElement, value: string) {
if (el) {
el.empty();
el.createEl('span', { text: this.getPathInfoText(value) });
}
}
private getPathInfoText(value: string): string {
if (!value) {
return t('path.info.empty');
}
// Use GitHub service parsing method
const parseResult = this.plugin.githubService.parseRepositoryUrl(value);
if (parseResult) {
let pathInfo = t('path.info.sync.to', { owner: parseResult.owner, repo: parseResult.repo });
if (parseResult.path) {
pathInfo += t('path.info.folder', { path: parseResult.path });
} else {
pathInfo += t('path.info.root');
}
return pathInfo;
}
return t('path.info.error');
}
}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "git-sync",
"name": "Git Sync",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Synchronize files in your Obsidian Vault with a specified directory in the designated GitHub repository (rather than the entire repository).",
"author": "Xheldon",
"authorUrl": "https://github.com/Xheldon",
"fundingUrl": "https://paypal.me/xheldoncao",
"isDesktopOnly": false
}

2736
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "git-folder-sync",
"version": "1.0.0",
"description": "Synchronize files in your Obsidian Vault with a specified directory in the designated GitHub repository (rather than the entire repository).",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"plugin",
"github",
"sync"
],
"author": "Xheldon <xheldoncao@gmail.com> (https://github.com/Xheldon)",
"license": "MIT",
"funding": {
"type": "paypal",
"url": "https://paypal.me/xheldoncao"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@octokit/rest": "^19.0.0"
}
}

325
styles.css Normal file
View file

@ -0,0 +1,325 @@
/* Git Sync Plugin Styles */
.git-sync-modal {
min-width: 500px;
max-width: 800px;
width: auto;
max-height: 90vh;
overflow-y: auto;
}
.git-sync-modal .modal-content {
padding: 20px;
}
.git-sync-settings-container {
padding: 0;
min-width: 0;
}
.git-sync-settings .setting-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 18px 0;
border-top: 1px solid var(--background-modifier-border);
}
.git-sync-settings .setting-item:first-child {
border-top: none;
}
.git-sync-settings .setting-item-info {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
margin-right: 16px;
}
.git-sync-settings .setting-item-name {
font-weight: var(--font-weight-medium);
color: var(--text-normal);
margin-bottom: 2px;
}
.git-sync-settings .setting-item-description {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.3;
}
.git-sync-settings .setting-item-control {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* Sponsor section special layout */
.git-sync-sponsor-control {
flex-direction: column !important;
align-items: flex-end !important;
}
.git-sync-settings .setting-item-input {
width: 300px;
min-width: 300px;
max-width: 400px;
padding: 6px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 3px;
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--font-ui-small);
}
/* Input field general styles */
.git-sync-settings input[type='text'],
.git-sync-settings input[type='password'] {
width: 300px;
min-width: 300px;
max-width: 400px;
}
/* China mainland donation channel styles */
.git-sync-china-sponsor {
margin-top: 8px;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.git-sync-settings .setting-item-input:focus {
border-color: var(--interactive-accent);
outline: none;
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.git-sync-settings .setting-item-separator {
border: none;
border-top: 1px solid var(--background-modifier-border);
margin: 12px 0;
}
.git-sync-settings button {
padding: 6px 12px;
border: 1px solid var(--interactive-normal);
border-radius: 3px;
background: var(--interactive-normal);
color: var(--text-on-accent);
cursor: pointer;
font-size: var(--font-ui-small);
font-weight: var(--font-weight-medium);
transition: all 0.1s ease;
}
.git-sync-settings button:hover {
background: var(--interactive-hover);
border-color: var(--interactive-hover);
}
.git-sync-settings button:active {
background: var(--interactive-active);
border-color: var(--interactive-active);
}
.git-sync-settings button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.git-sync-settings button.mod-cta {
background: var(--interactive-accent);
border-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.git-sync-settings button.mod-cta:hover {
background: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}
.git-sync-settings button.mod-warning {
background: var(--color-orange);
border-color: var(--color-orange);
color: var(--text-on-accent);
}
.git-sync-settings button.mod-warning:hover {
background: var(--color-red);
border-color: var(--color-red);
}
.git-sync-settings button.mod-secondary {
background: var(--background-secondary);
border-color: var(--background-modifier-border);
color: var(--text-normal);
}
.git-sync-settings button.mod-secondary:hover {
background: var(--background-secondary-alt);
}
/* Sync button styles */
.git-sync-menu-button {
display: inline-flex;
align-items: center;
padding: 4px 8px;
margin: 0 2px;
border-radius: 3px;
background: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
font-size: var(--font-ui-smaller);
transition: all 0.1s ease;
}
.git-sync-menu-button:hover {
background: var(--interactive-hover);
}
.git-sync-menu-button svg {
width: 14px;
height: 14px;
margin-right: 4px;
}
/* Responsive design */
@media (max-width: 768px) {
.git-sync-modal {
min-width: 300px;
max-width: 90vw;
width: auto;
}
.git-sync-settings .setting-item {
flex-direction: column;
align-items: stretch;
}
.git-sync-settings .setting-item-info {
margin-right: 0;
margin-bottom: 12px;
}
.git-sync-settings .setting-item-input {
width: 100%;
}
}
/* Ensure modal content doesn't overflow */
.git-sync-modal .modal-container {
max-height: 90vh;
overflow-y: auto;
}
.git-sync-settings {
max-width: 100%;
overflow-x: hidden;
}
.git-sync-settings .setting-item-input {
min-width: 200px;
max-width: 100%;
box-sizing: border-box;
}
/* Path description styles */
.git-sync-path-info {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 8px 12px;
margin: 8px 0 16px 0;
font-size: var(--font-ui-smaller);
color: var(--text-accent);
font-weight: var(--font-weight-medium);
}
.git-sync-path-info:empty {
display: none;
}
/* Status bar styles */
.git-sync-status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
font-size: var(--font-ui-smaller);
}
.git-sync-status-text {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.git-sync-status-button {
padding: 2px 6px;
border: 1px solid var(--background-modifier-border);
border-radius: 3px;
background: var(--background-secondary);
color: var(--text-normal);
cursor: pointer;
font-size: var(--font-ui-smaller);
transition: all 0.1s ease;
}
.git-sync-status-button:hover {
background: var(--background-secondary-alt);
border-color: var(--interactive-accent);
}
.git-sync-status-button:active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.git-sync-status-button:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--background-secondary);
border-color: var(--background-modifier-border);
}
.git-sync-status-button:disabled:hover {
background: var(--background-secondary);
border-color: var(--background-modifier-border);
}
/* Save button container styles */
.git-sync-save-button-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--background-modifier-border);
}
.git-sync-save-button-container button {
padding: 8px 16px;
border: 1px solid var(--interactive-accent);
border-radius: 4px;
background: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
font-size: var(--font-ui-small);
font-weight: var(--font-weight-medium);
transition: all 0.1s ease;
}
.git-sync-save-button-container button:hover {
background: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}
.git-sync-save-button-container button:active {
background: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.danger-zone {
color: var(--color-red);
}
.sponsor {
color: var(--color-orange);
}

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": false,
"moduleResolution": "node",
"importHelpers": true,
"declaration": true,
"outDir": "lib",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"typeRoots": ["node_modules/@types"],
"lib": ["DOM", "ES6"],
"jsx": "react-jsx"
},
"include": ["**/*.ts", "**/*.tsx"]
}

58
types.ts Normal file
View file

@ -0,0 +1,58 @@
export interface GitSyncSettings {
githubToken: string;
repositoryUrl: string; // Format: @https://github.com/user/repo/path/to/folder
lastSyncTime: number;
showRibbonIcon: boolean; // Whether to show sidebar button
language: 'zh' | 'en' | 'auto'; // Language setting, auto means follow system
}
export const DEFAULT_SETTINGS: GitSyncSettings = {
githubToken: '',
repositoryUrl: '',
lastSyncTime: 0,
showRibbonIcon: true,
language: 'auto' // Follow Obsidian language setting
};
export interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
url: string;
html_url: string;
git_url: string;
download_url: string;
type: 'file' | 'dir';
content?: string;
encoding?: string;
}
export interface SyncResult {
success: boolean;
message: string;
filesProcessed?: number;
}
// File cache status
export interface FileCache {
filePath: string; // File path in Obsidian
githubPath: string; // File path in GitHub
lastModified: string; // Last modified time
sha: string; // SHA value of GitHub file
isPublished: boolean; // Whether published to GitHub
isSynced: boolean; // Whether synced with GitHub
cacheTime: number; // Cache timestamp
fileSize: number; // File size
contentHash: string; // File content hash for detecting local modifications
}
// Cache manager interface
export interface FileCacheManager {
getFileCache(filePath: string): FileCache | null;
setFileCache(filePath: string, cache: FileCache): void;
removeFileCache(filePath: string): void;
clearCache(): void;
isCacheValid(cache: FileCache, maxAge?: number): boolean;
getAllCachedFiles(): FileCache[];
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read manifest.json
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}