diff --git a/src/main.ts b/src/main.ts index 6a6aca4..a6a750e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,11 +4,11 @@ import { Platform, WorkspaceLeaf, normalizePath, + Notice, } from "obsidian"; import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings"; import GitHubSyncSettingsTab from "./settings/tab"; import SyncManager, { ConflictFile, ConflictResolution } from "./sync-manager"; -import { OnboardingDialog } from "./views/onboarding/view"; import Logger from "./logger"; import { ConflictsResolutionView, @@ -41,14 +41,13 @@ export default class GitHubSyncPlugin extends Plugin { private conflicts: ConflictFile[] = []; async onUserEnable() { - if (Platform.isMobile) { - // TODO: Implement onboarding for mobile - this.settings.firstStart = false; - this.saveSettings(); - return; - } - if (this.settings.firstStart) { - new OnboardingDialog(this).open(); + if ( + this.settings.githubToken === "" || + this.settings.githubOwner === "" || + this.settings.githubRepo === "" || + this.settings.githubBranch === "" + ) { + new Notice("Go to settings to configure syncing"); } } @@ -129,13 +128,30 @@ export default class GitHubSyncPlugin extends Plugin { name: "Sync with GitHub", repeatable: false, icon: "refresh-cw", - callback: async () => { - await this.syncManager.sync(); - this.updateStatusBarItem(); - }, + callback: this.sync.bind(this), }); } + async sync() { + if ( + this.settings.githubToken === "" || + this.settings.githubOwner === "" || + this.settings.githubRepo === "" || + this.settings.githubBranch === "" + ) { + new Notice("Sync plugin not configured"); + return; + } + if (this.settings.firstSync) { + await this.syncManager.firstSync(); + this.settings.firstSync = false; + this.saveSettings(); + } else { + await this.syncManager.sync(); + } + this.updateStatusBarItem(); + } + async onunload() { this.stopSyncInterval(); } @@ -198,10 +214,7 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadModifiedFilesRibbonIcon = this.addRibbonIcon( "refresh-cw", "Sync with GitHub", - async () => { - await this.syncManager.sync(); - this.updateStatusBarItem(); - }, + this.sync.bind(this), ); } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index bb7ae29..97dba70 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,5 +1,5 @@ export interface GitHubSyncSettings { - firstStart: boolean; + firstSync: boolean; githubToken: string; githubOwner: string; githubRepo: string; @@ -15,7 +15,7 @@ export interface GitHubSyncSettings { } export const DEFAULT_SETTINGS: GitHubSyncSettings = { - firstStart: true, + firstSync: true, githubToken: "", githubOwner: "", githubRepo: "", diff --git a/src/sync-manager.ts b/src/sync-manager.ts index 8697fc9..0efaef0 100644 --- a/src/sync-manager.ts +++ b/src/sync-manager.ts @@ -87,17 +87,24 @@ export default class SyncManager { return; } + const notice = new Notice("Syncing..."); this.syncing = true; try { - this.firstSyncImpl(); - } finally { - this.syncing = false; + await this.firstSyncImpl(); + // Shown only if sync doesn't fail + new Notice("Sync successful", 5000); + } catch (err) { + // Show the error to the user, it's not automatically dismissed to make sure + // the user sees it. + new Notice(`Error syncing. ${err}`); } + this.syncing = false; + notice.hide(); } private async firstSyncImpl() { await this.logger.info("Starting first sync"); - let repositoryIsBare = false; + let repositoryIsEmpty = false; let res: RepoContent; let files: { [key: string]: GetTreeResponseItem; @@ -108,16 +115,20 @@ export default class SyncManager { files = res.files; treeSha = res.sha; } catch (err) { - if (err.status !== 409) { + // 409 is returned in case the remote repo has been just created + // and contains no files. + // 404 instead is returned in case there are no files. + // Either way we can handle both by commiting a new empty manifest. + if (err.status !== 409 && err.status !== 404) { this.syncing = false; throw err; } // The repository is bare, meaning it has no tree, no commits and no branches - repositoryIsBare = true; + repositoryIsEmpty = true; } - if (repositoryIsBare) { - await this.logger.info("Remote repository is bare"); + if (repositoryIsEmpty) { + await this.logger.info("Remote repository is empty"); // 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. @@ -126,7 +137,7 @@ export default class SyncManager { await this.client.createFile( `${this.vault.configDir}/${MANIFEST_FILE_NAME}`, "", - "Initial commit", + "First sync", ); // Now get the repo content again cause we know for sure it will return a // valid sha that we can use to create the first sync commit. @@ -135,14 +146,13 @@ export default class SyncManager { treeSha = res.sha; } - const remoteRepoIsEmpty = Object.keys(files).length === 0; const vaultIsEmpty = await this.vaultIsEmpty(); - if (!remoteRepoIsEmpty && !vaultIsEmpty) { + if (!repositoryIsEmpty && !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) { + } else if (repositoryIsEmpty) { // Remote has no files and no manifest, let's just upload whatever we have locally. // This is fine even if the vault is empty. // The most important thing at this point is that the remote manifest is created. diff --git a/src/views/onboarding/component.tsx b/src/views/onboarding/component.tsx deleted file mode 100644 index 65dd91e..0000000 --- a/src/views/onboarding/component.tsx +++ /dev/null @@ -1,556 +0,0 @@ -import { useState, useEffect } from "react"; -import SyncManager from "src/sync-manager"; -import { usePlugin } from "src/views/hooks"; -import { DEFAULT_SETTINGS, GitHubSyncSettings } from "src/settings/settings"; - -const STEPS = ["welcome", "repo", "token", "sync", "first_sync"] as const; - -type Step = (typeof STEPS)[number]; - -type StepData = { - repo: { owner: string; repo: string; branch: string }; - token: { token: string }; - sync: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }; -}; - -const DEFAULT_STEP_DATA: StepData = { - repo: { owner: "", repo: "", branch: "" }, - token: { token: "" }, - sync: { - mode: "manual", - syncOnStart: false, - syncConfigDir: true, - onConflict: "overwrite", - }, -}; - -const WelcomeStepComponent = () => { - return ( -
-

