Big refactoring

This commit is contained in:
Silvano Cerza 2025-01-12 21:51:24 +01:00
parent b82a2cf6df
commit a7995fc9a7
8 changed files with 340 additions and 336 deletions

View file

@ -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<void> {
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);
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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<TreeItem[]> {
async getRepoContent(): Promise<TreeItem[]> {
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,
}),
});
}

View file

@ -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);
}
}

166
src/sync-manager.ts Normal file
View file

@ -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);
}
}

View file

@ -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}`);
}
};