diff --git a/src/events-listener.ts b/src/events-listener.ts index ee9dbea..0129b18 100644 --- a/src/events-listener.ts +++ b/src/events-listener.ts @@ -1,6 +1,7 @@ import { Vault, TAbstractFile, TFolder } from "obsidian"; import MetadataStore from "./metadata-store"; import { GitHubSyncSettings } from "./settings/settings"; +import Logger from "./logger"; /** * Tracks changes to local sync directory and updates files metadata. @@ -10,6 +11,7 @@ export default class EventsListener { private vault: Vault, private metadataStore: MetadataStore, private settings: GitHubSyncSettings, + private logger: Logger, ) {} start() { @@ -20,8 +22,10 @@ export default class EventsListener { } private async onCreate(file: TAbstractFile) { + await this.logger.info("Received create event", file.path); if (!this.isSyncable(file.path)) { // The file has not been created in directory that we're syncing with GitHub + await this.logger.info("Skipped created file", file.path); return; } if (file instanceof TFolder) { @@ -35,6 +39,7 @@ export default class EventsListener { // It's enough to mark it as non just downloaded. this.metadataStore.data.files[file.path].justDownloaded = false; await this.metadataStore.save(); + await this.logger.info("Updated just downloaded created file", file.path); return; } @@ -47,14 +52,16 @@ export default class EventsListener { lastModified: Date.now(), }; await this.metadataStore.save(); + await this.logger.info("Updated created file", file.path); } private async onDelete(file: TAbstractFile | string) { + const filePath = file instanceof TAbstractFile ? file.path : file; + await this.logger.info("Received delete event", filePath); if (file instanceof TFolder) { // Skip folders return; } - const filePath = file instanceof TAbstractFile ? file.path : file; if (!this.isSyncable(filePath)) { // The file was not in directory that we're syncing with GitHub return; @@ -63,11 +70,14 @@ export default class EventsListener { this.metadataStore.data.files[filePath].deleted = true; this.metadataStore.data.files[filePath].deletedAt = Date.now(); await this.metadataStore.save(); + await this.logger.info("Updated deleted file", filePath); } private async onModify(file: TAbstractFile) { + await this.logger.info("Received modify event", file.path); if (!this.isSyncable(file.path)) { // The file has not been create in directory that we're syncing with GitHub + await this.logger.info("Skipped modified file", file.path); return; } if (file instanceof TFolder) { @@ -80,14 +90,20 @@ export default class EventsListener { // It's enough to makr it as non just downloaded. this.metadataStore.data.files[file.path].justDownloaded = false; await this.metadataStore.save(); + await this.logger.info( + "Updated just downloaded modified file", + file.path, + ); return; } this.metadataStore.data.files[file.path].lastModified = Date.now(); this.metadataStore.data.files[file.path].dirty = true; await this.metadataStore.save(); + await this.logger.info("Updated modified file", file.path); } private async onRename(file: TAbstractFile, oldPath: string) { + await this.logger.info("Received rename event", file.path); if (file instanceof TFolder) { // Skip folders return; diff --git a/src/github/client.ts b/src/github/client.ts index 8e8d3e6..ab55947 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -1,4 +1,5 @@ import { requestUrl } from "obsidian"; +import Logger from "src/logger"; export type RepoContent = { files: { [key: string]: GetTreeResponseItem }; @@ -43,6 +44,7 @@ export default class GithubClient { private owner: string, private repo: string, private branch: string, + private logger: Logger, ) {} headers() { @@ -62,7 +64,12 @@ export default class GithubClient { const res = await requestUrl({ url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`, headers: this.headers(), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to get repo content", res); + throw new Error(`Failed to get repo content, status ${res.status}`); + } const files = res.json.tree .filter((file: GetTreeResponseItem) => file.type === "blob") .reduce( @@ -81,7 +88,12 @@ export default class GithubClient { headers: this.headers(), method: "POST", body: JSON.stringify(tree), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to create tree", res); + throw new Error(`Failed to create tree, status ${res.status}`); + } return res.json.sha; } @@ -95,7 +107,12 @@ export default class GithubClient { tree: treeSha, parents: [parent], }), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to create commit", res); + throw new Error(`Failed to create commit, status ${res.status}`); + } return res.json.sha; } @@ -103,19 +120,29 @@ export default class GithubClient { const res = await requestUrl({ url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`, headers: this.headers(), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to get branch head sha", res); + throw new Error(`Failed to get branch head sha, status ${res.status}`); + } return res.json.object.sha; } async updateBranchHead(sha: string) { - await requestUrl({ + const res = 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, }), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to update branch head sha", res); + throw new Error(`Failed to update branch head sha, status ${res.status}`); + } } /** @@ -126,7 +153,12 @@ export default class GithubClient { const res = await requestUrl({ url: url, headers: this.headers(), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to get blob", res); + throw new Error(`Failed to get blob, status ${res.status}`); + } return res.json; } @@ -138,7 +170,7 @@ export default class GithubClient { * @param content base64 encoded content of the file */ async createFile(path: string, content: string, message: string) { - await requestUrl({ + const res = await requestUrl({ url: `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${path}`, headers: this.headers(), method: "PUT", @@ -147,6 +179,11 @@ export default class GithubClient { content: content, branch: this.branch, }), + throw: false, }); + if (res.status < 200 || res.status >= 400) { + await this.logger.error("Failed to create file", res); + throw new Error(`Failed to create file, status ${res.status}`); + } } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..ed382ec --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,53 @@ +import { Vault, normalizePath } from "obsidian"; + +const LOG_FILE_NAME = "github-sync.log" as const; + +export default class Logger { + private enabled: boolean; + private logFile: string; + + constructor(private vault: Vault) { + this.logFile = normalizePath(`${vault.configDir}/${LOG_FILE_NAME}`); + this.enabled = true; + } + + private async write( + level: string, + message: string, + data?: any, + ): Promise { + if (!this.enabled) return; + + const logEntry = { + timestamp: new Date().toISOString(), + level, + message, + additional_data: data, + }; + + await this.vault.adapter.append( + this.logFile, + JSON.stringify(logEntry) + "\n", + ); + } + + enable(): void { + this.enabled = true; + } + + disable(): void { + this.enabled = false; + } + + async info(message: string, data?: any): Promise { + await this.write("INFO", message, data); + } + + async warn(message: string, data?: any): Promise { + await this.write("WARN", message, data); + } + + async error(message: string, data?: any): Promise { + await this.write("ERROR", message, data); + } +} diff --git a/src/main.ts b/src/main.ts index 30061cd..927128c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,10 +4,12 @@ import GitHubSyncSettingsTab from "./settings/tab"; import SyncManager from "./sync-manager"; import { FileMetadata } from "./metadata-store"; import { OnboardingDialog } from "./views/onboarding/view"; +import Logger from "./logger"; export default class GitHubSyncPlugin extends Plugin { settings: GitHubSyncSettings; syncManager: SyncManager; + logger: Logger; statusBarItem: HTMLElement | null = null; uploadModifiedFilesRibbonIcon: HTMLElement | null = null; @@ -27,6 +29,8 @@ export default class GitHubSyncPlugin extends Plugin { } async onload() { + this.logger = new Logger(this.app.vault); + await this.loadSettings(); this.addSettingTab(new GitHubSyncSettingsTab(this.app, this)); @@ -35,6 +39,7 @@ export default class GitHubSyncPlugin extends Plugin { this.app.vault, this.settings, this.onConflicts.bind(this), + this.logger, ); await this.syncManager.loadMetadata(); diff --git a/src/sync-manager.ts b/src/sync-manager.ts index 5ea57d6..711fbf1 100644 --- a/src/sync-manager.ts +++ b/src/sync-manager.ts @@ -7,6 +7,7 @@ import GithubClient, { import MetadataStore, { FileMetadata, Metadata } from "./metadata-store"; import EventsListener from "./events-listener"; import { GitHubSyncSettings } from "./settings/settings"; +import Logger from "./logger"; interface SyncAction { type: "upload" | "download" | "delete_local" | "delete_remote"; @@ -27,6 +28,7 @@ export default class SyncManager { private vault: Vault, private settings: GitHubSyncSettings, private onConflicts: OnConflictsCallback, + private logger: Logger, ) { this.metadataStore = new MetadataStore(this.vault); this.client = new GithubClient( @@ -34,11 +36,13 @@ export default class SyncManager { this.settings.githubOwner, this.settings.githubRepo, this.settings.githubBranch, + this.logger, ); this.eventsListener = new EventsListener( this.vault, this.metadataStore, this.settings, + this.logger, ); } @@ -62,6 +66,7 @@ export default class SyncManager { * This fails if neither remote nor local folders are empty. */ async firstSync() { + await this.logger.info("Starting first sync"); let repositoryIsBare = false; let res: RepoContent; let files: { @@ -81,6 +86,7 @@ export default class SyncManager { } if (repositoryIsBare) { + await this.logger.info("Remote repository is bare"); // Since the repository is completely empty we need to create a first commit. // We can't create that by going throught the normal sync process since the // API doesn't let us create a new tree when the repo is empty. @@ -103,6 +109,7 @@ export default class SyncManager { if (!remoteRepoIsEmpty && !vaultIsEmpty) { // Both have files, we can't sync, show error + await this.logger.error("Both remote and local have files, can't sync"); throw new Error("Both remote and local have files, can't sync"); } else if (remoteRepoIsEmpty || repositoryIsBare) { // Remote has no files and no manifest, let's just upload whatever we have locally. @@ -130,6 +137,7 @@ export default class SyncManager { files: { [key: string]: GetTreeResponseItem }, treeSha: string, ) { + await this.logger.info("Starting first sync from remote files"); await Promise.all( Object.keys(files) .filter((filePath: string) => { @@ -192,6 +200,7 @@ export default class SyncManager { files: { [key: string]: GetTreeResponseItem }, treeSha: string, ) { + await this.logger.info("Starting first sync from local files"); const newTreeFiles = Object.keys(files) .map((filePath: string) => ({ path: files[filePath].path, @@ -228,10 +237,12 @@ export default class SyncManager { * @returns */ async sync() { + await this.logger.info("Starting sync"); const { files, sha: treeSha } = await this.client.getRepoContent(); const manifest = files[`${this.vault.configDir}/github-sync-metadata.json`]; if (manifest === undefined) { + await this.logger.error("Remote manifest is missing", { files, treeSha }); throw new Error("Remote manifest is missing"); } @@ -250,6 +261,8 @@ export default class SyncManager { // Add the new action to the list later on console.log("Conflicts"); console.log(conflicts); + await this.logger.warn("Found conflicts", conflicts); + (await this.onConflicts(conflicts)).forEach( (resolution: boolean, index: number) => { if (resolution) { @@ -277,8 +290,10 @@ export default class SyncManager { if (actions.length === 0) { // Nothing to sync + await this.logger.info("Nothing to sync"); return; } + await this.logger.info("Actions to sync", actions); const newTreeFiles: { [key: string]: NewTreeRequestItem } = Object.keys( files, @@ -544,6 +559,7 @@ export default class SyncManager { ); await this.client.updateBranchHead(commitSha); + await this.logger.info("Sync done"); } async downloadFile(file: GetTreeResponseItem, lastModified: number) { @@ -585,8 +601,10 @@ export default class SyncManager { } async loadMetadata() { + await this.logger.info("Loading metadata"); await this.metadataStore.load(); if (Object.keys(this.metadataStore.data.files).length === 0) { + await this.logger.info("Metadata was empty, loading all files"); let files = []; let folders = [this.vault.getRoot().path]; while (folders.length > 0) { @@ -595,6 +613,7 @@ export default class SyncManager { continue; } if (!this.settings.syncConfigDir && folder === this.vault.configDir) { + await this.logger.info("Skipping config dir"); // Skip the config dir if the user doesn't want to sync it continue; } @@ -630,6 +649,7 @@ export default class SyncManager { }; this.metadataStore.save(); } + await this.logger.info("Loaded metadata"); } /** @@ -638,6 +658,7 @@ export default class SyncManager { * as we need to add those files to the metadata store or they would never be synced. */ async addConfigDirToMetadata() { + await this.logger.info("Adding config dir to metadata"); // Get all the files in the config dir let files = []; let folders = [this.vault.configDir]; @@ -671,6 +692,7 @@ export default class SyncManager { * keep being synced. */ async removeConfigDirFromMetadata() { + await this.logger.info("Removing config dir from metadata"); // Get all the files in the config dir let files = []; let folders = [this.vault.configDir]; diff --git a/src/views/onboarding/component.tsx b/src/views/onboarding/component.tsx index 885aece..65dd91e 100644 --- a/src/views/onboarding/component.tsx +++ b/src/views/onboarding/component.tsx @@ -306,6 +306,7 @@ const FirstSyncStepComponent = ({ async (_) => { return []; }, + plugin.logger, ); await syncManager.loadMetadata(); // When loading the plugin we also load the metadata, though since by default