First setup

-

- This plugin is still in beta so not all features are available yet. If - you find any issues report that. -

-

- You'll be guided through some steps to get you started. -

-
- ); -}; - -const RepoStepComponent = ({ - values, - onChange, -}: { - values: { owner: string; repo: string; branch: string }; - onChange: (values: { owner: string; repo: string; branch: string }) => void; -}) => { - return ( -
-

- First we need the repository owner, name and branch. -

-

- You must use a repository that you have read and write access to. If - your vault already has content I suggest creating a new private - repository to sync. -

-
- onChange({ ...values, owner: e.target.value })} - /> - onChange({ ...values, repo: e.target.value })} - /> - onChange({ ...values, branch: e.target.value })} - /> -
-
- ); -}; - -const TokenStepComponent = ({ - values, - onChange, -}: { - values: { token: string }; - onChange: (values: { token: string }) => void; -}) => { - return ( -
-

- Now we need a GitHub Personal Access Token. -

-

- The token is used to get, create and update the files between your vault - and your GitHub repository. Click{" "} - here to - create a new token. For more information about the tokens see{" "} - - the official GitHub documentation. - -

-

- The token must have the Contents Read and Write permissions for the same - repository you set in the previous step. -

- onChange({ token: e.target.value })} - /> -
- ); -}; - -const SyncSettingsStepComponent = ({ - values, - onChange, -}: { - values: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }; - onChange: (values: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }) => void; -}) => { - return ( -
-

- You can sync your vault manually or every minute. You can change the - interval at a later time. -

- -

Optionally you can sync every time you open Obsidian.

-
- onChange({ ...values, syncOnStart: !values.syncOnStart }) - } - > - -
-

- If you want different configs for different vaults you can disable - config syncing. -

-
- onChange({ ...values, syncConfigDir: !values.syncConfigDir }) - } - > - -
-

In case of conflicts you can choose how to handle them.

-

- This is not yet implemented so remote files will always overwrite local - files in case of conflicts. -

