diff --git a/src/database/facebook_account.ts b/src/database/facebook_account.ts index 1e1027fe..83d69e51 100644 --- a/src/database/facebook_account.ts +++ b/src/database/facebook_account.ts @@ -18,6 +18,7 @@ interface FacebookAccountRow { deleteTaggedPosts: number; deleteTaggedMedia: number; userLang: string; + langNeedsRestore: number; } function facebookAccountRowToFacebookAccount( @@ -40,6 +41,7 @@ function facebookAccountRowToFacebookAccount( deleteTaggedPosts: row.deleteTaggedPosts === 1, deleteTaggedMedia: row.deleteTaggedMedia === 1, userLang: row.userLang || "English (US)", + langNeedsRestore: row.langNeedsRestore === 1, }; } @@ -102,7 +104,8 @@ export const saveFacebookAccount = (account: FacebookAccount) => { deleteCheckins = ?, deleteTaggedPosts = ?, deleteTaggedMedia = ?, - userLang = ? + userLang = ?, + langNeedsRestore = ? WHERE id = ? `, [ @@ -118,6 +121,7 @@ export const saveFacebookAccount = (account: FacebookAccount) => { account.deleteTaggedPosts ? 1 : 0, account.deleteTaggedMedia ? 1 : 0, account.userLang || "English (US)", + account.langNeedsRestore ? 1 : 0, account.id, ], ); diff --git a/src/database/migrations.ts b/src/database/migrations.ts index 14766ced..25cbefc5 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -279,6 +279,14 @@ SELECT id, type, sortOrder, xAccountId, uuid, facebookAccountID FROM account;`, `ALTER TABLE blueskyLocalAccount ADD COLUMN connectedAt DATETIME DEFAULT NULL;`, ], }, + // Set while a Facebook run has switched the account to English (US) and not + // yet switched it back, so an interrupted run can be undone on the next one. + { + name: "record whether a Facebook account's language still needs restoring", + sql: [ + `ALTER TABLE facebookAccount ADD COLUMN langNeedsRestore INTEGER DEFAULT 0;`, + ], + }, ]; export const runMainMigrations = () => { diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index 1bb66020..63adb164 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -97,6 +97,7 @@ export function createMockFacebookAccount( deleteTaggedPosts: false, deleteTaggedMedia: false, userLang: "English (US)", + langNeedsRestore: false, ...overrides, }; } diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_lang.test.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_lang.test.ts index b8f6e461..bfe3039e 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_lang.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_lang.test.ts @@ -303,6 +303,21 @@ describe("FacebookViewModel Language Jobs", () => { expect(vm.jobs[0].status).toBe("error"); expect(vm.account.facebookAccount?.userLang).toBe(""); }); + + it("keeps the stored language when a previous run left it unrestored", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ + userLang: "Español", + langNeedsRestore: true, + }), + }); + + await LangJobs.runJobSaveUserLang(vm, 0); + + expect(vm.getWebview()?.executeJavaScript).not.toHaveBeenCalled(); + expect(vm.account.facebookAccount?.userLang).toBe("Español"); + expect(vm.jobs[0].status).toBe("finished"); + }); }); describe("runJobSetLangToEnglish", () => { @@ -344,6 +359,8 @@ describe("FacebookViewModel Language Jobs", () => { "runJobSetLangToEnglish", "Successfully selected English (US)", ); + expect(vm.account.facebookAccount?.langNeedsRestore).toBe(true); + expect(window.electron.database.saveAccount).toHaveBeenCalled(); }); it("sets state to SetLangToEnglish", async () => { @@ -404,7 +421,10 @@ describe("FacebookViewModel Language Jobs", () => { it("restores language to Spanish", async () => { const vm = createMockFacebookViewModel({ - facebookAccount: createMockFacebookAccount({ userLang: "Español" }), + facebookAccount: createMockFacebookAccount({ + userLang: "Español", + langNeedsRestore: true, + }), }); const mockWebview = vm.getWebview()!; @@ -423,6 +443,27 @@ describe("FacebookViewModel Language Jobs", () => { "runJobRestoreUserLang", "Successfully restored to Español", ); + expect(vm.account.facebookAccount?.langNeedsRestore).toBe(false); + expect(window.electron.database.saveAccount).toHaveBeenCalled(); + }); + + it("leaves the restore owed when selecting the language fails", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ + userLang: "Español", + langNeedsRestore: true, + }), + }); + const mockWebview = vm.getWebview()!; + + vi.mocked(mockWebview.executeJavaScript) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); // select Español fails + + await LangJobs.runJobRestoreUserLang(vm, 0); + + expect(vm.account.facebookAccount?.langNeedsRestore).toBe(true); }); it("restores language to Portuguese (Brazil)", async () => { diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_lang.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_lang.ts index c89e0cc3..959d3b08 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_lang.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_lang.ts @@ -294,6 +294,16 @@ export async function runJobSaveUserLang( ): Promise { vm.runJobsState = RunJobsState.SaveUserLang; + // A previous run left the account in English (US); keep the real original + if (vm.account.facebookAccount?.langNeedsRestore) { + vm.log( + "runJobSaveUserLang", + `Previous run did not restore language, keeping ${vm.account.facebookAccount.userLang}`, + ); + await Helpers.finishJob(vm, jobIndex); + return; + } + vm.showBrowser = true; vm.showAutomationNotice = true; vm.instructions = vm.t("viewModels.facebook.jobs.savingLanguage"); @@ -381,6 +391,12 @@ export async function runJobSetLangToEnglish( await vm.waitForPause(); + // Mark the restore as owed before clicking, so a quit here is still undone + if (vm.account.facebookAccount) { + vm.account.facebookAccount.langNeedsRestore = true; + await window.electron.database.saveAccount(JSON.stringify(vm.account)); + } + // Select English (US) const selected = await selectLanguage(vm, DEFAULT_LANGUAGE); if (!selected) { @@ -477,6 +493,11 @@ export async function runJobRestoreUserLang( vm.log("runJobRestoreUserLang", `Successfully restored to ${userLang}`); } + if (vm.account.facebookAccount) { + vm.account.facebookAccount.langNeedsRestore = false; + await window.electron.database.saveAccount(JSON.stringify(vm.account)); + } + await vm.waitForPause(); // Wait for the change to take effect diff --git a/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts b/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts index b024580d..086ea54a 100644 --- a/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts @@ -222,6 +222,19 @@ describe("FacebookViewModel", () => { expect(vm.jobs[0].jobType).toBe("login"); }); + it("adds a restore job when a previous run left the language unrestored", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: { deleteWallPosts: false, langNeedsRestore: true }, + }); + + await vm.defineJobs(); + + expect(vm.jobs.map((j) => j.jobType)).toEqual([ + "login", + "restoreUserLang", + ]); + }); + it("initializes jobs with pending status", async () => { const vm = createMockFacebookViewModel({ facebookAccount: { deleteWallPosts: true }, @@ -425,6 +438,120 @@ describe("FacebookViewModel", () => { expect(vm.state).toBe(PlatformStates.WizardReviewDisplay); }); + describe("RunJobs state", () => { + const JOB_TYPES = [ + "login", + "saveUserLang", + "setLangToEnglish", + "deleteActivity", + "restoreUserLang", + ]; + + function setupRunJobs(throwOn: string | null) { + const vm = createMockFacebookViewModel({ + facebookAccount: { userLang: "Español" }, + }); + vm.state = PlatformStates.RunJobs; + vm.jobs = JOB_TYPES.map((jobType, id) => + createMockJob(jobType, { id }), + ); + vi.spyOn(vm, "error").mockResolvedValue(undefined); + const modalSpy = vi + .spyOn(vm, "showErrorModal") + .mockResolvedValue(undefined); + const runJobSpy = vi + .spyOn(vm, "runJob") + .mockImplementation(async (i: number) => { + const job = vm.jobs[i]; + job.status = "running"; + if (job.jobType === throwOn) { + throw new Error(`${throwOn} exploded`); + } + // Mirror what the real language jobs persist + if (job.jobType === "setLangToEnglish") { + vm.account.facebookAccount!.langNeedsRestore = true; + } + if (job.jobType === "restoreUserLang") { + vm.account.facebookAccount!.langNeedsRestore = false; + } + job.status = "finished"; + }); + return { vm, runJobSpy, modalSpy }; + } + + it("runs every job in order when nothing throws", async () => { + const { vm, runJobSpy } = setupRunJobs(null); + + await vm.run(); + + expect(runJobSpy.mock.calls.map(([i]) => i)).toEqual([0, 1, 2, 3, 4]); + expect(vm.state).toBe(PlatformStates.FinishedRunningJobs); + }); + + it("restores the user language before showing the error modal when deleteActivity throws", async () => { + const { vm, runJobSpy, modalSpy } = setupRunJobs("deleteActivity"); + + await vm.run(); + + expect(vm.error).toHaveBeenCalledWith( + AutomationErrorType.facebook_runJob_UnknownError, + expect.anything(), + null, + true, + ); + expect(runJobSpy.mock.calls.map(([i]) => i)).toEqual([0, 1, 2, 3, 4]); + expect(vm.jobs[4].status).toBe("finished"); + expect(vm.account.facebookAccount?.langNeedsRestore).toBe(false); + expect(modalSpy).toHaveBeenCalledTimes(1); + const restoreCall = runJobSpy.mock.invocationCallOrder[4]; + expect(restoreCall).toBeLessThan(modalSpy.mock.invocationCallOrder[0]); + expect(vm.state).toBe(PlatformStates.FinishedRunningJobs); + }); + + it("does not attempt a restore when login throws", async () => { + const { vm, runJobSpy, modalSpy } = setupRunJobs("login"); + vm.account.facebookAccount!.langNeedsRestore = true; + + await vm.run(); + + expect(runJobSpy.mock.calls.map(([i]) => i)).toEqual([0]); + expect(vm.jobs[4].status).toBe("pending"); + expect(modalSpy).toHaveBeenCalledTimes(1); + }); + + it("does not attempt a restore when the language was never switched", async () => { + const { vm, runJobSpy } = setupRunJobs("setLangToEnglish"); + + await vm.run(); + + expect(runJobSpy.mock.calls.map(([i]) => i)).toEqual([0, 1, 2]); + expect(vm.jobs[4].status).toBe("pending"); + }); + + it("reports a second error if the restore itself throws", async () => { + const { vm, runJobSpy } = setupRunJobs("deleteActivity"); + runJobSpy.mockImplementation(async (i: number) => { + const job = vm.jobs[i]; + job.status = "running"; + if ( + job.jobType === "deleteActivity" || + job.jobType === "restoreUserLang" + ) { + throw new Error(`${job.jobType} exploded`); + } + if (job.jobType === "setLangToEnglish") { + vm.account.facebookAccount!.langNeedsRestore = true; + } + job.status = "finished"; + }); + + await vm.run(); + + expect(vm.error).toHaveBeenCalledTimes(2); + expect(vm.state).toBe(PlatformStates.FinishedRunningJobs); + }); + }); + it("handles FinishedRunningJobs state", async () => { const vm = createMockFacebookViewModel(); vm.state = PlatformStates.FinishedRunningJobs; diff --git a/src/renderer/src/view_models/FacebookViewModel/view_model.ts b/src/renderer/src/view_models/FacebookViewModel/view_model.ts index 220d8beb..2e66259a 100644 --- a/src/renderer/src/view_models/FacebookViewModel/view_model.ts +++ b/src/renderer/src/view_models/FacebookViewModel/view_model.ts @@ -162,6 +162,9 @@ export class FacebookViewModel extends BrowserViewModel { jobTypes.push("setLangToEnglish"); jobTypes.push("deleteActivity"); jobTypes.push("restoreUserLang"); + } else if (this.account.facebookAccount?.langNeedsRestore) { + // A previous run was interrupted after switching to English (US) + jobTypes.push("restoreUserLang"); } // Create jobs in the database and get them back with real IDs @@ -257,6 +260,32 @@ export class FacebookViewModel extends BrowserViewModel { return Array.isArray(data) ? data : []; } + // Run restoreUserLang now if this run switched the language and is logged in + async restoreUserLangIfPending(): Promise { + const loggedIn = this.jobs.some( + (job) => job.jobType === "login" && job.status === "finished", + ); + const restoreIndex = this.jobs.findIndex( + (job) => job.jobType === "restoreUserLang" && job.status === "pending", + ); + if ( + !this.account.facebookAccount?.langNeedsRestore || + !loggedIn || + restoreIndex === -1 + ) { + return; + } + + this.log("run", "job loop ended early, restoring user language"); + try { + await this.runJob(restoreIndex); + } catch (e) { + await this.error(AutomationErrorType.facebook_runJob_UnknownError, { + error: formatError(e as Error), + }); + } + } + async runJob(jobIndex: number): Promise { this.runJobsState = RunJobsState.Default; @@ -397,12 +426,18 @@ export class FacebookViewModel extends BrowserViewModel { try { await this.runJob(i); } catch (e) { + // Report without the modal, restore the language, then show it: + // Cancel or Retry in the modal tears this run down await this.error( AutomationErrorType.facebook_runJob_UnknownError, { error: formatError(e as Error), }, + null, + true, ); + await this.restoreUserLangIfPending(); + await this.showErrorModal(); break; } } diff --git a/src/shared_types/account.ts b/src/shared_types/account.ts index 5a6de1d1..c9520784 100644 --- a/src/shared_types/account.ts +++ b/src/shared_types/account.ts @@ -89,6 +89,7 @@ export type FacebookAccount = { deleteTaggedPosts: boolean; deleteTaggedMedia: boolean; userLang: string; + langNeedsRestore: boolean; }; export type FacebookProgressInfo = {