diff --git a/src/github/client.ts b/src/github/client.ts index 847b88c..4052fbe 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -1,6 +1,30 @@ import { Vault, requestUrl, normalizePath } from "obsidian"; import MetadataStore from "../metadata-store"; +/** + * Represents a single item in a tree response from the GitHub API. + */ +type TreeItem = { + path: string; + mode: string; + type: string; + sha: string; + size: number; + url: string; +}; + +/** + * Represents a git blob response from the GitHub API. + */ +type BlobFile = { + sha: string; + node_id: string; + size: number; + url: string; + content: string; + encoding: string; +}; + export default class GithubClient { constructor( private vault: Vault, @@ -16,6 +40,86 @@ export default class GithubClient { }; } + /** + * Gets the content of a directory in the repo. + * If repoContentDir is an empty string all files in the repo will be returned. + * + * @param owner Owner of the repo + * @param repo Name of the repo + * @param repoContentDir Directory in the repo to download relative to the root of the repo + * @param branch Branch to download from + * @returns Array of files in the directory in the remote repo + */ + async getRepoContent( + owner: string, + repo: string, + repoContentDir: string, + branch: string, + ): Promise { + const res = await requestUrl({ + url: `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, + headers: this.headers(), + }); + const files = res.json["tree"].filter( + (file: TreeItem) => + file.type === "blob" && file.path.startsWith(repoContentDir), + ); + return files; + } + + /** + * Gets a blob from a blob url + * @param url blob url + */ + async getBlob(url: string): Promise { + const res = await requestUrl({ + url: url, + headers: this.headers(), + }); + return res.json; + } + + /** + * Downloads a single file from GitHub. If the file already exists locally it will be overwritten. + * + * @param file file info from the GitHub API + * @param repoContentDir directory in the repo to download relative to the root of the repo + * @param localContentDir local directory to download to + */ + async downloadFile( + file: TreeItem, + repoContentDir: string, + localContentDir: string, + ) { + const url = file.url; + const destinationFile = file.path.replace(repoContentDir, localContentDir); + const fileMetadata = this.metadataStore.data[destinationFile]; + if (fileMetadata && fileMetadata.sha === file.sha) { + // File already exists and has the same SHA, no need to download it again. + return; + } + + const blob = await this.getBlob(url); + const destinationFolder = normalizePath( + destinationFile.split("/").slice(0, -1).join("/"), + ); + if (!(await this.vault.adapter.exists(destinationFolder))) { + await this.vault.adapter.mkdir(destinationFolder); + } + this.vault.adapter.writeBinary( + normalizePath(destinationFile), + Buffer.from(blob.content, "base64"), + ); + this.metadataStore.data[destinationFile] = { + localPath: destinationFile, + remotePath: file.path, + sha: file.sha, + dirty: false, + justDownloaded: true, + }; + await this.metadataStore.save(); + } + /** * Recursively downloads the repo content to the local vault. * The repository directory structure is kept as is. @@ -33,86 +137,18 @@ export default class GithubClient { branch: string, localContentDir: string, ) { - const res = await requestUrl({ - url: `https://api.github.com/repos/${owner}/${repo}/contents/${repoContentDir}?ref=${branch}`, - headers: this.headers(), - }); - - const directories = res.json.filter((file: any) => file.type === "dir"); - // As specified in the Github docs also submodules are specified as "file". - // For the time being we're going to let this slide cause I don't really - // want to handle this right now. I'll figure out a way to do this later. Maybe. - // More info in the official docs: - // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content - const files = res.json.filter((file: any) => file.type === "file"); + const files = await this.getRepoContent( + owner, + repo, + repoContentDir, + branch, + ); await Promise.all( - directories.map((dir: any) => - this.downloadRepoContent( - owner, - repo, - dir.path, - branch, - localContentDir ? `${localContentDir}/${dir.name}` : dir.name, - ), + files.map(async (file: TreeItem) => + this.downloadFile(file, repoContentDir, localContentDir), ), ); - - await Promise.all( - files.map(async (file: any) => { - const url = file.download_url; - const destinationFile = file.path.replace( - repoContentDir, - localContentDir, - ); - const fileMetadata = this.metadataStore.data[destinationFile]; - if (fileMetadata && fileMetadata.sha === file.sha) { - // File already exists and has the same SHA, no need to download it again. - return; - } - - await this.downloadRawFile(url, destinationFile); - this.metadataStore.data[destinationFile] = { - localPath: destinationFile, - remotePath: file.path, - sha: file.sha, - dirty: false, - justDownloaded: true, - }; - await this.metadataStore.save(); - }), - ); - } - - /** - * Downloads a single file from GitHub. This doesn't use the API but the raw - * file content endpoint that we receive from the API. - * This makes some things slightly easier to handle. - * - * @param url URL to raw file content - * @param destinationFile Local path where to save the file, relative to the vault - */ - async downloadRawFile(url: string, destinationFile: string) { - // We're not setting auth headers here as we're not calling the Github API - // directly but downloading the raw file, cause there are some size limitation - // with the official API. - // Using the headers above is not feasible as it triggers CORS preflight - // and GH doesn't allow that for raw content endpoints. Thus breaking everything. - // So this doesn't work with a private repo, will figure it out later. - const res = await requestUrl({ - url: url, - }); - const destinationFolder = normalizePath( - destinationFile.split("/").slice(0, -1).join("/"), - ); - if (!(await this.vault.adapter.exists(destinationFolder))) { - await this.vault.adapter.mkdir(destinationFolder); - } - - this.vault.adapter.writeBinary( - normalizePath(destinationFile), - res.arrayBuffer, - ); } /**