Ugly, uncommented and messy but kinda works

This commit is contained in:
Silvano Cerza 2025-01-14 16:21:49 +01:00
parent cd577d5751
commit 4085c2f5c5
5 changed files with 579 additions and 56 deletions

View file

@ -31,7 +31,7 @@ export default class EventsListener {
}
private async onCreate(file: TAbstractFile) {
if (!file.path.startsWith(this.localContentDir)) {
if (!this.isSyncable(file.path)) {
// The file has not been created in directory that we're syncing with GitHub
return;
}
@ -40,22 +40,34 @@ export default class EventsListener {
return;
}
const data = this.metadataStore.data[file.path];
const data = this.metadataStore.data.files[file.path];
if (data && data.justDownloaded) {
// This file was just downloaded and not created by the user.
// It's enough to mark it as non just downloaded.
this.metadataStore.data[file.path].justDownloaded = false;
this.metadataStore.data.files[file.path].justDownloaded = false;
await this.metadataStore.save();
return;
}
this.metadataStore.data[file.path] = {
let remotePath: string;
if (file.path.startsWith(this.localContentDir)) {
remotePath = file.path.replace(this.localContentDir, this.repoContentDir);
} else if (
file.path.startsWith(`${this.vault.configDir}/github-sync-metadata.json`)
) {
remotePath = file.path;
} else {
throw new Error("Unexpected file path");
}
this.metadataStore.data.files[file.path] = {
localPath: file.path,
remotePath: file.path.replace(this.localContentDir, this.repoContentDir),
remotePath: remotePath!,
sha: null,
dirty: true,
// This file has been created by the user
justDownloaded: false,
lastModified: Date.now(),
};
await this.metadataStore.save();
this.eventsQueue.enqueue({
@ -70,11 +82,14 @@ export default class EventsListener {
return;
}
const filePath = file instanceof TAbstractFile ? file.path : file;
if (!filePath.startsWith(this.localContentDir)) {
if (!this.isSyncable(filePath)) {
// The file was not in directory that we're syncing with GitHub
return;
}
this.metadataStore.data.files[filePath].deleted = true;
this.metadataStore.data.files[filePath].deletedAt = Date.now();
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.
@ -85,7 +100,7 @@ export default class EventsListener {
}
private async onModify(file: TAbstractFile) {
if (!file.path.startsWith(this.localContentDir)) {
if (!this.isSyncable(file.path)) {
// The file has not been create in directory that we're syncing with GitHub
return;
}
@ -93,15 +108,16 @@ export default class EventsListener {
// Skip folders
return;
}
const data = this.metadataStore.data[file.path];
const data = this.metadataStore.data.files[file.path];
if (data && data.justDownloaded) {
// This file was just downloaded and not modified by the user.
// It's enough to makr it as non just downloaded.
this.metadataStore.data[file.path].justDownloaded = false;
this.metadataStore.data.files[file.path].justDownloaded = false;
await this.metadataStore.save();
return;
}
this.metadataStore.data[file.path].dirty = true;
this.metadataStore.data.files[file.path].lastModified = Date.now();
this.metadataStore.data.files[file.path].dirty = true;
await this.metadataStore.save();
this.eventsQueue.enqueue({
type: "modify",
@ -114,32 +130,33 @@ export default class EventsListener {
// Skip folders
return;
}
if (
!file.path.startsWith(this.localContentDir) &&
!oldPath.startsWith(this.localContentDir)
) {
if (!this.isSyncable(file.path) && !this.isSyncable(oldPath)) {
// Both are not in directory that we're syncing with GitHub
return;
}
if (
file.path.startsWith(this.localContentDir) &&
oldPath.startsWith(this.localContentDir)
) {
if (this.isSyncable(file.path) && this.isSyncable(oldPath)) {
// Both files are in the synced directory
// First create the new one
await this.onCreate(file);
// Then delete the old one
await this.onDelete(oldPath);
return;
} else if (file.path.startsWith(this.localContentDir)) {
} else if (this.isSyncable(file.path)) {
// Only the new file is in the local directory
await this.onCreate(file);
return;
} else if (oldPath.startsWith(this.localContentDir)) {
} else if (this.isSyncable(oldPath)) {
// Only the old file was in the local directory
await this.onDelete(oldPath);
return;
}
}
private isSyncable(filePath: string) {
return (
filePath.startsWith(this.localContentDir) ||
filePath.startsWith(`${this.vault.configDir}/github-sync-metadata.json`)
);
}
}

View file

@ -1,10 +1,15 @@
import { Vault, requestUrl, normalizePath } from "obsidian";
import MetadataStore from "../metadata-store";
import MetadataStore, { Metadata } from "../metadata-store";
export type RepoContent = {
files: { [key: string]: GetTreeResponseItem };
sha: string;
};
/**
* Represents a single item in a tree response from the GitHub API.
*/
export type TreeItem = {
export type GetTreeResponseItem = {
path: string;
mode: string;
type: string;
@ -13,6 +18,14 @@ export type TreeItem = {
url: string;
};
export type NewTreeRequestItem = {
path: string;
mode: string;
type: string;
sha?: string | null;
content?: string;
};
/**
* Represents a git blob response from the GitHub API.
*/
@ -32,6 +45,7 @@ export default class GithubClient {
private repo: string,
private branch: string,
private repoContentDir: string,
private configDir: string,
) {}
headers() {
@ -43,21 +57,68 @@ export default class GithubClient {
}
/**
* Gets the content of a directory in the repo.
* Or the whole repo if repoContentDir is an empty string.
* Gets the content of the repo.
*
* @returns Array of files in the directory in the remote repo
*/
async getRepoContent(): Promise<TreeItem[]> {
async getRepoContent(): Promise<RepoContent> {
const res = await requestUrl({
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(this.repoContentDir),
);
return files;
const files = res.json.tree
.filter((file: GetTreeResponseItem) => file.type === "blob")
.reduce(
(
acc: { [key: string]: GetTreeResponseItem },
file: GetTreeResponseItem,
) => ({ ...acc, [file.path]: file }),
{},
);
return { files, sha: res.json.sha };
}
async createTree(tree: { tree: NewTreeRequestItem[]; base_tree: string }) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees`,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
});
return res.json.sha;
}
async createCommit(message: string, treeSha: string, parent: string) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/commits`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
message: message,
tree: treeSha,
parents: [parent],
}),
});
return res.json.sha;
}
async getBranchHeadSha() {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`,
headers: this.headers(),
});
return res.json.object.sha;
}
async updateBranchHead(sha: string) {
await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
sha: sha,
}),
});
}
/**

View file

@ -109,7 +109,7 @@ export default class GitHubSyncPlugin extends Plugin {
if (!activeFile) {
return;
}
await this.syncManager.uploadFile(activeFile);
// await this.syncManager.uploadFile(activeFile);
this.updateStatusBarItem();
},
});
@ -245,7 +245,7 @@ export default class GitHubSyncPlugin extends Plugin {
if (!activeFile) {
return;
}
await this.syncManager.uploadFile(activeFile);
// await this.syncManager.uploadFile(activeFile);
this.updateStatusBarItem();
},
);

View file

@ -20,10 +20,17 @@ export interface FileMetadata {
// downloaded it will trigger a 'create' or 'modify' event.
// This is a problem as we can't know whether an event has been triggered by us or the user.
justDownloaded: boolean;
// The last time the file was modified
lastModified: number;
// Whether the file has been deleted
deleted?: boolean | null;
// When the file was deleted
deletedAt?: number | null;
}
export interface Metadata {
[key: string]: FileMetadata;
lastSync: number;
files: { [key: string]: FileMetadata };
}
/**
@ -50,7 +57,7 @@ export default class MetadataStore {
const content = await this.vault.adapter.read(this.metadataFile);
this.data = JSON.parse(content);
} else {
this.data = {};
this.data = { lastSync: 0, files: {} };
}
}

View file

@ -1,10 +1,18 @@
import { Vault, TFile, normalizePath } from "obsidian";
import GithubClient, { TreeItem } from "./github/client";
import MetadataStore, { FileMetadata } from "./metadata-store";
import { Vault, normalizePath } from "obsidian";
import GithubClient, {
GetTreeResponseItem,
NewTreeRequestItem,
} from "./github/client";
import MetadataStore, { FileMetadata, Metadata } from "./metadata-store";
import EventsListener from "./events/listener";
import EventsConsumer from "./events/consumer";
import { GitHubSyncSettings } from "./settings/settings";
interface SyncAction {
type: "upload" | "download" | "delete_local" | "delete_remote";
filePath: string;
}
export default class SyncManager {
private metadataStore: MetadataStore;
private client: GithubClient;
@ -23,6 +31,7 @@ export default class SyncManager {
this.settings.githubRepo,
this.settings.githubBranch,
this.settings.repoContentDir,
this.vault.configDir,
);
this.eventsListener = new EventsListener(
this.vault,
@ -34,33 +43,388 @@ export default class SyncManager {
}
async sync() {
// TODO
// TODO: Handle cases
// 1. Remote manifest not found
// 2. Local manifest not found
// 3. Remote repo and local vault folder have both some files
//
// These could very well be first sync, maybe a separate function might be best.
// Might be something for onboarding.
const { files, sha: treeSha } = await this.client.getRepoContent();
const manifest = files[`${this.vault.configDir}/github-sync-metadata.json`];
if (manifest === undefined && Object.keys(files).length > 0) {
const localContentDirExists = await this.vault.adapter.exists(
this.settings.localContentDir,
);
if (localContentDirExists) {
const existing = await this.vault.adapter.list(
this.settings.localContentDir,
);
if (
(this.settings.localContentDir === "" &&
existing.folders.length > 1) ||
existing.files.length > 0
) {
// There are local and remote files but remote doesn't have a manifest
// we don't know what to do in this case
throw new Error("Can't sync");
} else if (existing.folders.length > 0 || existing.files.length > 0) {
// There are local and remote files but remote doesn't have a manifest
// we don't know what to do in this case
throw new Error("Can't sync");
}
}
// There's no remote manifest and there are files in the repo,
// though there are no files locally either, so we can just download everything
// and sync the remote manifest.
await Promise.all(
Object.keys(files)
.filter((filePath: string) => {
if (filePath.startsWith(this.settings.repoContentDir)) {
return true;
} else if (filePath.startsWith(this.vault.configDir)) {
return true;
}
return false;
})
.map(async (filePath: string) => {
await this.downloadFile(files[filePath], Date.now());
}),
);
const newTreeFiles = Object.keys(files)
.map((filePath: string) => ({
path: files[filePath].path,
mode: files[filePath].mode,
type: files[filePath].type,
sha: files[filePath].sha,
}))
.reduce(
(
acc: { [key: string]: NewTreeRequestItem },
item: NewTreeRequestItem,
) => ({ ...acc, [item.path]: item }),
{},
);
// Add files that are in the manifest but not in the tree.
await Promise.all(
Object.keys(this.metadataStore.data.files).map(
async (filePath: string) => {
const normalizedPath = normalizePath(filePath);
const content = await this.vault.adapter.read(normalizedPath);
const { remotePath } = this.metadataStore.data.files[filePath];
newTreeFiles[remotePath] = {
path: remotePath,
mode: "100644",
type: "blob",
content: content,
};
},
),
);
await this.commitSync(newTreeFiles, treeSha);
return;
}
const blob = await this.client.getBlob(manifest.url);
const remoteMetadata: Metadata = JSON.parse(atob(blob.content));
const conflicts = this.findConflicts(
remoteMetadata.files,
this.metadataStore.data.files,
);
if (conflicts.length > 0) {
// TODO: Show conflicts to the user with a callback
// Wait for response
// Create new action to handle the conflict
// Add the new action to the list later on
console.log("Conflicts");
console.log(conflicts);
}
const actions = this.determineSyncActions(
remoteMetadata.files,
this.metadataStore.data.files,
);
if (actions.length === 0) {
console.log("Nothing to sync");
return;
} else {
console.log("Actions:");
console.log(actions);
}
const newTreeFiles: { [key: string]: NewTreeRequestItem } = Object.keys(
files,
)
.map((filePath: string) => ({
path: files[filePath].path,
mode: files[filePath].mode,
type: files[filePath].type,
sha: files[filePath].sha,
}))
.reduce(
(
acc: { [key: string]: NewTreeRequestItem },
item: NewTreeRequestItem,
) => ({ ...acc, [item.path]: item }),
{},
);
await Promise.all(
actions.map(async (action) => {
switch (action.type) {
case "upload": {
const normalizedPath = normalizePath(action.filePath);
const content = await this.vault.adapter.read(normalizedPath);
const remotePath = action.filePath.replace(
this.settings.localContentDir,
this.settings.repoContentDir,
);
newTreeFiles[remotePath] = {
path: remotePath,
mode: "100644",
type: "blob",
content: content,
};
break;
}
case "delete_remote": {
const { remotePath } =
this.metadataStore.data.files[action.filePath];
newTreeFiles[remotePath].sha = null;
break;
}
case "download":
break;
case "delete_local":
break;
}
}),
);
// Download files and delete local files
await Promise.all([
...actions
.filter((action) => action.type === "download")
.map(async (action: SyncAction) => {
const remotePath = action.filePath.replace(
this.settings.localContentDir,
this.settings.repoContentDir,
);
await this.downloadFile(
files[remotePath],
remoteMetadata.files[action.filePath].lastModified,
);
}),
...actions
.filter((action) => action.type === "delete_local")
.map(async (action: SyncAction) => {
await this.deleteLocalFile(action.filePath);
}),
]);
await this.commitSync(newTreeFiles, treeSha);
}
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);
findConflicts(
remoteFiles: { [key: string]: FileMetadata },
localFiles: { [key: string]: FileMetadata },
): { remoteFile: FileMetadata; localFile: FileMetadata }[] {
const commonFiles = Object.keys(remoteFiles).filter(
(key) => key in localFiles,
);
return commonFiles
.map((filePath: string) => {
const remoteFile = remoteFiles[filePath];
const localFile = localFiles[filePath];
// We compare the SHA cause it remote files changes the SHA changes
// but not the same happens when the file is modified locally.
// So if sha are different and the local file is newer we can't
// know for sure which version should be kept.
if (
remoteFile.sha !== localFile.sha &&
remoteFile.lastModified < localFile.lastModified
) {
// File is modified on both sides, the user must solve the conflict
return {
remoteFile: remoteFile,
localFile: localFile,
};
}
return null;
})
.filter(
(
conflict: {
remoteFile: FileMetadata;
localFile: FileMetadata;
} | null,
) => conflict !== null,
);
}
determineSyncActions(
remoteFiles: { [key: string]: FileMetadata },
localFiles: { [key: string]: FileMetadata },
) {
let actions: SyncAction[] = [];
const commonFiles = Object.keys(remoteFiles).filter(
(key) => key in localFiles,
);
// Get diff for common files
commonFiles.forEach((filePath: string) => {
const remoteFile = remoteFiles[filePath];
const localFile = localFiles[filePath];
if (remoteFile.deleted && localFile.deleted) {
// Nothing to do
return;
}
if (
remoteFile.sha !== localFile.sha &&
remoteFile.lastModified < localFile.lastModified
) {
// This is a conflict, we handle it separately
// We compare the SHA cause it remote files changes the SHA changes
// but not the same happens when the file is modified locally.
// So if sha are different and the local file is newer we can't
// know for sure which version should be kept.
}
if (remoteFile.deleted && !localFile.deleted) {
if ((remoteFile.deletedAt as number) > localFile.lastModified) {
actions.push({
type: "delete_local",
filePath: filePath,
});
} else if (localFile.lastModified > (remoteFile.deletedAt as number)) {
actions.push({ type: "upload", filePath: filePath });
}
}
if (!remoteFile.deleted && localFile.deleted) {
if (remoteFile.lastModified > (localFile.deletedAt as number)) {
actions.push({ type: "download", filePath: filePath });
} else if ((localFile.deletedAt as number) > remoteFile.lastModified) {
actions.push({
type: "delete_remote",
filePath: filePath,
});
}
}
if (remoteFile.lastModified > localFile.lastModified) {
actions.push({ type: "download", filePath: filePath });
} else if (localFile.lastModified > remoteFile.lastModified) {
console.log("Uploading");
console.log(filePath);
console.log(`localFile.lastModified: ${localFile.lastModified}`);
console.log(`remoteFile.lastModified: ${remoteFile.lastModified}`);
actions.push({ type: "upload", filePath: filePath });
}
});
// Get diff for files in remote but not in local
Object.keys(remoteFiles).forEach((filePath: string) => {
const remoteFile = remoteFiles[filePath];
const localFile = localFiles[filePath];
if (localFile) {
// Local file exists, we already handled it.
// Skip it.
return;
}
if (remoteFile.deleted) {
// Remote is deleted but we don't have it locally.
// Nothing to do.
// TODO: Maybe we need to remove remote reference too?
} else {
actions.push({ type: "download", filePath: filePath });
}
});
// Get diff for files in local but not in remote
Object.keys(localFiles).forEach((filePath: string) => {
const remoteFile = remoteFiles[filePath];
const localFile = localFiles[filePath];
if (remoteFile) {
// Remote file exists, we already handled it.
// Skip it.
return;
}
if (localFile.deleted) {
// Local is deleted and remote doesn't exist.
// Just remove the local reference.
} else {
actions.push({ type: "upload", filePath: filePath });
}
});
return actions;
}
async commitSync(
treeFiles: { [key: string]: NewTreeRequestItem },
baseTreeSha: string,
) {
// Update local sync time
this.metadataStore.data.lastSync = Date.now();
this.metadataStore.save();
// Update manifest in list of new tree items
delete treeFiles[`${this.vault.configDir}/github-sync-metadata.json`].sha;
treeFiles[`${this.vault.configDir}/github-sync-metadata.json`].content =
JSON.stringify(this.metadataStore.data);
// Create the new tree
const newTree: { tree: NewTreeRequestItem[]; base_tree: string } = {
tree: Object.keys(treeFiles).map(
(filePath: string) => treeFiles[filePath],
),
base_tree: baseTreeSha,
};
const newTreeSha = await this.client.createTree(newTree);
const branchHeadSha = await this.client.getBranchHeadSha();
const commitSha = await this.client.createCommit(
"Sync",
newTreeSha,
branchHeadSha,
);
await this.client.updateBranchHead(commitSha);
}
async uploadFile(filePath: string) {
const { remotePath, sha } = this.metadataStore.data.files[filePath];
const normalizedFilePath = normalizePath(filePath);
if (!(await this.vault.adapter.exists(normalizedFilePath))) {
throw new Error(`Can't find file ${file.path}`);
throw new Error(`Can't find file ${filePath}`);
}
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;
this.metadataStore.data.files[filePath].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.data.files[filePath].sha = newSha;
this.metadataStore.save();
}
async downloadFile(file: TreeItem) {
async downloadFile(file: GetTreeResponseItem, lastModified: number) {
const url = file.url;
const destinationFile = file.path.replace(
this.settings.repoContentDir,
this.settings.localContentDir,
);
const fileMetadata = this.metadataStore.data[destinationFile];
const fileMetadata = this.metadataStore.data.files[destinationFile];
if (fileMetadata && fileMetadata.sha === file.sha) {
// File already exists and has the same SHA, no need to download it again.
return;
@ -77,33 +441,58 @@ export default class SyncManager {
normalizePath(destinationFile),
Buffer.from(blob.content, "base64"),
);
this.metadataStore.data[destinationFile] = {
this.metadataStore.data.files[destinationFile] = {
localPath: destinationFile,
remotePath: file.path,
sha: file.sha,
dirty: false,
justDownloaded: true,
lastModified: lastModified,
};
await this.metadataStore.save();
}
async deleteLocalFile(filePath: string) {
const normalizedPath = normalizePath(filePath);
await this.vault.adapter.remove(normalizedPath);
this.metadataStore.data.files[filePath].deleted = true;
this.metadataStore.data.files[filePath].deletedAt = Date.now();
this.metadataStore.save();
}
async deleteRemoteFile(filePath: string) {
const { remotePath, sha } = this.metadataStore.data.files[filePath];
if (!sha) {
// File was never uploaded, no need to delete it
// This is unlikely to happen but we handle it anyway
return;
}
await this.client.deleteFile(remotePath, sha);
this.metadataStore.save();
}
async deleteFile(filePath: string) {
const { remotePath, sha } = this.metadataStore.data[filePath];
const { remotePath, sha } = this.metadataStore.data.files[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();
const { files } = await this.client.getRepoContent();
await Promise.all(
files.map(async (file: TreeItem) => await this.downloadFile(file)),
Object.keys(files)
.filter((filePath: string) =>
filePath.startsWith(this.settings.repoContentDir),
)
.map(
async (filePath: string) =>
await this.downloadFile(files[filePath], Date.now()),
),
);
}
@ -119,10 +508,58 @@ export default class SyncManager {
async loadMetadata() {
await this.metadataStore.load();
if (Object.keys(this.metadataStore.data.files).length === 0) {
// Must be the first time we run, initialize the metadata store
// with the files from the config directory
let files = [];
let folders = [this.vault.configDir];
while (folders.length > 0) {
const folder = folders.pop();
if (!folder) {
continue;
}
const res = await this.vault.adapter.list(folder);
files.push(...res.files);
folders.push(...res.folders);
}
files.forEach((filePath: string) => {
if (filePath === `${this.vault.configDir}/workspace.json`) {
// Obsidian recommends not syncing the workspace file
return;
}
if (filePath.startsWith(`${this.vault.configDir}/plugins`)) {
// Let's not sync plugins for the time being
return;
}
this.metadataStore.data.files[filePath] = {
localPath: filePath,
// The config dir is always stored in the repo root so we use
// the same path for remote
remotePath: filePath,
sha: null,
dirty: false,
justDownloaded: false,
lastModified: Date.now(),
};
});
// Add itself, if we're here the manifest file doesn't exist
// so it can't add itself to the list of files
this.metadataStore.data.files[
`${this.vault.configDir}/github-sync-metadata.json`
] = {
localPath: `${this.vault.configDir}/github-sync-metadata.json`,
remotePath: `${this.vault.configDir}/github-sync-metadata.json`,
sha: null,
dirty: false,
justDownloaded: false,
lastModified: Date.now(),
};
this.metadataStore.save();
}
}
getFileMetadata(filePath: string): FileMetadata {
return this.metadataStore.data[filePath];
return this.metadataStore.data.files[filePath];
}
async startEventsListener() {
@ -138,9 +575,10 @@ export default class SyncManager {
throw new Error("Sync interval is already running");
}
this.syncIntervalId = window.setInterval(
() => this.uploadModifiedFiles(),
() => this.sync(),
// Sync interval is set in minutes but setInterval expects milliseconds
minutes * 60 * 1000,
// minutes * 60 * 1000,
10000,
);
return this.syncIntervalId;
}