mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 05:41:36 +00:00
Big refactoring
This commit is contained in:
parent
b82a2cf6df
commit
a7995fc9a7
8 changed files with 340 additions and 336 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
203
src/main.ts
203
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
166
src/sync-manager.ts
Normal file
166
src/sync-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue