diff --git a/src/events/consumer.ts b/src/events/consumer.ts index 533914c..7fb3cf0 100644 --- a/src/events/consumer.ts +++ b/src/events/consumer.ts @@ -1,45 +1,14 @@ -import GithubClient from "src/github/client"; import { type Event } from "./types"; -import MetadataStore from "src/metadata-store"; +import SyncManager from "src/sync-manager"; export default class EventsConsumer { - constructor( - private readonly client: GithubClient, - private readonly metadataStore: MetadataStore, - private readonly owner: string, - private readonly repo: string, - private readonly branch: string, - ) {} + constructor(private readonly syncManager: SyncManager) {} async process(event: Event): Promise { if (event.type == "create" || event.type == "modify") { - await this.client.uploadFile( - this.owner, - this.repo, - this.branch, - event.filePath, - ); - // Reset dirty state - this.metadataStore.data[event.filePath].dirty = false; - // Gets the new SHA of the file - const sha = await this.client.getFileSha( - this.owner, - this.repo, - this.branch, - event.filePath, - ); - this.metadataStore.data[event.filePath].sha = sha; - this.metadataStore.save(); + await this.syncManager.uploadFile(event.file); } else if (event.type == "delete") { - await this.client.deleteFile( - this.owner, - this.repo, - this.branch, - event.filePath, - ); - // File has been deleted, no need to keep track of it anymore - delete this.metadataStore.data[event.filePath]; - this.metadataStore.save(); + await this.syncManager.deleteFile(event.filePath); } } } diff --git a/src/events/listener.ts b/src/events/listener.ts index e320dcd..24ecb3f 100644 --- a/src/events/listener.ts +++ b/src/events/listener.ts @@ -1,4 +1,4 @@ -import { Vault, TAbstractFile } from "obsidian"; +import { Vault, TAbstractFile, TFolder, TFile } from "obsidian"; import { Event } from "./types"; import MetadataStore from "../metadata-store"; import EventsQueue from "./queue"; @@ -14,7 +14,9 @@ export default class EventsListener { private metadataStore: MetadataStore, private localContentDir: string, private repoContentDir: string, - ) { + ) {} + + start() { this.vault.on("create", this.onCreate.bind(this)); this.vault.on("delete", this.onDelete.bind(this)); this.vault.on("modify", this.onModify.bind(this)); @@ -33,11 +35,15 @@ export default class EventsListener { // The file has not been created in directory that we're syncing with GitHub return; } + if (file instanceof TFolder) { + // Skip folders + return; + } const data = this.metadataStore.data[file.path]; if (data && data.justDownloaded) { // This file was just downloaded and not created by the user. - // It's enough to makr it as non just downloaded. + // It's enough to mark it as non just downloaded. this.metadataStore.data[file.path].justDownloaded = false; await this.metadataStore.save(); return; @@ -54,20 +60,27 @@ export default class EventsListener { await this.metadataStore.save(); this.eventsQueue.enqueue({ type: "create", - filePath: file.path, + file: file as TFile, }); } - private async onDelete(file: TAbstractFile) { - if (!file.path.startsWith(this.localContentDir)) { + private async onDelete(file: TAbstractFile | string) { + if (file instanceof TFolder) { + // Skip folders + return; + } + const filePath = file instanceof TAbstractFile ? file.path : file; + if (!filePath.startsWith(this.localContentDir)) { // The file was not in directory that we're syncing with GitHub return; } - delete this.metadataStore.data[file.path]; - await this.metadataStore.save(); + + // We don't delete metadata as we need that info when calling the API + // to delete the file. + // We'll delete them later. this.eventsQueue.enqueue({ type: "delete", - filePath: file.path, + filePath: filePath, }); } @@ -76,7 +89,10 @@ export default class EventsListener { // The file has not been create in directory that we're syncing with GitHub return; } - + if (file instanceof TFolder) { + // Skip folders + return; + } const data = this.metadataStore.data[file.path]; if (data && data.justDownloaded) { // This file was just downloaded and not modified by the user. @@ -89,11 +105,15 @@ export default class EventsListener { await this.metadataStore.save(); this.eventsQueue.enqueue({ type: "modify", - filePath: file.path, + file: file as TFile, }); } private async onRename(file: TAbstractFile, oldPath: string) { + if (file instanceof TFolder) { + // Skip folders + return; + } if ( !file.path.startsWith(this.localContentDir) && !oldPath.startsWith(this.localContentDir) @@ -110,7 +130,7 @@ export default class EventsListener { // First create the new one await this.onCreate(file); // Then delete the old one - await this.onDelete(file); + await this.onDelete(oldPath); return; } else if (file.path.startsWith(this.localContentDir)) { // Only the new file is in the local directory @@ -118,7 +138,7 @@ export default class EventsListener { return; } else if (oldPath.startsWith(this.localContentDir)) { // Only the old file was in the local directory - await this.onDelete(file); + await this.onDelete(oldPath); return; } } diff --git a/src/events/queue.ts b/src/events/queue.ts index 28ef123..ee4d825 100644 --- a/src/events/queue.ts +++ b/src/events/queue.ts @@ -1,4 +1,4 @@ -import { Event } from "./types"; +import type { Event, CreateModifyEvent, DeleteEvent } from "./types"; /** * A custom queue to better handle events. @@ -12,36 +12,37 @@ export default class EventsQueue { * would cancel themselves out. */ enqueue(event: Event) { - if (!this.eventsQueue.has(event.filePath)) { + const filePath = event.type === "delete" ? event.filePath : event.file.path; + if (!this.eventsQueue.has(filePath)) { // No other event exist for this file, just enqueue it - this.eventsQueue.set(event.filePath, event); + this.eventsQueue.set(filePath, event); return; } if ( - this.eventsQueue.get(event.filePath)?.type === "create" && + this.eventsQueue.get(filePath)?.type === "create" && event.type === "delete" ) { // The previous event was a create and the new one is a delete. // Just delete the previous one as they would amount to the same outcome. - this.eventsQueue.delete(event.filePath); + this.eventsQueue.delete(filePath); } else if ( - this.eventsQueue.get(event.filePath)?.type === "delete" && + this.eventsQueue.get(filePath)?.type === "delete" && event.type === "create" ) { // The old event was a delete and the new one is a create. // Delete the old one and enqueue a modify event as it likely // that the content changed. - this.eventsQueue.delete(event.filePath); - this.eventsQueue.set(event.filePath, { + this.eventsQueue.delete(filePath); + this.eventsQueue.set(filePath, { type: "modify", - filePath: event.filePath, + file: event.file, }); } else { // Delete and enqueue the event in all other cases. // We first delete the event to change the order of the queue - this.eventsQueue.delete(event.filePath); - this.eventsQueue.set(event.filePath, event); + this.eventsQueue.delete(filePath); + this.eventsQueue.set(filePath, event); } } diff --git a/src/events/types.ts b/src/events/types.ts index 9d21510..d3a8f29 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -1,4 +1,13 @@ -export type Event = { - type: "create" | "delete" | "modify"; +import { TFile } from "obsidian"; + +export type CreateModifyEvent = { + type: "create" | "modify"; + file: TFile; +}; + +export type DeleteEvent = { + type: "delete"; filePath: string; }; + +export type Event = CreateModifyEvent | DeleteEvent; diff --git a/src/github/client.ts b/src/github/client.ts index 2aeb7b7..b53db94 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -4,7 +4,7 @@ import MetadataStore from "../metadata-store"; /** * Represents a single item in a tree response from the GitHub API. */ -type TreeItem = { +export type TreeItem = { path: string; mode: string; type: string; @@ -16,7 +16,7 @@ type TreeItem = { /** * Represents a git blob response from the GitHub API. */ -type BlobFile = { +export type BlobFile = { sha: string; node_id: string; size: number; @@ -27,9 +27,11 @@ type BlobFile = { export default class GithubClient { constructor( - private vault: Vault, - private metadataStore: MetadataStore, private token: string, + private owner: string, + private repo: string, + private branch: string, + private repoContentDir: string, ) {} headers() { @@ -42,27 +44,18 @@ 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. + * Or the whole repo if repoContentDir is an empty string. * - * @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 { + async getRepoContent(): Promise { const res = await requestUrl({ - url: `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, + url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`, headers: this.headers(), }); const files = res.json["tree"].filter( (file: TreeItem) => - file.type === "blob" && file.path.startsWith(repoContentDir), + file.type === "blob" && file.path.startsWith(this.repoContentDir), ); return files; } @@ -80,132 +73,39 @@ export default class GithubClient { } /** - * Downloads a single file from GitHub. If the file already exists locally it will be overwritten. + * Create or edit a remote file to GitHub. * - * @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. - * - * @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 - * @param localContentDir Local directory to download to - */ - async downloadRepoContent( - owner: string, - repo: string, - repoContentDir: string, - branch: string, - localContentDir: string, - ) { - const files = await this.getRepoContent( - owner, - repo, - repoContentDir, - branch, - ); - - await Promise.all( - files.map(async (file: TreeItem) => - this.downloadFile(file, repoContentDir, localContentDir), - ), - ); - } - - /** - * Upload a single file to GitHub. - * All the file information needed to upload the file is take form the metadata store. - * - * @param owner Owner of the repo - * @param repo Name of the repo - * @param branch Branch to download from - * @param filePath local file path to upload + * @param remoteFilePath Path to remote file + * @param fileContent Content of the file + * @param sha SHA of the file */ async uploadFile( - owner: string, - repo: string, - branch: string, - filePath: string, + remoteFilePath: string, + fileContent: ArrayBuffer, + sha: string | null, ) { - const { remotePath } = this.metadataStore.data[filePath]; - - const normalizedFilePath = normalizePath(filePath); - if (!(await this.vault.adapter.exists(normalizedFilePath))) { - throw new Error(`Can't find file ${filePath}`); - } - - const buffer = await this.vault.adapter.readBinary(normalizedFilePath); - const content = Buffer.from(buffer).toString("base64"); - const res = await requestUrl({ - url: `https://api.github.com/repos/${owner}/${repo}/contents/${remotePath}`, + await requestUrl({ + url: `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${remoteFilePath}`, method: "PUT", headers: this.headers(), body: JSON.stringify({ - message: `Edit ${remotePath}`, - branch: branch, - content: content, - sha: this.metadataStore.data[filePath].sha, + message: `Edit ${remoteFilePath}`, + branch: this.branch, + content: Buffer.from(fileContent).toString("base64"), + sha: sha, }), }); } /** - * Gets the SHA of a file in the remote repo given its local path. + * Gets the SHA of a file in the remote repo. * - * @param owner Owner of the repo - * @param repo Name of the repo - * @param branch Branch to download from - * @param filePath local file path to upload + * @param remoteFilePath Path to remote file * @returns sha of the file as string */ - async getFileSha( - owner: string, - repo: string, - branch: string, - filePath: string, - ) { - const { remotePath } = this.metadataStore.data[filePath]; + async getFileSha(remoteFilePath: string) { const res = await requestUrl({ - url: `https://api.github.com/repos/${owner}/${repo}/contents/${remotePath}?ref=${branch}`, + url: `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${remoteFilePath}?ref=${this.branch}`, headers: this.headers(), }); return res.json.sha; @@ -213,28 +113,19 @@ export default class GithubClient { /** * Delete a single file from GitHub. - * All the file information needed to delete the file is take form the metadata store. * - * @param owner Owner of the repo - * @param repo Name of the repo - * @param branch Branch to download from - * @param filePath local file path that has been deleted + * @param remoteFilePath Path to remote file + * @param sha SHA of the file */ - async deleteFile( - owner: string, - repo: string, - branch: string, - filePath: string, - ) { - const { remotePath } = this.metadataStore.data[filePath]; - const res = await requestUrl({ - url: `https://api.github.com/repos/${owner}/${repo}/contents/${remotePath}`, + async deleteFile(remoteFilePath: string, sha: string) { + await requestUrl({ + url: `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${remoteFilePath}`, method: "DELETE", headers: this.headers(), body: JSON.stringify({ - message: `Delete ${remotePath}`, - branch: branch, - sha: this.metadataStore.data[filePath].sha, + message: `Delete ${remoteFilePath}`, + branch: this.branch, + sha: sha, }), }); } diff --git a/src/main.ts b/src/main.ts index a4c0299..52c0091 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,20 +1,12 @@ import { EventRef, Plugin, FileView } from "obsidian"; import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings"; -import GithubClient from "./github/client"; import GitHubSyncSettingsTab from "./settings/tab"; -import MetadataStore from "./metadata-store"; -import EventsListener from "./events/listener"; -import EventsConsumer from "./events/consumer"; -import { type Event } from "./events/types"; import { UploadDialog } from "./views/upload-all-files-dialog/view"; +import SyncManager from "./sync-manager"; export default class GitHubSyncPlugin extends Plugin { settings: GitHubSyncSettings; - metadataStore: MetadataStore; - client: GithubClient; - eventsListener: EventsListener; - eventsConsumer: EventsConsumer; - syncIntervalId: number | null = null; + syncManager: SyncManager; statusBarItem: HTMLElement | null = null; downloadAllRibbonIcon: HTMLElement | null = null; @@ -33,22 +25,30 @@ export default class GitHubSyncPlugin extends Plugin { async onload() { await this.loadSettings(); - await this.loadMetadata(); this.addSettingTab(new GitHubSyncSettingsTab(this.app, this)); - this.client = new GithubClient( - this.app.vault, - this.metadataStore, - this.settings.githubToken, - ); + this.syncManager = new SyncManager(this.app.vault, this.settings); + await this.syncManager.loadMetadata(); + + if (this.settings.uploadStrategy == "interval") { + this.restartSyncInterval(); + } + + // const res = await this.client.getRepoContent( + // this.settings.githubOwner, + // this.settings.githubRepo, + // this.settings.repoContentDir, + // this.settings.githubBranch, + // ); + // console.log(res); this.app.workspace.onLayoutReady(async () => { // Create the events handling only after tha layout is ready to avoid // getting spammed with create events. // See the official Obsidian docs: // https://docs.obsidian.md/Reference/TypeScript+API/Vault/on('create') - await this.startEventsHandlers(); + await this.syncManager.startEventsListener(); // Load the ribbons after layout is ready so they're shown after the core // buttons @@ -78,7 +78,10 @@ export default class GitHubSyncPlugin extends Plugin { name: "Download all files to GitHub", repeatable: false, icon: "arrow-down-from-line", - callback: async () => await this.downloadAllFiles(), + callback: async () => { + await this.syncManager.downloadAllFiles(); + this.updateStatusBarItem(); + }, }); this.addCommand({ @@ -86,7 +89,10 @@ export default class GitHubSyncPlugin extends Plugin { name: "Upload modified files to GitHub", repeatable: false, icon: "refresh-cw", - callback: async () => await this.uploadModifiedFiles(), + callback: async () => { + await this.syncManager.uploadModifiedFiles(); + this.updateStatusBarItem(); + }, }); this.addCommand({ @@ -94,7 +100,18 @@ export default class GitHubSyncPlugin extends Plugin { name: "Upload active file to GitHub", repeatable: false, icon: "arrow-up", - callback: async () => await this.uploadActiveFile(), + callback: async () => { + const activeView = this.app.workspace.getActiveViewOfType(FileView); + if (!activeView) { + return; + } + const activeFile = activeView.file; + if (!activeFile) { + return; + } + await this.syncManager.uploadFile(activeFile); + this.updateStatusBarItem(); + }, }); this.addCommand({ @@ -107,70 +124,11 @@ export default class GitHubSyncPlugin extends Plugin { } async onunload() { + // TODO: Stop all the things here + this.stopSyncInterval(); console.log("GitHubSyncPlugin unloaded"); } - /** - * Starts a new sync interval. - * Raises an error if the interval is already running. - */ - startSyncInterval() { - if (this.syncIntervalId) { - throw new Error("Sync interval is already running"); - } - this.syncIntervalId = window.setInterval( - () => this.uploadModifiedFiles(), - // Sync interval is set in minutes but setInterval expects milliseconds - this.settings.uploadInterval * 60 * 1000, - ); - this.registerInterval(this.syncIntervalId); - } - - /** - * Stops the currently running sync interval - */ - stopSyncInterval() { - if (this.syncIntervalId) { - window.clearInterval(this.syncIntervalId); - this.syncIntervalId = null; - } - } - - private async downloadAllFiles() { - await this.client.downloadRepoContent( - this.settings.githubOwner, - this.settings.githubRepo, - this.settings.repoContentDir, - this.settings.githubBranch, - this.settings.localContentDir, - ); - this.updateStatusBarItem(); - } - - private async uploadModifiedFiles() { - await Promise.all( - this.eventsListener.flush().map(async (event: Event) => { - await this.eventsConsumer.process(event); - }), - ); - this.updateStatusBarItem(); - } - - private async uploadActiveFile() { - const activeView = this.app.workspace.getActiveViewOfType(FileView); - if (!activeView) { - return; - } - const activeFile = activeView.file; - if (!activeFile) { - return; - } - // TODO: Remove the file from eventsListener if it's there - // TODO: Upload file - // TODO: Update SHA - this.updateStatusBarItem(); - } - /** * Opens dialog to upload all file in the tracked folder. * This doesn't take into account the state of the files and uploads @@ -181,41 +139,6 @@ export default class GitHubSyncPlugin extends Plugin { this.updateStatusBarItem(); } - /** - * Util function that stops and restart the sync interval - */ - restartSyncInterval() { - this.stopSyncInterval(); - this.startSyncInterval(); - } - - /** - * Starts events listener and consumer. - * If the sync strategy is set to "interval", starts the sync interval. - */ - private async startEventsHandlers() { - if (this.eventsListener && this.eventsConsumer) { - // They've been already started - return; - } - this.eventsListener = new EventsListener( - this.app.vault, - this.metadataStore, - this.settings.localContentDir, - this.settings.repoContentDir, - ); - this.eventsConsumer = new EventsConsumer( - this.client, - this.metadataStore, - this.settings.githubOwner, - this.settings.githubRepo, - this.settings.githubBranch, - ); - if (this.settings.uploadStrategy == "interval") { - this.restartSyncInterval(); - } - } - showStatusBarItem() { if (this.statusBarItem) { return; @@ -255,7 +178,7 @@ export default class GitHubSyncPlugin extends Plugin { } let state = "Unknown"; - const fileData = this.metadataStore.data[activeFile.path]; + const fileData = this.syncManager.getFileMetadata(activeFile.path); if (!fileData) { state = "Untracked"; } else if (fileData.dirty) { @@ -274,7 +197,10 @@ export default class GitHubSyncPlugin extends Plugin { this.downloadAllRibbonIcon = this.addRibbonIcon( "arrow-down-from-line", "Download all files from GitHub", - async () => await this.downloadAllFiles(), + async () => { + await this.syncManager.downloadAllFiles(); + this.updateStatusBarItem(); + }, ); } @@ -290,7 +216,10 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadModifiedFilesRibbonIcon = this.addRibbonIcon( "refresh-cw", "Upload modified files to GitHub", - async () => await this.uploadModifiedFiles(), + async () => { + await this.syncManager.uploadModifiedFiles(); + this.updateStatusBarItem(); + }, ); } @@ -307,7 +236,18 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadCurrentFileRibbonIcon = this.addRibbonIcon( "arrow-up", "Upload current file to GitHub", - async () => await this.uploadActiveFile(), + async () => { + const activeView = this.app.workspace.getActiveViewOfType(FileView); + if (!activeView) { + return; + } + const activeFile = activeView.file; + if (!activeFile) { + return; + } + await this.syncManager.uploadFile(activeFile); + this.updateStatusBarItem(); + }, ); } @@ -332,11 +272,6 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadAllFilesRibbonIcon = null; } - private async loadMetadata() { - this.metadataStore = new MetadataStore(this.app.vault); - await this.metadataStore.load(); - } - async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } @@ -344,4 +279,22 @@ export default class GitHubSyncPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); } + + // Proxy methods from sync manager to ease handling the interval + // when settings are changed + startSyncInterval() { + const intervalID = this.syncManager.startSyncInterval( + this.settings.uploadInterval, + ); + this.registerInterval(intervalID); + } + + stopSyncInterval() { + this.syncManager.stopSyncInterval(); + } + + restartSyncInterval() { + this.syncManager.stopSyncInterval(); + this.syncManager.startSyncInterval(this.settings.uploadInterval); + } } diff --git a/src/sync-manager.ts b/src/sync-manager.ts new file mode 100644 index 0000000..3fabdfe --- /dev/null +++ b/src/sync-manager.ts @@ -0,0 +1,166 @@ +import { Vault, TFile, normalizePath } from "obsidian"; +import GithubClient, { TreeItem } from "./github/client"; +import MetadataStore, { FileMetadata } from "./metadata-store"; +import EventsListener from "./events/listener"; +import EventsConsumer from "./events/consumer"; +import { type Event } from "./events/types"; +import { GitHubSyncSettings } from "./settings/settings"; + +export default class SyncManager { + private metadataStore: MetadataStore; + private client: GithubClient; + private eventsListener: EventsListener; + private eventsConsumer: EventsConsumer; + private syncIntervalId: number | null = null; + + constructor( + private vault: Vault, + private settings: GitHubSyncSettings, + ) { + this.metadataStore = new MetadataStore(this.vault); + this.client = new GithubClient( + this.settings.githubToken, + this.settings.githubOwner, + this.settings.githubRepo, + this.settings.githubBranch, + this.settings.repoContentDir, + ); + this.eventsListener = new EventsListener( + this.vault, + this.metadataStore, + this.settings.localContentDir, + this.settings.repoContentDir, + ); + this.eventsConsumer = new EventsConsumer(this); + } + + async sync() { + // TODO + } + + async uploadFile(file: TFile) { + // TODO: Remove the file from eventsListener if it's there + const { remotePath, sha } = this.metadataStore.data[file.path]; + const normalizedFilePath = normalizePath(file.path); + if (!(await this.vault.adapter.exists(normalizedFilePath))) { + throw new Error(`Can't find file ${file.path}`); + } + const fileContent = await this.vault.adapter.readBinary(normalizedFilePath); + await this.client.uploadFile(remotePath, fileContent, sha); + // Reset dirty state + this.metadataStore.data[file.path].dirty = false; + // Gets the new SHA of the file + const newSha = await this.client.getFileSha(remotePath); + this.metadataStore.data[file.path].sha = newSha; + this.metadataStore.save(); + } + + async downloadFile(file: TreeItem) { + const url = file.url; + const destinationFile = file.path.replace( + this.settings.repoContentDir, + this.settings.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.client.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(); + } + + async deleteFile(filePath: string) { + const { remotePath, sha } = this.metadataStore.data[filePath]; + if (!sha) { + // File was never uploaded, no need to delete it + return; + } + await this.client.deleteFile(remotePath, sha); + // File has been deleted, no need to keep track of it anymore + delete this.metadataStore.data[filePath]; + this.metadataStore.save(); + } + + async downloadAllFiles() { + const files = await this.client.getRepoContent(); + + await Promise.all( + files.map(async (file: TreeItem) => await this.downloadFile(file)), + ); + } + + async uploadModifiedFiles() { + // We upload files sequentially to avoid conflicts. + // GitHub rejects commits if they're made in fast succession, thus + // forcing us to retry the failed upload. + // So parallelization is not an option. + for (const event of this.eventsListener.flush()) { + await this.eventsConsumer.process(event); + } + } + + async loadMetadata() { + await this.metadataStore.load(); + } + + getFileMetadata(filePath: string): FileMetadata { + return this.metadataStore.data[filePath]; + } + + async startEventsListener() { + this.eventsListener.start(); + } + + /** + * Starts a new sync interval. + * Raises an error if the interval is already running. + */ + startSyncInterval(minutes: number): number { + if (this.syncIntervalId) { + throw new Error("Sync interval is already running"); + } + this.syncIntervalId = window.setInterval( + () => this.uploadModifiedFiles(), + // Sync interval is set in minutes but setInterval expects milliseconds + minutes * 60 * 1000, + ); + return this.syncIntervalId; + } + + /** + * Stops the currently running sync interval + */ + stopSyncInterval() { + if (this.syncIntervalId) { + window.clearInterval(this.syncIntervalId); + this.syncIntervalId = null; + } + } + + /** + * Util function that stops and restart the sync interval + */ + restartSyncInterval(minutes: number) { + this.stopSyncInterval(); + return this.startSyncInterval(minutes); + } +} diff --git a/src/views/upload-all-files-dialog/component.tsx b/src/views/upload-all-files-dialog/component.tsx index 24d6136..e98e4a0 100644 --- a/src/views/upload-all-files-dialog/component.tsx +++ b/src/views/upload-all-files-dialog/component.tsx @@ -117,21 +117,16 @@ const UploadDialogContent = ({ onCancel }: { onCancel: () => void }) => { // forcing us to retry the failed upload. // So parallelization is not an option. for (const file of files) { - console.log(`Uploading ${file.path}`); if (abortController.signal.aborted) { return; } - await plugin.eventsConsumer.process({ - type: "modify", - filePath: file.path, - }); + await plugin.syncManager.uploadFile(file); if (abortController.signal.aborted) { // Check abort state only after the uploaded file metadata // is updated, otherwise we risk having outdated SHAs return; } setCompletedCount((count) => count + 1); - console.log(`Uploaded ${file.path}`); } };