mirror of
https://github.com/xheldon/git-folder-sync.git
synced 2026-07-22 06:59:00 +00:00
init
This commit is contained in:
commit
33b4284577
16 changed files with 5387 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
164
README.md
Normal 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!
|
||||
|
||||
## 赞助
|
||||
|
||||
如果这个插件对你有帮助,欢迎请我喝杯咖啡 ☕
|
||||
|
||||
[](https://paypal.me/xheldoncao)
|
||||
|
||||
你的支持是我继续开发和维护这个项目的动力!
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
7
data.json
Normal file
7
data.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"githubToken": "",
|
||||
"repositoryUrl": "",
|
||||
"lastSyncTime": 0,
|
||||
"showRibbonIcon": true,
|
||||
"language": "auto"
|
||||
}
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal 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
196
file-cache.ts
Normal 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
418
github-service.ts
Normal 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
366
i18n-simple.ts
Normal 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
953
main.ts
Normal 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
11
manifest.json
Normal 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
2736
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal 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
325
styles.css
Normal 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
21
tsconfig.json
Normal 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
58
types.ts
Normal 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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue