diff --git a/src/account_facebook/controller/stats/getProgressInfo.ts b/src/account_facebook/controller/stats/getProgressInfo.ts index 173b45c6..6b6a3971 100644 --- a/src/account_facebook/controller/stats/getProgressInfo.ts +++ b/src/account_facebook/controller/stats/getProgressInfo.ts @@ -2,6 +2,7 @@ import type { FacebookAccountController } from "../../facebook_account_controlle import { FacebookProgressInfo, emptyFacebookProgressInfo, + FACEBOOK_DELETE_COUNTERS, } from "../../../shared_types"; export async function getProgressInfo( @@ -11,33 +12,20 @@ export async function getProgressInfo( controller.initDB(); } - const totalWallPostsDeletedConfig: string | null = await controller.getConfig( - "totalWallPostsDeleted", - ); - let totalWallPostsDeleted: number = 0; - if (totalWallPostsDeletedConfig) { - totalWallPostsDeleted = parseInt(totalWallPostsDeletedConfig); - } - - const totalWallPostsUntaggedConfig: string | null = - await controller.getConfig("totalWallPostsUntagged"); - let totalWallPostsUntagged: number = 0; - if (totalWallPostsUntaggedConfig) { - totalWallPostsUntagged = parseInt(totalWallPostsUntaggedConfig); - } - - const totalWallPostsHiddenConfig: string | null = await controller.getConfig( - "totalWallPostsHidden", - ); - let totalWallPostsHidden: number = 0; - if (totalWallPostsHiddenConfig) { - totalWallPostsHidden = parseInt(totalWallPostsHiddenConfig); + // temp: we need to update the server, currently it is summing all deletion + // activity and stuffing it into totalWallPostsDeleted + let totalWallPostsDeleted = 0; + for (const counter of FACEBOOK_DELETE_COUNTERS) { + const value = await controller.getConfig(`total_${counter}`); + if (value) { + totalWallPostsDeleted += parseInt(value); + } } const progressInfo = emptyFacebookProgressInfo(); progressInfo.accountUUID = controller.accountUUID; progressInfo.totalWallPostsDeleted = totalWallPostsDeleted; - progressInfo.totalWallPostsUntagged = totalWallPostsUntagged; - progressInfo.totalWallPostsHidden = totalWallPostsHidden; + progressInfo.totalWallPostsUntagged = 0; + progressInfo.totalWallPostsHidden = 0; return progressInfo; } diff --git a/src/database/facebook_account.ts b/src/database/facebook_account.ts index 57ab8329..1e1027fe 100644 --- a/src/database/facebook_account.ts +++ b/src/database/facebook_account.ts @@ -10,6 +10,13 @@ interface FacebookAccountRow { profileImageDataURI: string; accountID: string | null; deleteWallPosts: number; + deleteComments: number; + deleteReactions: number; + deletePostsOnOthers: number; + deleteOthersPosts: number; + deleteCheckins: number; + deleteTaggedPosts: number; + deleteTaggedMedia: number; userLang: string; } @@ -25,6 +32,13 @@ function facebookAccountRowToFacebookAccount( profileImageDataURI: row.profileImageDataURI, accountID: row.accountID, deleteWallPosts: row.deleteWallPosts === 1, + deleteComments: row.deleteComments === 1, + deleteReactions: row.deleteReactions === 1, + deletePostsOnOthers: row.deletePostsOnOthers === 1, + deleteOthersPosts: row.deleteOthersPosts === 1, + deleteCheckins: row.deleteCheckins === 1, + deleteTaggedPosts: row.deleteTaggedPosts === 1, + deleteTaggedMedia: row.deleteTaggedMedia === 1, userLang: row.userLang || "English (US)", }; } @@ -81,6 +95,13 @@ export const saveFacebookAccount = (account: FacebookAccount) => { profileImageDataURI = ?, accountID = ?, deleteWallPosts = ?, + deleteComments = ?, + deleteReactions = ?, + deletePostsOnOthers = ?, + deleteOthersPosts = ?, + deleteCheckins = ?, + deleteTaggedPosts = ?, + deleteTaggedMedia = ?, userLang = ? WHERE id = ? `, @@ -89,6 +110,13 @@ export const saveFacebookAccount = (account: FacebookAccount) => { account.profileImageDataURI, account.accountID, account.deleteWallPosts ? 1 : 0, + account.deleteComments ? 1 : 0, + account.deleteReactions ? 1 : 0, + account.deletePostsOnOthers ? 1 : 0, + account.deleteOthersPosts ? 1 : 0, + account.deleteCheckins ? 1 : 0, + account.deleteTaggedPosts ? 1 : 0, + account.deleteTaggedMedia ? 1 : 0, account.userLang || "English (US)", account.id, ], diff --git a/src/database/migrations.ts b/src/database/migrations.ts index 65b35874..6de5f33a 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -202,5 +202,19 @@ export const runMainMigrations = () => { );`, ], }, + // Add per-category delete settings so users can choose which activity-log + // data to delete (comments, reactions, etc.) independently of wall posts. + { + name: "add per-category delete settings to facebookAccount table", + sql: [ + `ALTER TABLE facebookAccount ADD COLUMN deleteComments INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deleteReactions INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deletePostsOnOthers INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deleteOthersPosts INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deleteCheckins INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deleteTaggedPosts INTEGER DEFAULT 0;`, + `ALTER TABLE facebookAccount ADD COLUMN deleteTaggedMedia INTEGER DEFAULT 0;`, + ], + }, ]); }; diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e3cc3a52..af7d1153 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -45,6 +45,7 @@ "savePosts": "Saving posts", "savePostsHTML": "Saving posts HTML", "deleteWallPosts": "Removing wall posts", + "deleteActivity": "Deleting Facebook data", "restoreUserLang": "Restoring language" }, "progress": { @@ -420,20 +421,39 @@ "deleteWallDescription": "Remove all posts from your Facebook wall." }, "deleteOptions": { - "title": "Delete My Wall", - "description": "Select the data you want to remove from your Facebook wall.", - "deleteWallPosts": "Remove all posts from my wall" + "title": "Delete My Facebook Data", + "description": "Select the data you want to delete from your Facebook activity log.", + "deleteWallPosts": "Posts, photos, and videos", + "deleteComments": "Comments", + "deleteReactions": "Likes and reactions", + "deletePostsOnOthers": "Your posts on other people's timelines", + "deleteOthersPosts": "Other people's posts on your timeline", + "deleteCheckins": "Check-ins", + "deleteTaggedPosts": "Posts you're tagged in", + "deleteTaggedMedia": "Photos and videos you're tagged in" }, "review": { - "deleteWallPosts": "All posts from your Facebook wall", + "deleteWallPosts": "Your posts, photos, and videos", + "deleteComments": "Your comments", + "deleteReactions": "Your likes and reactions", + "deletePostsOnOthers": "Your posts on other people's timelines", + "deleteOthersPosts": "Other people's posts on your timeline", + "deleteCheckins": "Your check-ins", + "deleteTaggedPosts": "Posts you're tagged in", + "deleteTaggedMedia": "Photos and videos you're tagged in", "languageSettingsMightChange": "Your Facebook language settings might temporarily change.", "languageSettingsDescription": "If your Facebook account isn't already set to English, Cyd will set it to English, delete your data, and then change it back to your preferred language." }, "finished": { "title": "Jobs Completed", - "wallPostsDeleted": "wall posts deleted", - "wallPostsUntagged": "wall posts untagged", - "wallPostsHidden": "wall posts hidden" + "wallPostsDeleted": "posts, photos, and videos deleted", + "commentsDeleted": "comments deleted", + "reactionsDeleted": "likes and reactions deleted", + "postsOnOthersDeleted": "posts on others' timelines deleted", + "othersPostsDeleted": "others' posts on your timeline deleted", + "checkinsDeleted": "check-ins deleted", + "taggedPostsDeleted": "tagged posts deleted", + "taggedMediaDeleted": "tagged photos and videos deleted" }, "premium": { "readyToDelete": "You're all set! Let's continue to configure what you want to delete.", @@ -632,6 +652,7 @@ "savingLanguage": "I'm checking your language settings.", "settingLanguageToEnglish": "I'm temporarily changing your language to English (US) for automation.", "restoringLanguage": "I'm restoring your original language setting.", + "deletingCategory": "# I'm deleting **{category}** from your Facebook activity log.", "removingWallPosts": "# I'm removing all posts from your Facebook wall.", "managePostsLoading": "# I'm checking what posts are left to remove.", "checkBatchActionWallPosts": "# I'm looking for a batch of posts to **{action}**...", diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index 1223de92..0f8fbe3c 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -84,6 +84,13 @@ export function createMockFacebookAccount( profileImageDataURI: "", accountID: null, deleteWallPosts: false, + deleteComments: false, + deleteReactions: false, + deletePostsOnOthers: false, + deleteOthersPosts: false, + deleteCheckins: false, + deleteTaggedPosts: false, + deleteTaggedMedia: false, userLang: "English (US)", ...overrides, }; diff --git a/src/renderer/src/util_facebook.test.ts b/src/renderer/src/util_facebook.test.ts index e94082fb..856dbb22 100644 --- a/src/renderer/src/util_facebook.test.ts +++ b/src/renderer/src/util_facebook.test.ts @@ -23,7 +23,7 @@ describe("util_facebook", () => { }); describe("facebookGetLastDelete", () => { - test("returns null when no lastFinishedJob_deleteWallPosts config exists", async () => { + test("returns null when no lastFinishedJob_deleteActivity config exists", async () => { mockFacebookGetConfig.mockResolvedValue(null); const result = await UtilFacebook.facebookGetLastDelete(1); @@ -31,11 +31,11 @@ describe("util_facebook", () => { expect(result).toBeNull(); expect(mockFacebookGetConfig).toHaveBeenCalledWith( 1, - "lastFinishedJob_deleteWallPosts", + "lastFinishedJob_deleteActivity", ); }); - test("returns Date when lastFinishedJob_deleteWallPosts config exists", async () => { + test("returns Date when lastFinishedJob_deleteActivity config exists", async () => { const testDate = "2024-01-15T10:30:00.000Z"; mockFacebookGetConfig.mockResolvedValue(testDate); @@ -45,7 +45,7 @@ describe("util_facebook", () => { expect(result?.toISOString()).toBe(testDate); expect(mockFacebookGetConfig).toHaveBeenCalledWith( 1, - "lastFinishedJob_deleteWallPosts", + "lastFinishedJob_deleteActivity", ); }); }); diff --git a/src/renderer/src/util_facebook.ts b/src/renderer/src/util_facebook.ts index e747e0f6..390a37e1 100644 --- a/src/renderer/src/util_facebook.ts +++ b/src/renderer/src/util_facebook.ts @@ -36,13 +36,13 @@ export async function facebookPostProgress( export async function facebookGetLastDelete( accountID: number, ): Promise { - const lastFinishedJob_deleteWallPosts = + const lastFinishedJob_deleteActivity = await window.electron.Facebook.getConfig( accountID, - "lastFinishedJob_deleteWallPosts", + "lastFinishedJob_deleteActivity", ); - if (lastFinishedJob_deleteWallPosts) { - return new Date(lastFinishedJob_deleteWallPosts); + if (lastFinishedJob_deleteActivity) { + return new Date(lastFinishedJob_deleteActivity); } return null; } diff --git a/src/renderer/src/view_models/FacebookViewModel/categories.ts b/src/renderer/src/view_models/FacebookViewModel/categories.ts new file mode 100644 index 00000000..4f8aa155 --- /dev/null +++ b/src/renderer/src/view_models/FacebookViewModel/categories.ts @@ -0,0 +1,96 @@ +import type { FacebookAccount } from "../../../../shared_types"; + +// The account boolean settings that enable deleting each data category. +export type FacebookDeleteSetting = + | "deleteWallPosts" + | "deleteComments" + | "deleteReactions" + | "deletePostsOnOthers" + | "deleteOthersPosts" + | "deleteCheckins" + | "deleteTaggedPosts" + | "deleteTaggedMedia"; + +// The numeric FacebookProgress fields, one per category. +export type FacebookDeleteCounter = + | "wallPostsDeleted" + | "commentsDeleted" + | "reactionsDeleted" + | "postsOnOthersDeleted" + | "othersPostsDeleted" + | "checkinsDeleted" + | "taggedPostsDeleted" + | "taggedMediaDeleted"; + +export type FacebookDeleteCategory = { + // The FacebookAccount boolean field that enables deleting this category. + setting: FacebookDeleteSetting & keyof FacebookAccount; + // The category_key URL parameter for the Facebook activity log. + categoryKey: string; + // The FacebookProgress counter incremented as items in this category are deleted. + counter: FacebookDeleteCounter; + // The i18n key for the checkbox label shown on the delete options page. + labelKey: string; +}; + +// Data categories the user can choose to delete. +// The deleteActivity job, defineJobs(), and the delete options UI all derive from this. +export const FACEBOOK_DELETE_CATEGORIES: FacebookDeleteCategory[] = [ + { + setting: "deleteWallPosts", + categoryKey: "MANAGEPOSTSPHOTOSANDVIDEOS", + counter: "wallPostsDeleted", + labelKey: "facebook.deleteOptions.deleteWallPosts", + }, + { + setting: "deleteComments", + categoryKey: "COMMENTSCLUSTER", + counter: "commentsDeleted", + labelKey: "facebook.deleteOptions.deleteComments", + }, + { + setting: "deleteReactions", + categoryKey: "LIKEDPOSTS", + counter: "reactionsDeleted", + labelKey: "facebook.deleteOptions.deleteReactions", + }, + { + setting: "deletePostsOnOthers", + categoryKey: "POSTSONOTHERSTIMELINES", + counter: "postsOnOthersDeleted", + labelKey: "facebook.deleteOptions.deletePostsOnOthers", + }, + { + setting: "deleteOthersPosts", + categoryKey: "WALLCLUSTER", + counter: "othersPostsDeleted", + labelKey: "facebook.deleteOptions.deleteOthersPosts", + }, + { + setting: "deleteCheckins", + categoryKey: "CHECKINS", + counter: "checkinsDeleted", + labelKey: "facebook.deleteOptions.deleteCheckins", + }, + { + setting: "deleteTaggedPosts", + categoryKey: "MANAGETAGSBYOTHERSCLUSTER", + counter: "taggedPostsDeleted", + labelKey: "facebook.deleteOptions.deleteTaggedPosts", + }, + { + setting: "deleteTaggedMedia", + categoryKey: "TAGGEDPHOTOS", + counter: "taggedMediaDeleted", + labelKey: "facebook.deleteOptions.deleteTaggedMedia", + }, +]; + +// Returns the categories the user has enabled for deletion on this account. +export function selectedDeleteCategories( + account: FacebookAccount, +): FacebookDeleteCategory[] { + return FACEBOOK_DELETE_CATEGORIES.filter((category) => + Boolean(account[category.setting]), + ); +} diff --git a/src/renderer/src/view_models/FacebookViewModel/helpers.ts b/src/renderer/src/view_models/FacebookViewModel/helpers.ts index 1babb2cc..b45a6705 100644 --- a/src/renderer/src/view_models/FacebookViewModel/helpers.ts +++ b/src/renderer/src/view_models/FacebookViewModel/helpers.ts @@ -34,6 +34,25 @@ export async function errorJob( vm.log("errorJob", vm.jobs[jobIndex].jobType); } +// Persist a cumulative deletion total for a progress counter (for the server) +export async function incrementCumulativeTotal( + vm: FacebookViewModel, + counter: string, + count: number, +): Promise { + if (count <= 0) { + return; + } + const key = `total_${counter}`; + const current = await window.electron.Facebook.getConfig(vm.account.id, key); + const newValue = (current ? parseInt(current) : 0) + count; + await window.electron.Facebook.setConfig( + vm.account.id, + key, + newValue.toString(), + ); +} + export async function syncProgress(vm: FacebookViewModel): Promise { // For now, just log progress - can be expanded to persist to database vm.log("syncProgress", JSON.stringify(vm.progress)); diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts index 51fdc0f9..f4684d09 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts @@ -16,7 +16,6 @@ import { mockElectronAPI, } from "../../test_util"; import * as DeleteJobs from "./jobs_delete"; -import { parseActions, getHighestPriority } from "./jobs_delete"; /** * Creates a mock FacebookJob for testing @@ -71,11 +70,11 @@ function createMockFacebookViewModel( createMockJob("login"), createMockJob("saveUserLang"), createMockJob("setLangToEnglish"), - createMockJob("deleteWallPosts"), + createMockJob("deleteActivity"), createMockJob("restoreUserLang"), ]; - vi.spyOn(vm, "log").mockImplementation(() => {}); + vi.spyOn(vm, "log").mockImplementation(() => { }); vi.spyOn(vm, "sleep").mockImplementation(async (ms: number) => { vi.setSystemTime(Date.now() + ms); }); @@ -88,6 +87,53 @@ function createMockFacebookViewModel( return vm; } +function mockSafeExecuteJavaScript( + vm: FacebookViewModel, + opts: { + batchesPerCategory?: number; + itemsPerBatch?: number; + clickTrashSuccess?: boolean; + batchCompletes?: boolean; + } = {}, +) { + const { + batchesPerCategory = 1, + itemsPerBatch = 5, + clickTrashSuccess = true, + batchCompletes = true, + } = opts; + + // Loading the activity log refills the available batches for the category. + let remainingBatches = 0; + vi.mocked(vm.loadURL).mockImplementation(async () => { + remainingBatches = batchesPerCategory; + }); + + vi.spyOn(vm, "safeExecuteJavaScript").mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (_code: string, label?: string): Promise => { + switch (label) { + case "toggleSelectAllCheckbox": + if (remainingBatches > 0) { + remainingBatches--; + return { success: true, value: true }; + } + return { success: true, value: false }; + case "countSelectableItems": + return { success: true, value: itemsPerBatch }; + case "clickDeletePostsOption": + return { success: true, value: clickTrashSuccess }; + case "confirmDeletion": + return { success: true, value: true }; + case "waitForBatchToComplete": + return { success: true, value: batchCompletes }; + default: + return { success: true, value: false }; + } + }, + ); +} + describe("FacebookViewModel Delete Jobs", () => { beforeEach(() => { vi.clearAllMocks(); @@ -103,762 +149,136 @@ describe("FacebookViewModel Delete Jobs", () => { vi.restoreAllMocks(); }); - describe("runJobDeleteWallPosts", () => { - it("sets runJobsState to DeleteWallPosts", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - // Mock all necessary JavaScript calls to simulate "no posts found" - vi.mocked(mockWebview.executeJavaScript).mockResolvedValue(false); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - // The function sets this at the start - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - "Loading profile page", - ); - }); - - it("shows browser and automation notice", async () => { + describe("runJobDeleteActivity", () => { + it("sets runJobsState, showBrowser, and showAutomationNotice", async () => { const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - vi.mocked(mockWebview.executeJavaScript).mockResolvedValue(false); + mockSafeExecuteJavaScript(vm); - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + await DeleteJobs.runJobDeleteActivity(vm, 3); + expect(vm.runJobsState).toBe(RunJobsState.DeleteActivity); expect(vm.showBrowser).toBe(true); expect(vm.showAutomationNotice).toBe(true); }); - it("loads the profile page", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - vi.mocked(mockWebview.executeJavaScript).mockResolvedValue(false); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.loadURL).toHaveBeenCalledWith("https://www.facebook.com/me/"); - }); - - it("reports error when Manage posts button is not found", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - // First call is to click the Manage posts button - return false (not found) - vi.mocked(mockWebview.executeJavaScript).mockResolvedValue(false); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.error).toHaveBeenCalledWith( - AutomationErrorType.facebook_runJob_deleteWallPosts_ClickManagePostsFailed, - expect.objectContaining({ - message: "Failed to click Manage posts button", - }), - expect.objectContaining({ currentURL: expect.any(String) }), - ); - expect(vm.jobs[3].status).toBe("error"); - }); - - it("reports error when dialog does not appear", async () => { + it("finishes without deleting anything when no categories are selected", async () => { const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; + mockSafeExecuteJavaScript(vm); - // First call clicks the button successfully - vi.mocked(mockWebview.executeJavaScript) - .mockResolvedValueOnce(true) // clickManagePostsButton - .mockResolvedValue(false); // waitForManagePostsDialog always returns false - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.error).toHaveBeenCalledWith( - AutomationErrorType.facebook_runJob_deleteWallPosts_DialogNotFound, - expect.objectContaining({ - message: "Manage posts dialog did not appear", - }), - expect.objectContaining({ currentURL: expect.any(String) }), - ); - }); + await DeleteJobs.runJobDeleteActivity(vm, 3); - it("exits loop when no deletable items found", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - // Simulate: button clicked, dialog appears, but no items to delete - vi.mocked(mockWebview.executeJavaScript) - .mockResolvedValueOnce(true) // clickManagePostsButton - .mockResolvedValueOnce(true) // waitForManagePostsDialog (first check) - .mockResolvedValue([]) - .mockResolvedValueOnce(true) // clickManagePostsButton - .mockResolvedValueOnce(true) // waitForManagePostsDialog (first check) - .mockResolvedValue([]); // getListsAndItems returns empty - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - "No actionable items found, finishing", - ); + // No category was selected, so the activity log is never loaded + expect(vm.loadURL).not.toHaveBeenCalled(); + expect(vm.safeExecuteJavaScript).not.toHaveBeenCalled(); + expect(vm.progress.isDeleteActivityFinished).toBe(true); + expect(vm.jobs[3].status).toBe("finished"); + expect(vm.jobs[3].finishedAt).not.toBeNull(); }); - it("updates progress with deleted posts count", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - // Simulate a successful deletion of 3 posts, then no more posts - // The call pattern is interleaved: toggleCheckbox, then getActionDescription for each item - let callCount = 0; - vi.mocked(mockWebview.executeJavaScript).mockImplementation(async () => { - callCount++; - - // First batch: - // 1. clickManagePostsButton - if (callCount === 1) return true; - // 2. waitForManagePostsDialog - if (callCount === 2) return true; - // 3. getListsAndItems - return 3 items (first poll succeeds immediately) - if (callCount === 3) - return [ - { listIndex: 0, itemIndex: 0 }, - { listIndex: 0, itemIndex: 1 }, - { listIndex: 0, itemIndex: 2 }, - ]; - // Interleaved: for each item, toggleCheckbox then getActionDescription - // 4. toggleCheckbox for item 0 - if (callCount === 4) return true; - // 5. getActionDescription for item 0 - if (callCount === 5) - return "You can hide or delete the posts selected."; - // 6. toggleCheckbox for item 1 - if (callCount === 6) return true; - // 7. getActionDescription for item 1 - if (callCount === 7) - return "You can hide or delete the posts selected."; - // 8. toggleCheckbox for item 2 - if (callCount === 8) return true; - // 9. getActionDescription for item 2 - if (callCount === 9) - return "You can hide or delete the posts selected."; - // 10. clickNextButton - if (callCount === 10) return true; - // 11. selectDeletePostsOption - if (callCount === 11) return true; - // 12. clickDoneButton - if (callCount === 12) return true; - // 13. waitForManagePostsDialogToDisappear - first check still shows dialog - if (callCount === 13) return true; - // 14. waitForManagePostsDialogToDisappear - dialog disappeared - if (callCount === 14) return false; - // 15. Second batch: clickManagePostsButton - if (callCount === 15) return true; - // 16. waitForManagePostsDialog - if (callCount === 16) return true; - // 17+. getListsAndItems - no more items - return []; + it("deletes a single selected category and increments its counter", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ deleteWallPosts: true }), + }); + mockSafeExecuteJavaScript(vm, { + batchesPerCategory: 1, + itemsPerBatch: 5, }); - // Mock Date.now advances naturally via fake timers + sleep mock - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - // After deleting 3 posts, progress should reflect this - expect(vm.progress.wallPostsDeleted).toBeGreaterThanOrEqual(0); - }); - - it("logs total posts deleted at end", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - vi.mocked(mockWebview.executeJavaScript) - .mockResolvedValueOnce(true) // clickManagePostsButton - .mockResolvedValueOnce(true) // waitForManagePostsDialog - .mockResolvedValue([]); // getListsAndItems returns no items - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + await DeleteJobs.runJobDeleteActivity(vm, 3); - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - expect.stringContaining("Total posts deleted"), + // Loaded the activity log for the one selected category + expect(vm.loadURL).toHaveBeenCalledTimes(1); + expect(vm.loadURL).toHaveBeenCalledWith( + expect.stringContaining("category_key=MANAGEPOSTSPHOTOSANDVIDEOS"), ); - }); - - it("marks job as finished at end", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - vi.mocked(mockWebview.executeJavaScript) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValue([]); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + // Toggled select-all and clicked the Trash button + const labels = vi + .mocked(vm.safeExecuteJavaScript) + .mock.calls.map((call) => call[1]); + expect(labels).toContain("toggleSelectAllCheckbox"); + expect(labels).toContain("clickDeletePostsOption"); + + // Incremented the correct counter and finished cleanly + expect(vm.progress.wallPostsDeleted).toBe(5); + expect(vm.progress.isDeleteActivityFinished).toBe(true); + expect(vm.progress.currentCategory).toBe(""); expect(vm.jobs[3].status).toBe("finished"); - expect(vm.jobs[3].finishedAt).not.toBeNull(); - }); - - it("continues to next batch if posts remain", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - let batchCount = 0; - vi.mocked(mockWebview.executeJavaScript).mockImplementation( - async (code: string) => { - // Track batch starts by detecting clickManagePostsButton calls - if (code.includes('aria-label="Manage posts"')) { - if (code.includes("click()")) { - batchCount++; - // Allow 2 batches, then fail - return batchCount <= 2; - } - } - return false; - }, - ); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - // Should have attempted multiple batches - expect(batchCount).toBeGreaterThanOrEqual(1); - }); - - it("handles errors from safeExecuteJavaScript gracefully", async () => { - const vm = createMockFacebookViewModel(); - vm.webview = null; // No webview available - - // Should complete without throwing - await expect( - DeleteJobs.runJobDeleteWallPosts(vm, 3), - ).resolves.not.toThrow(); + expect(vm.error).not.toHaveBeenCalled(); }); - it("waits for pause at key points", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - vi.mocked(mockWebview.executeJavaScript).mockResolvedValue(false); - - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.waitForPause).toHaveBeenCalled(); - }); - - it("stops batch and uncheck when priority drops from delete to hide", async () => { - // Items: item 0 supports delete+hide, item 1 supports hide only. - // Expected: check item 0 (priority=delete), check item 1 -> combined=hide -> uncheck item 1 and stop. - // Then proceed to delete item 0. On 2nd batch, clickManagePostsButton fails -> exit. - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - let managePostsClicks = 0; - let isDialogOpen = false; - let isActionOptionsVisible = false; - const checkedItems = new Set(); - - vi.mocked(mockWebview.executeJavaScript).mockImplementation( - async (code: string) => { - if ( - code.includes( - `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, - ) - ) { - managePostsClicks++; - isDialogOpen = managePostsClicks === 1; - isActionOptionsVisible = false; - return managePostsClicks <= 2; - } - - if ( - code.includes( - `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, - ) && - code.includes("return !!dialog;") - ) { - return isDialogOpen; - } - - if (code.includes("result.push({ listIndex, itemIndex });")) { - return managePostsClicks === 1 - ? [ - { listIndex: 0, itemIndex: 0 }, - { listIndex: 0, itemIndex: 1 }, - ] - : []; - } - - if (code.includes("const shouldCheck = ")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - const shouldCheckMatch = code.match( - /const shouldCheck = (true|false);/, - ); - - if (!listMatch || !itemMatch || !shouldCheckMatch) { - return false; - } - - const key = `${listMatch[1]}-${itemMatch[1]}`; - const shouldCheck = shouldCheckMatch[1] === "true"; - - if (shouldCheck) { - checkedItems.add(key); - } else { - checkedItems.delete(key); - } - - return true; - } - - if (code.includes("checkbox instanceof HTMLInputElement")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - - if (!listMatch || !itemMatch) { - return null; - } - - return checkedItems.has(`${listMatch[1]}-${itemMatch[1]}`); - } - - if (code.includes('text.startsWith("You can")')) { - if (checkedItems.has("0-0") && !checkedItems.has("0-1")) { - return "You can hide or delete the posts selected."; - } - - if (checkedItems.has("0-0") && checkedItems.has("0-1")) { - return "You can hide the posts selected."; - } - - return ""; - } - - if (code.includes(`aria-label="Next"`)) { - isActionOptionsVisible = true; - return true; - } - - if ( - code.includes("const hasActionOptions =") && - code.includes(`aria-label="Done"`) - ) { - return isActionOptionsVisible; - } - - if (code.includes("text.includes('delete posts')")) { - return checkedItems.size === 1 && checkedItems.has("0-0"); - } - - if (code.includes(`aria-label="Done"`)) { - isDialogOpen = false; - isActionOptionsVisible = false; - return true; - } + it("exits the category loop cleanly when no items are selectable", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ deleteWallPosts: true }), + }); + // toggleSelectAllCheckbox immediately reports no checkbox => no items + mockSafeExecuteJavaScript(vm, { batchesPerCategory: 0 }); - return false; - }, - ); + await DeleteJobs.runJobDeleteActivity(vm, 3); - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + const labels = vi + .mocked(vm.safeExecuteJavaScript) + .mock.calls.map((call) => call[1]); + // We checked the select-all checkbox but never tried to delete + expect(labels).toContain("toggleSelectAllCheckbox"); + expect(labels).not.toContain("clickDeletePostsOption"); - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - expect.stringContaining('changes priority from "delete" to "hide"'), - ); - expect(vm.progress.wallPostsDeleted).toBe(1); + expect(vm.progress.wallPostsDeleted).toBe(0); + expect(vm.progress.isDeleteActivityFinished).toBe(true); + expect(vm.jobs[3].status).toBe("finished"); + expect(vm.error).not.toHaveBeenCalled(); }); - it("deletes second item even if first item is hide and second item is delete", async () => { - // Items: item 0 supports hide, item 1 supports delete+hide only. - // Expected: check item 0 (priority=hide) -> uncheck, check item 1 -> priority=delete. - // Then proceed to delete item 1. On 2nd batch, clickManagePostsButton fails -> exit. - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - let managePostsClicks = 0; - let isDialogOpen = false; - let isActionOptionsVisible = false; - const checkedItems = new Set(); - - vi.mocked(mockWebview.executeJavaScript).mockImplementation( - async (code: string) => { - if ( - code.includes( - `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, - ) - ) { - managePostsClicks++; - isDialogOpen = managePostsClicks === 1; - isActionOptionsVisible = false; - return managePostsClicks <= 2; - } - - if ( - code.includes( - `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, - ) && - code.includes("return !!dialog;") - ) { - return isDialogOpen; - } - - if (code.includes("result.push({ listIndex, itemIndex });")) { - return managePostsClicks === 1 - ? [ - { listIndex: 0, itemIndex: 0 }, - { listIndex: 0, itemIndex: 1 }, - ] - : []; - } - - if (code.includes("const shouldCheck = ")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - const shouldCheckMatch = code.match( - /const shouldCheck = (true|false);/, - ); - - if (!listMatch || !itemMatch || !shouldCheckMatch) { - return false; - } - - const key = `${listMatch[1]}-${itemMatch[1]}`; - const shouldCheck = shouldCheckMatch[1] === "true"; - - if (shouldCheck) { - checkedItems.add(key); - } else { - checkedItems.delete(key); - } - - return true; - } - - if (code.includes("checkbox instanceof HTMLInputElement")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - - if (!listMatch || !itemMatch) { - return null; - } - - return checkedItems.has(`${listMatch[1]}-${itemMatch[1]}`); - } - - if (code.includes('text.startsWith("You can")')) { - if (checkedItems.has("0-0") && !checkedItems.has("0-1")) { - return "You can hide the posts selected."; - } - - if (!checkedItems.has("0-0") && checkedItems.has("0-1")) { - // Unchecked hide item but checked the deleteable item - return "You can hide or delete the posts selected."; - } - - return ""; - } - - if (code.includes(`aria-label="Next"`)) { - isActionOptionsVisible = true; - return true; - } - - if ( - code.includes("const hasActionOptions =") && - code.includes(`aria-label="Done"`) - ) { - return isActionOptionsVisible; - } - - if (code.includes("text.includes('delete posts')")) { - return checkedItems.size === 1 && checkedItems.has("0-1"); - } - - if (code.includes(`aria-label="Done"`)) { - isDialogOpen = false; - isActionOptionsVisible = false; - return true; - } - - return false; - }, - ); + it("processes multiple selected categories and increments each counter", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ + deleteWallPosts: true, + deleteComments: true, + }), + }); + mockSafeExecuteJavaScript(vm, { + batchesPerCategory: 1, + itemsPerBatch: 3, + }); - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + await DeleteJobs.runJobDeleteActivity(vm, 3); - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - expect.stringContaining('Selected 1 items for action "delete"'), + // One activity-log load per selected category + expect(vm.loadURL).toHaveBeenCalledTimes(2); + expect(vm.loadURL).toHaveBeenCalledWith( + expect.stringContaining("category_key=MANAGEPOSTSPHOTOSANDVIDEOS"), ); - expect(vm.progress.wallPostsDeleted).toBe(1); - }); - - it("performs untag action when highest priority is untag", async () => { - // Item supports untag+hide. Expected: batch action = untag. - // On 2nd batch, clickManagePostsButton fails -> exit. - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - let managePostsClicks = 0; - let isDialogOpen = false; - let isActionOptionsVisible = false; - const checkedItems = new Set(); - - vi.mocked(mockWebview.executeJavaScript).mockImplementation( - async (code: string) => { - if ( - code.includes( - `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, - ) - ) { - managePostsClicks++; - isDialogOpen = managePostsClicks === 1; - isActionOptionsVisible = false; - return managePostsClicks <= 2; - } - - if ( - code.includes( - `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, - ) && - code.includes("return !!dialog;") - ) { - return isDialogOpen; - } - - if (code.includes("result.push({ listIndex, itemIndex });")) { - return managePostsClicks === 1 - ? [{ listIndex: 0, itemIndex: 0 }] - : []; - } - - if (code.includes("const shouldCheck = ")) { - if (code.includes("const shouldCheck = true;")) { - checkedItems.add("0-0"); - } else { - checkedItems.delete("0-0"); - } - return true; - } - - if (code.includes("checkbox instanceof HTMLInputElement")) { - return checkedItems.has("0-0"); - } - - if (code.includes('text.startsWith("You can")')) { - return checkedItems.has("0-0") - ? "You can untag yourself from or hide the posts selected." - : ""; - } - - if (code.includes(`aria-label="Next"`)) { - isActionOptionsVisible = true; - return true; - } - - if ( - code.includes("const hasActionOptions =") && - code.includes(`aria-label="Done"`) - ) { - return isActionOptionsVisible; - } - - if ( - code.includes("text.includes('untag')") || - code.includes("text.includes('remove tags')") - ) { - return true; - } - - if (code.includes(`aria-label="Done"`)) { - isDialogOpen = false; - isActionOptionsVisible = false; - return true; - } - - return false; - }, + expect(vm.loadURL).toHaveBeenCalledWith( + expect.stringContaining("category_key=COMMENTSCLUSTER"), ); - await DeleteJobs.runJobDeleteWallPosts(vm, 3); - - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - 'Item keeps batch action "untag", checked 1/10', - ); - expect(vm.progress.wallPostsUntagged).toBe(1); + expect(vm.progress.wallPostsDeleted).toBe(3); + expect(vm.progress.commentsDeleted).toBe(3); + expect(vm.progress.isDeleteActivityFinished).toBe(true); + expect(vm.jobs[3].status).toBe("finished"); }); - it("unchecks the last item before clicking Next when delete is no longer allowed", async () => { - const vm = createMockFacebookViewModel(); - const mockWebview = vm.getWebview()!; - - let managePostsClicks = 0; - let isDialogOpen = false; - let isActionOptionsVisible = false; - const checkedItems = new Set(); - const itemCount = 9; - - vi.mocked(mockWebview.executeJavaScript).mockImplementation( - async (code: string) => { - if ( - code.includes( - `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, - ) - ) { - managePostsClicks++; - isDialogOpen = managePostsClicks <= 2; - isActionOptionsVisible = false; - return isDialogOpen; - } - - if ( - code.includes( - `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, - ) && - code.includes("return !!dialog;") - ) { - return isDialogOpen; - } - - if (code.includes("result.push({ listIndex, itemIndex });")) { - if (managePostsClicks === 1) { - return Array.from({ length: itemCount }, (_, itemIndex) => ({ - listIndex: 0, - itemIndex, - })); - } - - return []; - } - - if (code.includes("const shouldCheck = ")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - const shouldCheckMatch = code.match( - /const shouldCheck = (true|false);/, - ); - - if (!listMatch || !itemMatch || !shouldCheckMatch) { - return false; - } - - const key = `${listMatch[1]}-${itemMatch[1]}`; - const shouldCheck = shouldCheckMatch[1] === "true"; - - // Regression guard: the real DOM exposes checkbox.checked, not aria-checked on the input. - const currentChecked = code.includes("checkbox.checked") - ? checkedItems.has(key) - : false; - - if (currentChecked !== shouldCheck) { - if (shouldCheck) { - checkedItems.add(key); - } else { - checkedItems.delete(key); - } - } - - return true; - } - - if (code.includes("checkbox instanceof HTMLInputElement")) { - const listMatch = code.match(/const list = lists\[(\d+)\];/); - const itemMatch = code.match(/const item = items\[(\d+)\];/); - - if (!listMatch || !itemMatch) { - return null; - } - - return checkedItems.has(`${listMatch[1]}-${itemMatch[1]}`); - } - - if (code.includes('text.startsWith("You can")')) { - return checkedItems.size <= 8 - ? "You can hide or delete the posts selected." - : "You can hide the posts selected."; - } - - if (code.includes(`aria-label="Next"`)) { - isActionOptionsVisible = true; - return true; - } - - if ( - code.includes("const hasActionOptions =") && - code.includes(`aria-label="Done"`) - ) { - return isActionOptionsVisible; - } - - if (code.includes("text.includes('delete posts')")) { - return checkedItems.size <= 8; - } - - if (code.includes(`aria-label="Done"`)) { - isDialogOpen = false; - isActionOptionsVisible = false; - return true; - } - - return false; - }, - ); + it("errors the job when clicking the Trash button fails", async () => { + const vm = createMockFacebookViewModel({ + facebookAccount: createMockFacebookAccount({ deleteWallPosts: true }), + }); + mockSafeExecuteJavaScript(vm, { + batchesPerCategory: 1, + clickTrashSuccess: false, + }); - await DeleteJobs.runJobDeleteWallPosts(vm, 3); + await DeleteJobs.runJobDeleteActivity(vm, 3); - expect(vm.progress.wallPostsDeleted).toBe(8); - expect(vm.error).not.toHaveBeenCalledWith( + expect(vm.error).toHaveBeenCalledWith( AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, - expect.anything(), - expect.anything(), - ); - expect(vm.log).toHaveBeenCalledWith( - "runJobDeleteWallPosts", - 'Selected 8 items for action "delete"', + expect.objectContaining({ + message: "Failed to click Trash button", + }), + expect.objectContaining({ currentURL: expect.any(String) }), ); - }); - }); - - describe("parseActions", () => { - it("parses delete+hide from combined description", () => { - expect( - parseActions("You can hide or delete the posts selected."), - ).toEqual(["delete", "hide"]); - }); - - it("parses untag+hide", () => { - expect( - parseActions("You can untag yourself from or hide the posts selected."), - ).toEqual(["untag", "hide"]); - }); - - it("parses hide only", () => { - expect(parseActions("You can hide the posts selected.")).toEqual([ - "hide", - ]); - }); - - it("returns empty array for unrecognized text", () => { - expect(parseActions("Something completely different.")).toEqual([]); - }); - }); - - describe("getHighestPriority", () => { - it("returns delete when delete is available", () => { - expect(getHighestPriority(["delete", "hide"])).toBe("delete"); - }); - - it("returns untag over hide", () => { - expect(getHighestPriority(["untag", "hide"])).toBe("untag"); - }); - - it("returns hide when only hide available", () => { - expect(getHighestPriority(["hide"])).toBe("hide"); - }); - - it("returns null for empty actions", () => { - expect(getHighestPriority([])).toBeNull(); + expect(vm.jobs[3].status).toBe("error"); + // The job errored before completing, so it is never marked finished + expect(vm.progress.isDeleteActivityFinished).toBe(false); }); }); }); diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts index 7c4b577c..8cf4f33b 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts @@ -3,8 +3,18 @@ import { RunJobsState } from "./types"; import * as Helpers from "./helpers"; import { checkRateLimit } from "./rate_limit"; import { AutomationErrorType } from "../../automation_errors"; +import { + selectedDeleteCategories, + type FacebookDeleteCategory, +} from "./categories"; -const FACEBOOK_PROFILE_URL = "https://www.facebook.com/me/"; +const ACTIVITY_LOG_CHECKBOX_NAME = "comet_activity_log_select_all_checkbox"; + +// Facebook throttles consecutive bulk deletions. Cool down between batches, and if it +// reports it's "still processing the previous changes", back off and retry +const BATCH_COOLDOWN_MS = 5000; +const PROCESSING_BACKOFF_MS = 60000; +const MAX_PROCESSING_RETRIES = 15; async function reportDeleteWallPostsError( vm: FacebookViewModel, @@ -19,969 +29,381 @@ async function reportDeleteWallPostsError( } /** - * Click the "Manage posts" button on the profile page - */ -async function clickManagePostsButton(vm: FacebookViewModel): Promise { - const result = await vm.safeExecuteJavaScript( - `(() => { - const buttons = document.querySelectorAll('div[aria-label="Manage posts"][role="button"]'); - if (buttons.length > 0) { - buttons[0].click(); - return true; - } - return false; - })()`, - "clickManagePostsButton", - ); - return result.success && result.value; -} - -/** - * Wait for the "Manage posts" dialog to appear + * Toggle a checkbox by name and return success */ -async function waitForManagePostsDialog( +async function toggleSelectAllCheckbox( vm: FacebookViewModel, + shouldCheck: boolean, ): Promise { - // Wait up to 30 seconds for dialog to appear - for (let i = 0; i < 60; i++) { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - return !!dialog; - })()`, - "waitForManagePostsDialog", - ); - - if (!result.success) { - return false; - } - - if (result.value) { - // Give it a moment for content to load - await vm.sleep(500); - return true; - } - await vm.sleep(500); - } - return false; -} + const result = await vm.safeExecuteJavaScript( + `(() => { + const checkbox = document.querySelector('input[name="${ACTIVITY_LOG_CHECKBOX_NAME}"]'); + if (!checkbox) return false; -/** - * Wait for the "Manage posts" dialog to disappear - * This indicates the deletion process has completed - */ -async function waitForManagePostsDialogToDisappear( - vm: FacebookViewModel, -): Promise { - // Wait up to 60 seconds for dialog to disappear (deletion might take a while) - for (let i = 0; i < 120; i++) { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - return !!dialog; - })()`, - "waitForManagePostsDialogToDisappear", - ); + // aria-checked is the string "true"/"false"; fall back to the native checked prop. + const ariaChecked = checkbox.getAttribute('aria-checked'); + const isChecked = ariaChecked === 'true' ? true : (ariaChecked === 'false' ? false : checkbox.checked); - if (!result.success) { - return false; - } + const shouldCheck = ${shouldCheck}; - if (!result.value) { - vm.log("waitForManagePostsDialogToDisappear", "Dialog has disappeared"); + // Only click if we need to change the state + if (isChecked !== shouldCheck) { + checkbox.click(); + return true; + } return true; - } - await vm.sleep(500); - } - - vm.log( - "waitForManagePostsDialogToDisappear", - "Timeout waiting for dialog to disappear", + })()`, + "toggleSelectAllCheckbox", ); - return false; + return result.success && result.value; } /** - * Get the action description text from the dialog - * Returns text like "You can hide or delete the posts selected." or empty string + * Count the selectable items (row checkboxes, excluding the "select all" header checkbox) + * so we can report how many items a delete batch removed. */ -async function getActionDescription(vm: FacebookViewModel): Promise { - const result = await vm.safeExecuteJavaScript( +async function countSelectableItems(vm: FacebookViewModel): Promise { + const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return ""; - - // Find the actions description span - // It's nested in the structure described by the user - // We'll search for spans that contain text about deletion/hiding - const spans = dialog.querySelectorAll('span'); - for (const span of spans) { - const text = span.textContent?.trim() || ""; - if (text.startsWith("You can")) { - return text; - } + // Row selectors can be or [role=checkbox]; exclude the + // "select all" header control either way. + const candidates = document.querySelectorAll('input[type="checkbox"], [role="checkbox"]'); + let count = 0; + for (const el of candidates) { + if (el.getAttribute && el.getAttribute('name') === '${ACTIVITY_LOG_CHECKBOX_NAME}') continue; + count++; } - return ""; - })()`, - "getActionDescription", - ); - return result.success ? result.value || "" : ""; -} - -type PostAction = "delete" | "untag" | "hide"; - -const actionVerbKeys: Record = { - delete: "viewModels.facebook.jobs.actionDelete", - untag: "viewModels.facebook.jobs.actionUntag", - hide: "viewModels.facebook.jobs.actionHide", -}; - -const actionPresentKeys: Record = { - delete: "viewModels.facebook.jobs.actionDeletePresent", - untag: "viewModels.facebook.jobs.actionUntagPresent", - hide: "viewModels.facebook.jobs.actionHidePresent", -}; - -async function getCheckboxState( - vm: FacebookViewModel, - listIndex: number, - itemIndex: number, -): Promise { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return null; - - const lists = dialog.querySelectorAll('div[role="list"]'); - if (${listIndex} >= lists.length) return null; - - const list = lists[${listIndex}]; - const items = list.querySelectorAll('div[role="listitem"]'); - if (${itemIndex} >= items.length) return null; - - const item = items[${itemIndex}]; - const checkbox = item.querySelector('input[type="checkbox"]'); - const checkboxControl = item.querySelector('[role="checkbox"]'); - const ariaChecked = - checkboxControl?.getAttribute('aria-checked') ?? - checkbox?.getAttribute('aria-checked'); - - if (ariaChecked === 'true') return true; - if (ariaChecked === 'false') return false; - if (checkbox instanceof HTMLInputElement) return checkbox.checked; - - return null; + return count; })()`, - "getCheckboxState", + "countSelectableItems", ); - - return result.success ? result.value : null; + return result.success && typeof result.value === "number" ? result.value : 0; } -async function waitForCheckboxState( - vm: FacebookViewModel, - listIndex: number, - itemIndex: number, - expectedChecked: boolean, - timeoutMs: number = 5000, -): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - const checked = await getCheckboxState(vm, listIndex, itemIndex); - if (checked === expectedChecked) { - return true; - } - await vm.sleep(200); - } - - return false; -} - -async function waitForActionDescriptionStable( - vm: FacebookViewModel, - timeoutMs: number = 5000, -): Promise { - const startTime = Date.now(); - let lastDescription = ""; - - while (Date.now() - startTime < timeoutMs) { - const description = await getActionDescription(vm); - if (description !== "" && description === lastDescription) { - return description; - } - lastDescription = description; - await vm.sleep(200); - } - - return lastDescription; -} - -async function waitForBatchAction( - vm: FacebookViewModel, - expectedAction: PostAction, - timeoutMs: number = 5000, -): Promise<{ success: boolean; actionDescription: string }> { - const startTime = Date.now(); - let lastDescription = ""; - - while (Date.now() - startTime < timeoutMs) { - const actionDescription = await getActionDescription(vm); - lastDescription = actionDescription; - - if ( - getHighestPriority(parseActions(actionDescription)) === expectedAction - ) { - return { success: true, actionDescription }; - } - - await vm.sleep(200); - } - - return { success: false, actionDescription: lastDescription }; -} - -async function waitForActionOptionsDialog( +/** + * Click the delete action in the activity-log toolbar. Facebook labels this differently + * per category ("Remove", "Delete", "Trash", "Move to Trash", "Remove tags", etc.). + * The toolbar appears after items are selected, so poll for it to show up. + */ +async function clickDeletePostsOption( vm: FacebookViewModel, timeoutMs: number = 10000, ): Promise { const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; + const LABELS = ['remove', 'delete', 'trash', 'move to trash', 'remove tags', 'remove tag', 'untag']; + + // First try a proper button with a matching aria-label. + for (const el of document.querySelectorAll('[role="button"][aria-label]')) { + const label = (el.getAttribute('aria-label') || '').trim().toLowerCase(); + if (LABELS.includes(label) && el.getAttribute('aria-disabled') !== 'true') { + el.click(); + return true; + } + } - const hasActionOptions = dialog.querySelector('div[aria-disabled]'); - const hasDoneButton = dialog.querySelector('div[aria-label="Done"][role="button"]'); - return Boolean(hasActionOptions && hasDoneButton); + // Otherwise find the small element whose visible text is one of the labels. + let target = null; + for (const el of document.querySelectorAll('span, div')) { + if (el.children.length > 1) continue; // innermost text node only + const text = (el.textContent || '').trim().toLowerCase(); + if (LABELS.includes(text)) { target = el; break; } + } + if (!target) return false; + + // Click the clickable ancestor: the one that contains FB's overlay catcher. + let node = target; + for (let i = 0; i < 6 && node; i++) { + const overlay = node.querySelector && node.querySelector('[data-visualcompletion="ignore"]'); + if (overlay) { overlay.click(); node.click(); return true; } + node = node.parentElement; + } + target.click(); + return true; })()`, - "waitForActionOptionsDialog", + "clickDeletePostsOption", ); - if (result.success && result.value) { return true; } - - await vm.sleep(200); + await vm.sleep(500); } - return false; } /** - * Parse the available actions from an action description string. - * e.g. "You can hide or delete the posts selected." -> ['delete', 'hide'] - * "You can untag yourself from or hide the posts selected." -> ['untag', 'hide'] - * "You can hide the posts selected." -> ['hide'] - */ -export function parseActions(actionDescription: string): PostAction[] { - if (typeof actionDescription !== "string") { - return []; - } - - const actions: PostAction[] = []; - const text = actionDescription.toLowerCase(); - if (text.includes("delete")) actions.push("delete"); - if (text.includes("untag")) actions.push("untag"); - if (text.includes("hide")) actions.push("hide"); - return actions; -} - -/** - * Return the highest-priority action from a list. - * Priority order: delete > untag > hide - */ -export function getHighestPriority(actions: PostAction[]): PostAction | null { - if (actions.includes("delete")) return "delete"; - if (actions.includes("untag")) return "untag"; - if (actions.includes("hide")) return "hide"; - return null; -} - -/** - * Toggle a checkbox by index and return success - */ -async function toggleCheckbox( - vm: FacebookViewModel, - listIndex: number, - itemIndex: number, - shouldCheck: boolean, -): Promise { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; - - const lists = dialog.querySelectorAll('div[role="list"]'); - if (${listIndex} >= lists.length) return false; - - const list = lists[${listIndex}]; - const items = list.querySelectorAll('div[role="listitem"]'); - if (${itemIndex} >= items.length) return false; - - const item = items[${itemIndex}]; - const checkbox = item.querySelector('input[type="checkbox"]'); - const checkboxControl = item.querySelector('[role="checkbox"]'); - if (!checkbox && !checkboxControl) return false; - - const ariaChecked = - checkboxControl?.getAttribute('aria-checked') ?? - checkbox?.getAttribute('aria-checked'); - let isChecked; - - if (ariaChecked === 'true') { - isChecked = true; - } else if (ariaChecked === 'false') { - isChecked = false; - } else if (checkbox instanceof HTMLInputElement) { - isChecked = checkbox.checked; - } else { - return false; - } - - const shouldCheck = ${shouldCheck}; - const clickTarget = checkboxControl ?? checkbox; - if (!clickTarget) return false; - - // Only click if we need to change the state - if (isChecked !== shouldCheck) { - clickTarget.click(); - return true; - } - return true; - })()`, - "toggleCheckbox", - ); - return result.success && result.value; -} - -/** - * Get the total number of lists and items - */ -async function getListsAndItems( - vm: FacebookViewModel, -): Promise<{ listIndex: number; itemIndex: number }[]> { - const result = await vm.safeExecuteJavaScript< - { listIndex: number; itemIndex: number }[] - >( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return []; - - const lists = dialog.querySelectorAll('div[role="list"]'); - const result = []; - - for (let listIndex = 0; listIndex < lists.length; listIndex++) { - const list = lists[listIndex]; - const listItems = list.querySelectorAll('div[role="listitem"]'); - - for (let itemIndex = 0; itemIndex < listItems.length; itemIndex++) { - const item = listItems[itemIndex]; - const checkbox = item.querySelector('input[type="checkbox"]'); - if (checkbox) { - result.push({ listIndex, itemIndex }); - } - } - } - - return result; - })()`, - "getListsAndItems", - ); - if (!result.success || !Array.isArray(result.value)) return []; - return result.value; -} - -/** - * Click the Next button in the dialog - */ -async function clickNextButton(vm: FacebookViewModel): Promise { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; - - const nextButton = dialog.querySelector('div[aria-label="Next"][role="button"]'); - if (nextButton) { - nextButton.click(); - return true; - } - return false; - })()`, - "clickNextButton", - ); - return result.success && result.value; -} - -/** - * Select the "Delete posts" radio button in the action selection dialog - * Looks for a div with text "delete posts" (case insensitive), checks it's not disabled, - * and clicks the radio button (i tag) inside it + * Confirm the "Move to trash?" dialog that appears after clicking Trash. + * Best-effort: some flows delete without a confirmation step. */ -async function selectDeletePostsOption( - vm: FacebookViewModel, -): Promise { +async function confirmDeletion(vm: FacebookViewModel): Promise { const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); + const dialog = document.querySelector('div[role="dialog"]'); if (!dialog) return false; - - // Find all divs that might contain the delete posts option - const divs = dialog.querySelectorAll('div[aria-disabled]'); - - for (const div of divs) { - // Check if this div or its children contain text about deleting posts - const text = div.textContent?.toLowerCase() || ''; - if (text.includes('delete posts')) { - // Check that it's not disabled - if (div.getAttribute('aria-disabled') === 'false') { - // Find the radio button (i tag) inside this div - const radioButton = div.querySelector('i'); - if (radioButton) { - radioButton.click(); - return true; - } - } else { - console.log('Delete posts option is disabled'); - return false; - } + const CONFIRM_LABELS = ['delete', 'move to trash', 'confirm', 'remove', 'remove tags', 'remove tag', 'untag']; + const buttons = dialog.querySelectorAll('div[role="button"], button'); + for (const button of buttons) { + const label = (button.getAttribute('aria-label') || button.textContent || '').trim().toLowerCase(); + if (CONFIRM_LABELS.includes(label)) { + if (button.getAttribute('aria-disabled') === 'true') return false; + button.click(); + return true; } } - - console.log('Could not find delete posts option'); return false; })()`, - "selectDeletePostsOption", + "confirmDeletion", ); return result.success && result.value; } /** - * Select the "Untag yourself" radio button in the action selection dialog + * Detect Facebook's "We're still processing the previous changes / Try again later" modal, + * which appears when we attempt to delete again too soon. */ -async function selectUntagPostsOption(vm: FacebookViewModel): Promise { +async function isStillProcessing(vm: FacebookViewModel): Promise { const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; - - const divs = dialog.querySelectorAll('div[aria-disabled]'); - - for (const div of divs) { - const text = div.textContent?.toLowerCase() || ''; - if (text.includes('untag') || text.includes('remove tags')) { - if (div.getAttribute('aria-disabled') === 'false') { - const radioButton = div.querySelector('i'); - if (radioButton) { - radioButton.click(); - return true; - } - } else { - console.log('Untag option is disabled'); - return false; - } - } - } - - console.log('Could not find untag option'); - return false; + const text = (document.body.innerText || '').toLowerCase(); + return text.includes('still processing the previous changes') || + text.includes('try again later'); })()`, - "selectUntagPostsOption", + "isStillProcessing", ); return result.success && result.value; } /** - * Select the "Hide posts" radio button in the action selection dialog + * Dismiss a modal by clicking its OK/Close button. */ -async function selectHidePostsOption(vm: FacebookViewModel): Promise { - const result = await vm.safeExecuteJavaScript( +async function dismissModal(vm: FacebookViewModel): Promise { + await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); + const dialog = document.querySelector('div[role="dialog"]'); if (!dialog) return false; - - const divs = dialog.querySelectorAll('div[aria-disabled]'); - - for (const div of divs) { - const text = div.textContent?.toLowerCase() || ''; - if (text.includes('hide')) { - if (div.getAttribute('aria-disabled') === 'false') { - const radioButton = div.querySelector('i'); - if (radioButton) { - radioButton.click(); - return true; - } - } else { - console.log('Hide option is disabled'); - return false; - } + const buttons = dialog.querySelectorAll('div[role="button"], button'); + for (const button of buttons) { + const label = (button.getAttribute('aria-label') || button.textContent || '').trim().toLowerCase(); + if (['ok', 'okay', 'close', 'got it', 'dismiss'].includes(label)) { + button.click(); + return true; } } - - console.log('Could not find hide option'); return false; })()`, - "selectHidePostsOption", + "dismissModal", ); - return result.success && result.value; } /** - * Click the Done button in the dialog + * Wait for a delete batch to be applied: the select-all checkbox clears or disappears. */ -async function clickDoneButton(vm: FacebookViewModel): Promise { - const result = await vm.safeExecuteJavaScript( - `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; - - const doneButton = dialog.querySelector('div[aria-label="Done"][role="button"]'); - if (doneButton) { - doneButton.click(); - return true; - } - return false; - })()`, - "clickDoneButton", - ); - return result.success && result.value; +async function waitForBatchToComplete( + vm: FacebookViewModel, + timeoutMs: number = 30000, +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + const settled = await vm.safeExecuteJavaScript( + `(() => { + const checkbox = document.querySelector('input[name="${ACTIVITY_LOG_CHECKBOX_NAME}"]'); + if (!checkbox) return true; // no items left + const ariaChecked = checkbox.getAttribute('aria-checked'); + const isChecked = ariaChecked === 'true' ? true : (ariaChecked === 'false' ? false : checkbox.checked); + return !isChecked; // selection cleared => batch applied + })()`, + "waitForBatchToComplete", + ); + if (settled.success && settled.value) { + return true; + } + await vm.sleep(500); + } + return false; } -export async function runJobDeleteWallPosts( +async function loadActivityLog( vm: FacebookViewModel, - jobIndex: number, + categoryKey: string, ): Promise { - vm.runJobsState = RunJobsState.DeleteWallPosts; - - vm.showBrowser = true; - vm.showAutomationNotice = true; - vm.instructions = vm.t("viewModels.facebook.jobs.removingWallPosts"); + if (vm.account.facebookAccount) { + vm.log( + "loadActivityLog", + `Loading activity log for category key: ${categoryKey}`, + ); - vm.log("runJobDeleteWallPosts", "Loading profile page"); + const FACEBOOK_ACTIVITY_LOG_URL = `https://www.facebook.com/${vm.account.facebookAccount.accountID}/\ +allactivity?activity_history=false&category_key=${categoryKey}\ +&manage_mode=true&should_load_landing_page=false`; - await vm.waitForPause(); + await vm.loadURL(FACEBOOK_ACTIVITY_LOG_URL); + await vm.waitForLoadingToFinish(); - // Load the user's profile page - await vm.loadURL(FACEBOOK_PROFILE_URL); - await vm.waitForLoadingToFinish(); + await vm.waitForPause(); + } +} - await vm.waitForPause(); +/** + * Delete every item in a single activity-log category, batch by batch, until none remain. + * Returns the number of items deleted, or null if the job errored. + */ +async function deleteCategory( + vm: FacebookViewModel, + jobIndex: number, + category: FacebookDeleteCategory, +): Promise { + await loadActivityLog(vm, category.categoryKey); - // Keep deleting posts until there are no more to delete - let totalDeleted = 0; - let totalUntagged = 0; - let totalHidden = 0; - let batchNumber = 0; - const maxToCheck = 10; + let processingRetries = 0; + // Keep deleting until there are no more items to delete while (true) { // Check for rate limits await checkRateLimit(vm); - - batchNumber++; - vm.log("runJobDeleteWallPosts", `Starting batch ${batchNumber}`); - - vm.log("runJobDeleteWallPosts", "Clicking Manage posts button"); - - // Click the Manage posts button - // safeExecuteJavaScript handles webview validation and errors - const buttonClicked = await clickManagePostsButton(vm); - if (!buttonClicked) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_ClickManagePostsFailed, - { - batchNumber, - message: "Failed to click Manage posts button", - }, - ); - return; - } - await vm.waitForPause(); - // Wait for the dialog to open - const dialogOpened = await waitForManagePostsDialog(vm); - if (!dialogOpened) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_DialogNotFound, - { - batchNumber, - message: "Manage posts dialog did not appear", - }, - ); - return; - } - - vm.log("runJobDeleteWallPosts", "Dialog opened, waiting for posts to load"); - - await vm.waitForPause(); - - // Wait for items to appear in the dialog (with 30 second timeout) - // On slow connections, the dialog content may take time to load - let allItems: { listIndex: number; itemIndex: number }[] = []; - const maxWaitTime = 30000; // 30 seconds - const pollInterval = 500; // Check every 500ms - const startTime = Date.now(); - - while (Date.now() - startTime < maxWaitTime) { - allItems = await getListsAndItems(vm); - if (allItems.length > 0) { - vm.log( - "runJobDeleteWallPosts", - `Found ${allItems.length} items after ${Date.now() - startTime}ms`, + // Facebook may still be processing the previous batch; back off and retry. + if (await isStillProcessing(vm)) { + processingRetries++; + if (processingRetries > MAX_PROCESSING_RETRIES) { + await reportDeleteWallPostsError( + vm, + jobIndex, + AutomationErrorType.facebook_runJob_deleteWallPosts_CompletionTimeout, + { + category: category.setting, + message: "Facebook kept reporting it was still processing", + }, ); - break; + return null; } - await vm.sleep(pollInterval); - } - - if (allItems.length === 0) { vm.log( - "runJobDeleteWallPosts", - `No items found after ${maxWaitTime}ms timeout, proceeding anyway`, + "deleteCategory", + `Facebook still processing previous changes; backing off (retry ${processingRetries})`, ); + await dismissModal(vm); + await vm.sleep(PROCESSING_BACKOFF_MS); + await loadActivityLog(vm, category.categoryKey); + continue; } + processingRetries = 0; - vm.log( - "runJobDeleteWallPosts", - `Found ${allItems.length} items with checkboxes`, - ); - - let checkedCount = 0; - const batchActions: PostAction[] = ["delete", "untag", "hide"]; // Check all actions in priority order - let batchAction: PostAction = "delete"; - - // loop through different actions - for (const action of batchActions) { - batchAction = action; - vm.instructions = vm.t( - "viewModels.facebook.jobs.checkBatchActionWallPosts", - { - action: vm.t(actionVerbKeys[batchAction]), - }, - ); - // Loop through items, checking if any item match the current batchAction priority action. - // Stop when adding a new item would reduce the priority (e.g. from delete -> hide). - for (const { listIndex, itemIndex } of allItems) { - // Check for rate limits - await checkRateLimit(vm); - - if (checkedCount >= maxToCheck) { - vm.log( - "runJobDeleteWallPosts", - `Reached maximum of ${maxToCheck} items`, - ); - break; - } - - await vm.waitForPause(); - - // Check this checkbox - const toggled = await toggleCheckbox(vm, listIndex, itemIndex, true); - if (!toggled) { - vm.log( - "runJobDeleteWallPosts", - `Failed to check item [${listIndex}][${itemIndex}]`, - ); - continue; - } - - const checkboxChecked = await waitForCheckboxState( - vm, - listIndex, - itemIndex, - true, - ); - if (!checkboxChecked) { - vm.log( - "runJobDeleteWallPosts", - `Timed out waiting for item [${listIndex}][${itemIndex}] to become checked`, - ); - continue; - } - - // Read the combined action description (reflects all currently-checked items) - const actionDescription = await waitForActionDescriptionStable(vm); - vm.log( - "runJobDeleteWallPosts", - `Action description: "${actionDescription}"`, - ); - - const combinedPriority = getHighestPriority( - parseActions(actionDescription), - ); - - if (combinedPriority === null) { - // Unrecognized description, skip this item - vm.log( - "runJobDeleteWallPosts", - `Item [${listIndex}][${itemIndex}] has unrecognized action description, unchecking`, - ); - await toggleCheckbox(vm, listIndex, itemIndex, false); - await waitForCheckboxState(vm, listIndex, itemIndex, false); - continue; - } else if (combinedPriority === batchAction) { - // Same priority: keep this item checked and continue - checkedCount++; - vm.log( - "runJobDeleteWallPosts", - `Item keeps batch action "${batchAction}", checked ${checkedCount}/${maxToCheck}`, - ); - } else { - // Adding this item changes the priority — uncheck it and go to next item - vm.log( - "runJobDeleteWallPosts", - `Item [${listIndex}][${itemIndex}] changes priority from "${batchAction}" to "${combinedPriority}", unchecking`, - ); - await toggleCheckbox(vm, listIndex, itemIndex, false); - const checkboxUnchecked = await waitForCheckboxState( - vm, - listIndex, - itemIndex, - false, - ); - if (!checkboxUnchecked) { - vm.log( - "runJobDeleteWallPosts", - `Timed out waiting for item [${listIndex}][${itemIndex}] to become unchecked`, - ); - } - - const batchActionRestored = await waitForBatchAction(vm, batchAction); - if (!batchActionRestored.success && checkedCount !== 0) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, - { - batchNumber, - message: `Batch action did not return to "${batchAction}" after unchecking item [${listIndex}][${itemIndex}]`, - actionDescription: batchActionRestored.actionDescription, - }, - ); - return; - } - continue; - } - } - + // If there are no items to delete, we're done with this category. Facebook may + // still render the "select all" checkbox when empty, so gate on the item count + // rather than the checkbox's presence. + const batchCount = await countSelectableItems(vm); + if (batchCount === 0) { vm.log( - "runJobDeleteWallPosts", - `Selected ${checkedCount} items for action "${batchAction}"`, + "deleteCategory", + `No more items for category ${category.setting}`, ); - - if (checkedCount !== 0) { - // If actionable items found, no need to loop through other actions - vm.instructions = vm.t( - "viewModels.facebook.jobs.removeActionWallPosts", - { - action: vm.t(actionPresentKeys[batchAction]), - count: checkedCount, - }, - ); - break; - } - - // If nothing was checked, see if more items get selected by next priority action in the list - if (batchAction !== "hide") { - vm.log( - "runJobDeleteWallPosts", - `No actionable items found for action "${batchAction}", checking next priority action`, - ); - } + break; } - if (checkedCount === 0 && batchAction === "hide") { - // If the current action is hide and still checked item is 0, means all priority actions have - // been checked and nothing left to do. - vm.log("runJobDeleteWallPosts", "No actionable items found, finishing"); + // Select all currently loaded items + const toggled = await toggleSelectAllCheckbox(vm, true); + if (!toggled) { + vm.log( + "deleteCategory", + `Could not select items for category ${category.setting}`, + ); break; } await vm.waitForPause(); - const batchActionReady = await waitForBatchAction(vm, batchAction); - if (!batchActionReady.success) { + // Click on trash + const deletedBtnClicked = await clickDeletePostsOption(vm); + if (!deletedBtnClicked) { + vm.log("deleteCategory", `Failed to click "Trash" button`); await reportDeleteWallPostsError( vm, jobIndex, AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, - { - batchNumber, - message: `Action description did not settle on "${batchAction}" before clicking Next`, - actionDescription: batchActionReady.actionDescription, - }, + { category: category.setting, message: "Failed to click Trash button" }, ); - return; + return null; } - // Click the Next button - vm.log("runJobDeleteWallPosts", "Clicking Next button"); - const nextClicked = await clickNextButton(vm); - if (!nextClicked) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_ClickNextFailed, - { - batchNumber, - message: "Failed to click Next button", - }, - ); - return; - } + // Confirm the deletion dialog if one appears + await vm.sleep(1000); + await confirmDeletion(vm); - // Wait for the dialog to update with the action options - const actionOptionsReady = await waitForActionOptionsDialog(vm); - if (!actionOptionsReady) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_DialogNotFound, - { - batchNumber, - message: "Action options did not appear after clicking Next", - }, + // If Facebook says it's still processing, don't count this batch; back off and retry. + if (await isStillProcessing(vm)) { + vm.log( + "deleteCategory", + "Facebook still processing after confirm; backing off", ); - return; + await dismissModal(vm); + await vm.sleep(PROCESSING_BACKOFF_MS); + await loadActivityLog(vm, category.categoryKey); + continue; } - await vm.waitForPause(); - - // Select the appropriate action radio button - vm.log("runJobDeleteWallPosts", `Selecting "${batchAction}" option`); - let actionSelected = false; - let actionErrorType = - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed; - let actionErrorMessage = "Failed to select delete posts option"; - - if (batchAction === "delete") { - actionSelected = await selectDeletePostsOption(vm); - actionErrorType = - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed; - actionErrorMessage = "Failed to select delete posts option"; - } else if (batchAction === "untag") { - actionSelected = await selectUntagPostsOption(vm); - actionErrorType = - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectUntagOptionFailed; - actionErrorMessage = "Failed to select untag posts option"; - } else { - // hide - actionSelected = await selectHidePostsOption(vm); - actionErrorType = - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectHideOptionFailed; - actionErrorMessage = "Failed to select hide posts option"; - } - - if (!actionSelected) { - await reportDeleteWallPostsError(vm, jobIndex, actionErrorType, { - batchNumber, - message: actionErrorMessage, - }); - return; - } - - vm.log("runJobDeleteWallPosts", `"${batchAction}" option selected`); - - await vm.waitForPause(); - - // Click the Done button - vm.log("runJobDeleteWallPosts", "Clicking Done button"); - const doneClicked = await clickDoneButton(vm); - if (!doneClicked) { + const completed = await waitForBatchToComplete(vm); + if (!completed) { await reportDeleteWallPostsError( vm, jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_ClickDoneFailed, - { - batchNumber, - message: "Failed to click Done button", - }, + AutomationErrorType.facebook_runJob_deleteWallPosts_CompletionTimeout, + { category: category.setting, message: "Batch did not complete" }, ); - return; + return null; } - vm.log("runJobDeleteWallPosts", "Done button clicked"); + // Record progress for this batch + vm.progress[category.counter] += batchCount; + await Helpers.incrementCumulativeTotal(vm, category.counter, batchCount); + vm.emitter?.emit(`facebook-submit-progress-${vm.account.id}`); - await vm.waitForPause(); + // Cool down before the next batch to avoid tripping Facebook's throttle. + await vm.sleep(BATCH_COOLDOWN_MS); + } - // Wait for the dialog to disappear (indicates deletion is complete) - vm.log("runJobDeleteWallPosts", "Waiting for deletion to complete..."); - const dialogDisappeared = await waitForManagePostsDialogToDisappear(vm); - if (!dialogDisappeared) { - vm.log( - "runJobDeleteWallPosts", - "Timeout waiting for dialog to disappear", - ); - // Continue anyway - the deletion might have worked - } else { - vm.log("runJobDeleteWallPosts", "Deletion completed successfully"); - } + return vm.progress[category.counter]; +} - // Update progress - if (batchAction === "delete") { - totalDeleted += checkedCount; - vm.progress.wallPostsDeleted = totalDeleted; - } else if (batchAction === "untag") { - totalUntagged += checkedCount; - vm.progress.wallPostsUntagged = totalUntagged; - } else { - totalHidden += checkedCount; - vm.progress.wallPostsHidden = totalHidden; - } - vm.log( - "runJobDeleteWallPosts", - `Batch ${batchNumber} complete: ${batchAction} ${checkedCount} posts (deleted: ${totalDeleted}, untagged: ${totalUntagged}, hidden: ${totalHidden})`, - ); +export async function runJobDeleteActivity( + vm: FacebookViewModel, + jobIndex: number, +): Promise { + vm.runJobsState = RunJobsState.DeleteActivity; - // Update the persistent counter in the database - if (batchAction === "delete") { - await window.electron.Facebook.incrementTotalWallPostsDeleted( - vm.account.id, - checkedCount, - ); - } else if (batchAction === "untag") { - await window.electron.Facebook.incrementTotalWallPostsUntagged( - vm.account.id, - checkedCount, - ); - } else { - await window.electron.Facebook.incrementTotalWallPostsHidden( - vm.account.id, - checkedCount, - ); - } + vm.showBrowser = true; + vm.showAutomationNotice = true; + vm.progress.isDeleteActivityFinished = false; - // Submit progress to the API - vm.emitter?.emit(`facebook-submit-progress-${vm.account.id}`); + // Delete each data category the user selected. Every category uses the identical + // activity-log flow and differs only by its category_key. + const categories = vm.account.facebookAccount + ? selectedDeleteCategories(vm.account.facebookAccount) + : []; + for (const category of categories) { await vm.waitForPause(); - // Give Facebook a few seconds before refreshing - // It seems that this helps - await vm.sleep(3000); + vm.progress.currentCategory = category.setting; + vm.instructions = vm.t("viewModels.facebook.jobs.deletingCategory", { + category: vm.t(category.labelKey), + }); - // Reload the profile page to see any newly available posts - vm.log("runJobDeleteWallPosts", "Reloading profile page for next batch"); - vm.instructions = vm.t("viewModels.facebook.jobs.managePostsLoading"); - await vm.loadURL(FACEBOOK_PROFILE_URL); - await vm.waitForLoadingToFinish(); + const deleted = await deleteCategory(vm, jobIndex, category); + if (deleted === null) { + // deleteCategory already marked the job as errored + return; + } } - vm.log( - "runJobDeleteWallPosts", - `All done! Total posts deleted: ${totalDeleted}`, - ); + vm.progress.currentCategory = ""; + vm.progress.isDeleteActivityFinished = true; + vm.log("runJobDeleteActivity", "All done!"); await vm.waitForPause(); - // Always submit final progress to the API (even if 0 posts were deleted) + // Always submit final progress to the API (even if 0 items were deleted) vm.emitter?.emit(`facebook-submit-progress-${vm.account.id}`); await Helpers.finishJob(vm, jobIndex); diff --git a/src/renderer/src/view_models/FacebookViewModel/types.ts b/src/renderer/src/view_models/FacebookViewModel/types.ts index 8edae668..70643719 100644 --- a/src/renderer/src/view_models/FacebookViewModel/types.ts +++ b/src/renderer/src/view_models/FacebookViewModel/types.ts @@ -8,7 +8,7 @@ export enum RunJobsState { Default = "", SaveUserLang = "SaveUserLang", SetLangToEnglish = "SetLangToEnglish", - DeleteWallPosts = "DeleteWallPosts", + DeleteActivity = "DeleteActivity", RestoreUserLang = "RestoreUserLang", } @@ -17,7 +17,7 @@ export type FacebookJobType = | "login" | "saveUserLang" | "setLangToEnglish" - | "deleteWallPosts" + | "deleteActivity" | "restoreUserLang"; // Facebook job @@ -31,22 +31,40 @@ export type FacebookJob = { error: string | null; }; -// Facebook progress tracking +// Facebook progress tracking. export type FacebookProgress = { currentJob: string; + // The category currently being processed by the deleteActivity job (a category + // `setting` id, e.g. "deleteComments"), or "" when not deleting. + currentCategory: string; wallPostsDeleted: number; wallPostsUntagged: number; wallPostsHidden: number; - isDeleteWallPostsFinished: boolean; + commentsDeleted: number; + reactionsDeleted: number; + postsOnOthersDeleted: number; + othersPostsDeleted: number; + checkinsDeleted: number; + taggedPostsDeleted: number; + taggedMediaDeleted: number; + isDeleteActivityFinished: boolean; }; export function emptyFacebookProgress(): FacebookProgress { return { currentJob: "", + currentCategory: "", wallPostsDeleted: 0, wallPostsUntagged: 0, wallPostsHidden: 0, - isDeleteWallPostsFinished: false, + commentsDeleted: 0, + reactionsDeleted: 0, + postsOnOthersDeleted: 0, + othersPostsDeleted: 0, + checkinsDeleted: 0, + taggedPostsDeleted: 0, + taggedMediaDeleted: 0, + isDeleteActivityFinished: false, }; } 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 5c790caa..418f12e9 100644 --- a/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/view_model.test.ts @@ -124,7 +124,7 @@ describe("FacebookViewModel", () => { const vm = createMockFacebookViewModel(); expect(vm.progress.wallPostsDeleted).toBe(0); - expect(vm.progress.isDeleteWallPostsFinished).toBe(false); + expect(vm.progress.isDeleteActivityFinished).toBe(false); expect(vm.progress.currentJob).toBe(""); }); @@ -190,7 +190,7 @@ describe("FacebookViewModel", () => { const jobTypes = vm.jobs.map((j) => j.jobType); expect(jobTypes).toContain("saveUserLang"); expect(jobTypes).toContain("setLangToEnglish"); - expect(jobTypes).toContain("deleteWallPosts"); + expect(jobTypes).toContain("deleteActivity"); expect(jobTypes).toContain("restoreUserLang"); }); @@ -205,7 +205,7 @@ describe("FacebookViewModel", () => { "login", "saveUserLang", "setLangToEnglish", - "deleteWallPosts", + "deleteActivity", "restoreUserLang", ]); }); @@ -261,17 +261,17 @@ describe("FacebookViewModel", () => { it("resets progress to empty state", async () => { const vm = createMockFacebookViewModel(); vm.progress.wallPostsDeleted = 100; - vm.progress.isDeleteWallPostsFinished = true; + vm.progress.isDeleteActivityFinished = true; await vm.reset(); expect(vm.progress.wallPostsDeleted).toBe(0); - expect(vm.progress.isDeleteWallPostsFinished).toBe(false); + expect(vm.progress.isDeleteActivityFinished).toBe(false); }); it("clears jobs array", async () => { const vm = createMockFacebookViewModel(); - vm.jobs = [createMockJob("login"), createMockJob("deleteWallPosts")]; + vm.jobs = [createMockJob("login"), createMockJob("deleteActivity")]; await vm.reset(); @@ -296,7 +296,7 @@ describe("FacebookViewModel", () => { vm.action = "testAction"; vm.actionString = "Test action string"; vm.progress.wallPostsDeleted = 50; - vm.jobs = [createMockJob("login"), createMockJob("deleteWallPosts")]; + vm.jobs = [createMockJob("login"), createMockJob("deleteActivity")]; vm.currentJobIndex = 1; const savedState = vm.saveState(); @@ -323,15 +323,15 @@ describe("FacebookViewModel", () => { it("preserves all progress fields", () => { const vm = createMockFacebookViewModel(); - vm.progress.currentJob = "deleteWallPosts"; + vm.progress.currentJob = "deleteActivity"; vm.progress.wallPostsDeleted = 123; - vm.progress.isDeleteWallPostsFinished = true; + vm.progress.isDeleteActivityFinished = true; const savedState = vm.saveState(); - expect(savedState.progress.currentJob).toBe("deleteWallPosts"); + expect(savedState.progress.currentJob).toBe("deleteActivity"); expect(savedState.progress.wallPostsDeleted).toBe(123); - expect(savedState.progress.isDeleteWallPostsFinished).toBe(true); + expect(savedState.progress.isDeleteActivityFinished).toBe(true); }); }); @@ -465,7 +465,7 @@ describe("FacebookViewModel", () => { expect(progress.wallPostsDeleted).toBe(0); expect(progress.wallPostsUntagged).toBe(0); expect(progress.wallPostsHidden).toBe(0); - expect(progress.isDeleteWallPostsFinished).toBe(false); + expect(progress.isDeleteActivityFinished).toBe(false); }); it("returns a new object each time", () => { diff --git a/src/renderer/src/view_models/FacebookViewModel/view_model.ts b/src/renderer/src/view_models/FacebookViewModel/view_model.ts index 06025664..b2360d6b 100644 --- a/src/renderer/src/view_models/FacebookViewModel/view_model.ts +++ b/src/renderer/src/view_models/FacebookViewModel/view_model.ts @@ -21,6 +21,7 @@ import { import * as AuthOps from "./auth"; import * as DeleteJobs from "./jobs_delete"; import * as LangJobs from "./jobs_lang"; +import { selectedDeleteCategories } from "./categories"; interface CurrentUserInitialData { ACCOUNT_ID: string; @@ -139,16 +140,19 @@ export class FacebookViewModel extends BaseViewModel { // Always login first jobTypes.push("login"); - // Add delete jobs if enabled - if (this.account.facebookAccount?.deleteWallPosts) { + // Add deleteActivity job if the user wants at least one data category deleted + const hasDeleteCategories = + this.account.facebookAccount && + selectedDeleteCategories(this.account.facebookAccount).length > 0; + if (hasDeleteCategories) { // Language jobs wrap around delete jobs: // 1. Save user's current language // 2. Set language to English (needed for automation) - // 3. Delete wall posts + // 3. Delete the selected activity categories // 4. Restore user's original language jobTypes.push("saveUserLang"); jobTypes.push("setLangToEnglish"); - jobTypes.push("deleteWallPosts"); + jobTypes.push("deleteActivity"); jobTypes.push("restoreUserLang"); } @@ -319,8 +323,8 @@ export class FacebookViewModel extends BaseViewModel { await LangJobs.runJobSetLangToEnglish(this, jobIndex); break; - case "deleteWallPosts": - await DeleteJobs.runJobDeleteWallPosts(this, jobIndex); + case "deleteActivity": + await DeleteJobs.runJobDeleteActivity(this, jobIndex); break; case "restoreUserLang": diff --git a/src/renderer/src/views/facebook/components/FacebookProgressComponent.test.ts b/src/renderer/src/views/facebook/components/FacebookProgressComponent.test.ts index 7aa090f4..f737bee6 100644 --- a/src/renderer/src/views/facebook/components/FacebookProgressComponent.test.ts +++ b/src/renderer/src/views/facebook/components/FacebookProgressComponent.test.ts @@ -20,10 +20,18 @@ function createMockProgress( ): FacebookProgress { return { currentJob: "", + currentCategory: "", wallPostsDeleted: 0, wallPostsUntagged: 0, wallPostsHidden: 0, - isDeleteWallPostsFinished: false, + commentsDeleted: 0, + reactionsDeleted: 0, + postsOnOthersDeleted: 0, + othersPostsDeleted: 0, + checkinsDeleted: 0, + taggedPostsDeleted: 0, + taggedMediaDeleted: 0, + isDeleteActivityFinished: false, ...overrides, }; } @@ -53,7 +61,7 @@ describe("FacebookProgressComponent", () => { plugins: [i18n], }, props: { - progress: createMockProgress({ currentJob: "deleteWallPosts" }), + progress: createMockProgress({ currentJob: "deleteActivity" }), }, }); @@ -69,7 +77,7 @@ describe("FacebookProgressComponent", () => { }, props: { progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 42, }), }, @@ -85,7 +93,7 @@ describe("FacebookProgressComponent", () => { }, props: { progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 1234567, }), }, @@ -102,9 +110,9 @@ describe("FacebookProgressComponent", () => { }, props: { progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 100, - isDeleteWallPostsFinished: true, + isDeleteActivityFinished: true, }), }, }); @@ -119,9 +127,9 @@ describe("FacebookProgressComponent", () => { }, props: { progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 50, - isDeleteWallPostsFinished: false, + isDeleteActivityFinished: false, }), }, }); @@ -172,7 +180,7 @@ describe("FacebookProgressComponent", () => { }, props: { progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 10, }), }, @@ -182,7 +190,7 @@ describe("FacebookProgressComponent", () => { await wrapper.setProps({ progress: createMockProgress({ - currentJob: "deleteWallPosts", + currentJob: "deleteActivity", wallPostsDeleted: 25, }), }); diff --git a/src/renderer/src/views/facebook/components/FacebookProgressComponent.vue b/src/renderer/src/views/facebook/components/FacebookProgressComponent.vue index 415b540d..a4ea2c56 100644 --- a/src/renderer/src/views/facebook/components/FacebookProgressComponent.vue +++ b/src/renderer/src/views/facebook/components/FacebookProgressComponent.vue @@ -1,8 +1,8 @@