import { requestUrl, type Vault, type RequestUrlResponse } from "obsidian"; import type { GitHubFile, CompareResult, LocalFileInfo, TreeItem } from "./types"; const API_BASE = "https://api.github.com"; const RAW_BASE = "https://raw.githubusercontent.com"; export class GitHubApiClient { private token: string; private owner: string; private repo: string; private branch: string; constructor(token: string, owner: string, repo: string, branch: string) { this.token = token; this.owner = owner; this.repo = repo; this.branch = branch; } private get headers(): Record { return { Authorization: `Bearer ${this.token}`, Accept: "application/vnd.github.v3+json", }; } /** * 获取最新 commit 的 SHA */ async getLatestCommitSha(): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/commits/${this.branch}`, headers: this.headers, }); return resp.json.sha; } /** * 获取完整文件树 */ async getFileTree(): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`, headers: this.headers, }); const data = resp.json; if (data.truncated) { console.warn("[moving-note] File tree truncated, some files may be missing"); } return data.tree.filter((item: GitHubFile) => item.type === "blob"); } /** * 比较两个 commit 之间的变更 */ async compareCommits(oldSha: string): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/compare/${oldSha}...${this.branch}`, headers: this.headers, }); const data = resp.json; return { files: data.files.map( (f: { filename: string; status: string; previous_filename?: string; }) => ({ filename: f.filename, status: f.status, previous_filename: f.previous_filename, }) ), newSha: data.commits[data.commits.length - 1]?.sha ?? oldSha, }; } /** * 下载单个文件内容(文本) */ async downloadFileText(path: string): Promise { const resp = await requestUrl({ url: `${RAW_BASE}/${this.owner}/${this.repo}/${this.branch}/${encodeURI(path)}`, headers: { Authorization: `Bearer ${this.token}`, }, }); return resp.text; } /** * 下载单个文件内容(二进制) */ async downloadFileBinary(path: string): Promise { const resp = await requestUrl({ url: `${RAW_BASE}/${this.owner}/${this.repo}/${this.branch}/${encodeURI(path)}`, headers: { Authorization: `Bearer ${this.token}`, }, }); return resp.arrayBuffer; } /** * 判断文件是否是文本文件(基于扩展名) */ private isTextFile(path: string): boolean { const textExtensions = [ ".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", ".css", ".js", ".ts", ".html", ".htm", ".svg", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bat", ".py", ".rb", ".java", ".c", ".cpp", ".h", ".go", ".rs", ".php", ]; const ext = path.substring(path.lastIndexOf(".")).toLowerCase(); return textExtensions.includes(ext); } /** * 首次全量同步 — 下载仓库所有文件到 vault */ async fullSync(vault: Vault, onProgress?: (current: number, total: number) => void): Promise { const files = await this.getFileTree(); let count = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; try { // 跳过 .git 相关文件 if (file.path.startsWith(".git/")) continue; // 确保目录存在 const dir = file.path.substring(0, file.path.lastIndexOf("/")); if (dir) { await this.ensureDir(vault, dir); } // 下载并写入文件 if (this.isTextFile(file.path)) { const content = await this.downloadFileText(file.path); await this.writeFile(vault, file.path, content); } else { const buffer = await this.downloadFileBinary(file.path); await this.writeFileBinary(vault, file.path, buffer); } count++; onProgress?.(i + 1, files.length); } catch (e) { console.error(`[moving-note] Failed to sync file: ${file.path}`, e); } } return count; } /** * 增量同步 — 只同步变更的文件 */ async incrementalSync( vault: Vault, lastSha: string, onProgress?: (current: number, total: number) => void ): Promise<{ count: number; newSha: string }> { const compare = await this.compareCommits(lastSha); // 如果没有变更 if (compare.files.length === 0) { return { count: 0, newSha: lastSha }; } let count = 0; const total = compare.files.length; for (let i = 0; i < total; i++) { const file = compare.files[i]; try { if (file.status === "removed") { // 删除本地文件 const existing = vault.getAbstractFileByPath(file.filename); if (existing) { await vault.delete(existing); } count++; } else if ( file.status === "added" || file.status === "modified" || file.status === "renamed" || file.status === "copied" ) { // 如果是重命名,删除旧文件 if (file.status === "renamed" && file.previous_filename) { const old = vault.getAbstractFileByPath(file.previous_filename); if (old) { await vault.delete(old); } } // 确保目录存在 const dir = file.filename.substring(0, file.filename.lastIndexOf("/")); if (dir) { await this.ensureDir(vault, dir); } // 下载并写入 if (this.isTextFile(file.filename)) { const content = await this.downloadFileText(file.filename); await this.writeFile(vault, file.filename, content); } else { const buffer = await this.downloadFileBinary(file.filename); await this.writeFileBinary(vault, file.filename, buffer); } count++; } onProgress?.(i + 1, total); } catch (e) { console.error(`[moving-note] Failed to sync file: ${file.filename}`, e); } } return { count, newSha: compare.newSha }; } /** * 测试 token 是否有效 */ async validateToken(): Promise<{ valid: boolean; username?: string }> { try { const resp = await requestUrl({ url: `${API_BASE}/user`, headers: this.headers, }); return { valid: true, username: resp.json.login }; } catch { return { valid: false }; } } // ─── 推送方法(Git Data API)────────────────────────────── /** * 获取当前分支最新 commit 的 tree SHA */ async getBaseTreeSha(): Promise { const commitSha = await this.getLatestCommitSha(); const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/commits/${commitSha}`, headers: this.headers, }); return resp.json.tree.sha; } /** * 创建 blob(用于二进制文件) */ async createBlob(content: string, encoding: "utf-8" | "base64"): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/blobs`, method: "POST", headers: this.headers, body: JSON.stringify({ content, encoding }), }); return resp.json.sha; } /** * 创建新的 tree */ async createTree(baseTreeSha: string, treeItems: TreeItem[]): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/trees`, method: "POST", headers: this.headers, body: JSON.stringify({ base_tree: baseTreeSha, tree: treeItems }), }); return resp.json.sha; } /** * 创建 commit */ async createCommit(message: string, treeSha: string, parentSha: string): Promise { const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/commits`, method: "POST", headers: this.headers, body: JSON.stringify({ message, tree: treeSha, parents: [parentSha] }), }); return resp.json.sha; } /** * 更新分支引用到新的 commit */ async updateRef(commitSha: string): Promise { await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`, method: "PATCH", headers: this.headers, body: JSON.stringify({ sha: commitSha, force: true }), }); } /** * 批量推送文件到远程仓库 * 流程:创建 blob(二进制)→ 创建 tree → 创建 commit → 更新 ref */ async pushFiles(files: LocalFileInfo[], commitMessage: string): Promise { // 1. 获取 base tree SHA const baseTreeSha = await this.getBaseTreeSha(); const parentSha = await this.getLatestCommitSha(); // 2. 构建 tree items const treeItems: TreeItem[] = []; for (const file of files) { if (file.encoding === "base64") { // 二进制文件:先创建 blob const blobSha = await this.createBlob(file.content, "base64"); treeItems.push({ path: file.path, mode: "100644", type: "blob", sha: blobSha, }); } else { // 文本文件:直接传 content treeItems.push({ path: file.path, mode: "100644", type: "blob", content: file.content, }); } } // 3. 创建新 tree const newTreeSha = await this.createTree(baseTreeSha, treeItems); // 4. 创建 commit const newCommitSha = await this.createCommit(commitMessage, newTreeSha, parentSha); // 5. 更新分支引用 await this.updateRef(newCommitSha); return newCommitSha; } /** * 通过 Contents API 删除文件 */ async deleteFile(path: string, message: string): Promise { // 先获取文件的 SHA(GitHub 要求删除时提供 SHA) const resp = await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeURI(path)}?ref=${this.branch}`, headers: this.headers, }); const fileSha = resp.json.sha; await requestUrl({ url: `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeURI(path)}`, method: "DELETE", headers: this.headers, body: JSON.stringify({ message, sha: fileSha, branch: this.branch }), }); } // ─── 文件操作辅助方法 ───────────────────────────────────── private async ensureDir(vault: Vault, dirPath: string): Promise { const parts = dirPath.split("/"); let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; if (!(await vault.adapter.exists(current))) { await vault.createFolder(current); } } } private async writeFile(vault: Vault, path: string, content: string): Promise { const existing = vault.getAbstractFileByPath(path); if (existing && "extension" in existing) { await vault.modify(existing as import("obsidian").TFile, content); } else { await vault.create(path, content); } } private async writeFileBinary(vault: Vault, path: string, buffer: ArrayBuffer): Promise { const existing = vault.getAbstractFileByPath(path); if (existing && "extension" in existing) { await vault.modifyBinary(existing as import("obsidian").TFile, buffer); } else { await vault.createBinary(path, buffer); } } }