Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/database/facebook_account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface FacebookAccountRow {
deleteTaggedPosts: number;
deleteTaggedMedia: number;
userLang: string;
langNeedsRestore: number;
}

function facebookAccountRowToFacebookAccount(
Expand All @@ -40,6 +41,7 @@ function facebookAccountRowToFacebookAccount(
deleteTaggedPosts: row.deleteTaggedPosts === 1,
deleteTaggedMedia: row.deleteTaggedMedia === 1,
userLang: row.userLang || "English (US)",
langNeedsRestore: row.langNeedsRestore === 1,
};
}

Expand Down Expand Up @@ -102,7 +104,8 @@ export const saveFacebookAccount = (account: FacebookAccount) => {
deleteCheckins = ?,
deleteTaggedPosts = ?,
deleteTaggedMedia = ?,
userLang = ?
userLang = ?,
langNeedsRestore = ?
WHERE id = ?
`,
[
Expand All @@ -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,
],
);
Expand Down
8 changes: 8 additions & 0 deletions src/database/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/test_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function createMockFacebookAccount(
deleteTaggedPosts: false,
deleteTaggedMedia: false,
userLang: "English (US)",
langNeedsRestore: false,
...overrides,
};
}
Expand Down
43 changes: 42 additions & 1 deletion src/renderer/src/view_models/FacebookViewModel/jobs_lang.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()!;

Expand All @@ -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 () => {
Expand Down
21 changes: 21 additions & 0 deletions src/renderer/src/view_models/FacebookViewModel/jobs_lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ export async function runJobSaveUserLang(
): Promise<void> {
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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions src/renderer/src/view_models/FacebookViewModel/view_model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions src/renderer/src/view_models/FacebookViewModel/view_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
this.runJobsState = RunJobsState.Default;

Expand Down Expand Up @@ -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;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/shared_types/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type FacebookAccount = {
deleteTaggedPosts: boolean;
deleteTaggedMedia: boolean;
userLang: string;
langNeedsRestore: boolean;
};

export type FacebookProgressInfo = {
Expand Down
Loading