diff --git a/__test__/backup.test.ts b/__test__/backup.test.ts index 788c578..2f3f9f4 100644 --- a/__test__/backup.test.ts +++ b/__test__/backup.test.ts @@ -57,7 +57,8 @@ describe("Backup", function () { .calledWith("path").mockImplementation(() => Promise.resolve(testPath.backupBasePath)) .calledWith("zipArchive").mockImplementation(() => "no") .calledWith("execFinishCmd").mockImplementation(() => "") - .calledWith("usePassword").mockImplementation(() => false); + .calledWith("usePassword").mockImplementation(() => false) + .calledWith("createSubfolderPerProfile").mockImplementation(() => false); /* prettier-ignore */ when(spyOnGlobalValue) @@ -200,7 +201,7 @@ describe("Backup", function () { path.dirname(os.homedir()), path.join(os.homedir(), "Desktop"), path.join(os.homedir(), "Documents"), - + // Avoid including system-specific paths here. For example, // testing with "C:\Windows" fails on POSIX systems because it is interpreted // as a relative path. @@ -219,6 +220,71 @@ describe("Backup", function () { ); }); + describe("backups per profile", function () { + test.each([ + { + rootProfileDir: testPath.joplinProfile, + profileDir: testPath.joplinProfile, + joplinEnv: "prod", + expectedProfileName: "default", + }, + { + rootProfileDir: testPath.joplinProfile, + profileDir: testPath.joplinProfile, + joplinEnv: "dev", + expectedProfileName: "default-dev", + }, + { + rootProfileDir: testPath.joplinProfile, + profileDir: path.join(testPath.joplinProfile, "profile-test"), + joplinEnv: "prod", + expectedProfileName: "profile-test", + }, + { + rootProfileDir: testPath.joplinProfile, + profileDir: path.join(testPath.joplinProfile, "profile-idhere"), + joplinEnv: "prod", + expectedProfileName: "profile-idhere", + }, + { + rootProfileDir: testPath.joplinProfile, + profileDir: path.join(testPath.joplinProfile, "profile-idhere"), + joplinEnv: "dev", + expectedProfileName: "profile-idhere-dev", + }, + ])( + "should correctly set backupBasePath based on the current profile name (case %#)", + async ({ + profileDir, + rootProfileDir, + joplinEnv, + expectedProfileName, + }) => { + when(spyOnsSettingsValue) + .calledWith("path") + .mockImplementation(async () => testPath.backupBasePath); + when(spyOnGlobalValue) + .calledWith("rootProfileDir") + .mockImplementation(async () => rootProfileDir); + when(spyOnGlobalValue) + .calledWith("profileDir") + .mockImplementation(async () => profileDir); + when(spyOnGlobalValue) + .calledWith("env") + .mockImplementation(async () => joplinEnv); + + // Should use the folder named "default" for the default profile + backup.createSubfolderPerProfile = true; + await backup.loadBackupPath(); + expect(backup.backupBasePath).toBe( + path.normalize( + path.join(testPath.backupBasePath, expectedProfileName) + ) + ); + } + ); + }); + describe("Div", function () { it(`Create empty folder`, async () => { const folder = await backup.createEmptyFolder( @@ -1053,14 +1119,20 @@ describe("Backup", function () { }); describe("create backup readme", () => { - it.each([{ backupRetention: 1 }, { backupRetention: 2 }])( - "should create a README.md in the backup directory (case %#)", - async ({ backupRetention }) => { + it.each([ + { backupRetention: 1, createSubfolderPerProfile: false }, + { backupRetention: 2, createSubfolderPerProfile: false }, + { backupRetention: 1, createSubfolderPerProfile: true }, + ])( + "should create a README.md in the backup directory (case %j)", + async ({ backupRetention, createSubfolderPerProfile }) => { when(spyOnsSettingsValue) .calledWith("backupRetention") .mockImplementation(async () => backupRetention) .calledWith("backupInfo") - .mockImplementation(() => Promise.resolve("[]")); + .mockImplementation(() => Promise.resolve("[]")) + .calledWith("createSubfolderPerProfile") + .mockImplementation(() => Promise.resolve(createSubfolderPerProfile)); backup.backupStartTime = null; await backup.start(); diff --git a/src/Backup.ts b/src/Backup.ts index aeebbcc..183b3b7 100644 --- a/src/Backup.ts +++ b/src/Backup.ts @@ -17,6 +17,7 @@ class Backup { private msgDialog: any; private backupBasePath: string; private activeBackupPath: string; + private readmeOutputDirectory: string; private log: any; private logFile: string; private backupRetention: number; @@ -29,6 +30,7 @@ class Backup { private compressionLevel: number; private singleJex: boolean; private createSubfolder: boolean; + private createSubfolderPerProfile: boolean; private backupSetName: string; private exportFormat: string; private execFinishCmd: string; @@ -273,12 +275,10 @@ class Backup { ); } - if (this.createSubfolder) { - this.log.verbose("append subFolder"); - const orgBackupBasePath = this.backupBasePath; - this.backupBasePath = path.join(this.backupBasePath, "JoplinBackup"); + const origBackupBasePath = this.backupBasePath; + const handleSubfolderCreation = async () => { if ( - fs.existsSync(orgBackupBasePath) && + fs.existsSync(origBackupBasePath) && !fs.existsSync(this.backupBasePath) ) { try { @@ -287,6 +287,45 @@ class Backup { await this.showError(i18n.__("msg.error.folderCreation", e.message)); } } + }; + + if (this.createSubfolder) { + this.log.verbose("append subFolder"); + this.backupBasePath = path.join(this.backupBasePath, "JoplinBackup"); + await handleSubfolderCreation(); + } + + // Set the README output directory before adding a subdirectory for the profile. + // This gives us one README for all backup subfolders. + this.readmeOutputDirectory = this.backupBasePath; + + if (this.createSubfolderPerProfile) { + this.log.verbose("append profile subfolder"); + // We assume that Joplin's profile structure is the following + // rootProfileDir/ + // | profileDir/ + // | | [[profile content]] + // or, if using the default, + // rootProfileDir/ + // | [[profile content]] + const profileRootDir = await joplin.settings.globalValue( + "rootProfileDir" + ); + const profileCurrentDir = await joplin.settings.globalValue("profileDir"); + + let profileName = path.basename(profileCurrentDir); + if (profileCurrentDir === profileRootDir) { + profileName = "default"; + } + + // Appending a -dev to the profile name prevents a devmode default Joplin + // profile from overwriting a non-devmode Joplin profile. + if ((await joplin.settings.globalValue("env")) === "dev") { + profileName += "-dev"; + } + + this.backupBasePath = path.join(this.backupBasePath, profileName); + await handleSubfolderCreation(); } // Creating a backup can overwrite the backup directory. Thus, @@ -320,6 +359,9 @@ class Backup { public async loadSettings() { this.log.verbose("loadSettings"); this.createSubfolder = await joplin.settings.value("createSubfolder"); + this.createSubfolderPerProfile = await joplin.settings.value( + "createSubfolderPerProfile" + ); await this.loadBackupPath(); this.backupRetention = await joplin.settings.value("backupRetention"); @@ -499,7 +541,7 @@ class Backup { const backupDst = await this.makeBackupSet(); - await this.writeReadme(this.backupBasePath); + await this.writeReadme(this.readmeOutputDirectory); await joplin.settings.setValue( "lastBackup", this.backupStartTime.getTime() diff --git a/src/locales/en_US.json b/src/locales/en_US.json index e5ed212..880c49e 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -59,6 +59,10 @@ "label": "Create Subfolder", "description": "Create a subfolder in the the configured {{backupPath}}. Deactivate only if there is no other data in the {{backupPath}}!" }, + "createSubfolderPerProfile": { + "label": "Create subfolder for Joplin profile", + "description": "Create a subfolder within the backup directory for the current profile. This allows multiple profiles from the same Joplin installation to use the same backup directory without overwriting backups made from other profiles. All profiles that use the same backup directory must have this setting enabled." + }, "zipArchive": { "label": "Create archive", "description": "Save backup data in a archive, if a password protected backups is set, an archive is always created" diff --git a/src/settings.ts b/src/settings.ts index 09aae2a..3c58652 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -140,6 +140,15 @@ export namespace Settings { backupPath: i18n.__("settings.path.label"), }), }, + createSubfolderPerProfile: { + value: false, + type: SettingItemType.Bool, + section: "backupSection", + public: true, + advanced: true, + label: i18n.__("settings.createSubfolderPerProfile.label"), + description: i18n.__("settings.createSubfolderPerProfile.description"), + }, zipArchive: { value: "no", type: SettingItemType.String,