- -
- ); -}; - -const FirstSyncStepComponent = ({ - stepData, - onClose, -}: { - stepData: StepData; - onClose: () => Promise; -}) => { - const [syncing, setSyncing] = useState(true); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const plugin = usePlugin(); - if (!plugin) { - // Unlikely to happen, makes TS happy though - throw new Error("Plugin is not initialized"); - } - useEffect(() => { - (async () => { - const settings: GitHubSyncSettings = { - ...DEFAULT_SETTINGS, - githubToken: stepData.token.token, - githubOwner: stepData.repo.owner, - githubRepo: stepData.repo.repo, - githubBranch: stepData.repo.branch, - syncStrategy: stepData.sync.mode, - syncOnStartup: stepData.sync.syncOnStart, - syncConfigDir: stepData.sync.syncConfigDir, - conflictHandling: stepData.sync.onConflict, - }; - // We don't want to save the settings yet, so we create a new sync manager - // used only for the first sync. - // After that's sucessful we'll save the settings. - const syncManager = new SyncManager( - plugin.app.vault, - settings, - // There can't be any conflicts at this point so we don't need to handle them - async (_) => { - return []; - }, - plugin.logger, - ); - await syncManager.loadMetadata(); - // When loading the plugin we also load the metadata, though since by default - // configs sync is enabled we need to remove them if necessary. Otherwise they - // would be synced even if the user doesn't want them to be. - // We also add them if they want to sync config dirs just for completeness, though - // they should already be there. - if (stepData.sync.syncConfigDir) { - await syncManager.addConfigDirToMetadata(); - } else { - await syncManager.removeConfigDirFromMetadata(); - } - try { - await syncManager.firstSync(); - } catch (e) { - setError(e.message); - setSuccess(false); - setSyncing(false); - return; - } - plugin.settings = settings; - await plugin.saveSettings(); - setSyncing(false); - setSuccess(true); - })(); - }, [stepData]); - - const loadingBarStyle = () => { - if (error) { - return { width: 0 }; - } else if (success) { - return { width: "100%" }; - } else { - return {}; - } - }; - - return ( -
-

- Now we can try and sync your vault with your repository. This might take - a few minutes depending on the number of files. -

-
-
- {syncing ? "Syncing..." : null} - {error ? "Syncing failed" : null} - {success ? "Syncing complete" : null} -
-
-
-
-
-
-
- {error ?
{error}
: null} - {success ?
Everything is set up and ready to go.
: null} -
-
- {success ? ( -
- -
- ) : null} -
- ); -}; - -const OnBoardingComponent = ({ onClose }: { onClose: () => Promise }) => { - // It starts as true because the first step is just a welcome message - // so the button is already enabled. - const [isValid, setIsValid] = useState(true); - const [step, setStep] = useState("welcome"); - const [stepData, setStepData] = useState(DEFAULT_STEP_DATA); - - const updateStepData = (step: Step, data: StepData[keyof StepData]) => { - setStepData((stepData) => { - return { - ...stepData, - [step]: data, - }; - }); - }; - - const isStepValid = (step: Step) => { - switch (step) { - // Only these steps have required data, the others don't - // so the step is automatically valid - case "repo": - const { owner, repo, branch } = stepData.repo ?? {}; - return Boolean(owner && repo && branch); - case "token": - return Boolean(stepData.token?.token); - case "sync": - default: - return true; - } - }; - - useEffect(() => { - setIsValid(isStepValid(step)); - }, [step, stepData]); - - const previousStep = () => { - setStep((currentStep) => { - const currentIndex = STEPS.indexOf(currentStep); - return STEPS[currentIndex - 1] ?? STEPS[0]; - }); - }; - - const nextStep = () => { - setStep((currentStep) => { - const currentIndex = STEPS.indexOf(currentStep); - return STEPS[currentIndex + 1] ?? STEPS[STEPS.length - 1]; - }); - }; - - const currentStepComponent = () => { - switch (step) { - case "welcome": - return ; - case "repo": - return ( - updateStepData("repo", data)} - /> - ); - case "token": - return ( - updateStepData("token", data)} - /> - ); - case "sync": - return ( - updateStepData("sync", data)} - /> - ); - case "first_sync": - return ; - } - }; - return ( -
-
- - {currentStepComponent()} - -
-
- ); -}; - -export default OnBoardingComponent; diff --git a/src/views/onboarding/view.tsx b/src/views/onboarding/view.tsx deleted file mode 100644 index 0c47580..0000000 --- a/src/views/onboarding/view.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Modal } from "obsidian"; -import { Root, createRoot } from "react-dom/client"; -import GitHubSyncPlugin from "src/main"; -import { PluginContext } from "src/views/hooks"; -import OnboardingDialogComponent from "./component"; - -export class OnboardingDialog extends Modal { - root: Root | null = null; - - constructor(private plugin: GitHubSyncPlugin) { - super(plugin.app); - } - - onOpen() { - this.root = createRoot(this.modalEl); - // Make the dialog look like the settings modal - this.modalEl.addClass("mod-settings"); - this.modalEl.addClass("mod-sidebar-layout"); - - this.root.render( - - { - this.plugin.settings.firstStart = false; - // Save the settings only after a successful first sync - await this.plugin.saveSettings(); - // Reload metadata after sync to be sure that the main sync manager - // uses the latest version - await this.plugin.syncManager.loadMetadata(); - this.close(); - }} - /> - , - ); - } - - async onClose() { - const { contentEl } = this; - this.root?.unmount(); - contentEl.empty(); - } -}