From 1407b2b45368af29332f361bea624196290effbc Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 5 Apr 2026 16:15:17 -0400 Subject: [PATCH 1/2] facebook: rewrite main deletion loop this commit: * adds additional automation error types * parses available action types, add priority ordering * rewrites the main item checking loop * test coverage for new logic for facebook priority * i18n: update strings for facebook priority changes --- src/renderer/src/automation_errors.ts | 6 + src/renderer/src/i18n/locales/en.json | 6 +- .../FacebookViewModel/jobs_delete.test.ts | 142 ++++++++++++- .../FacebookViewModel/jobs_delete.ts | 192 +++++++++++++++--- 4 files changed, 312 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/automation_errors.ts b/src/renderer/src/automation_errors.ts index 95f752c0..5c5626d7 100644 --- a/src/renderer/src/automation_errors.ts +++ b/src/renderer/src/automation_errors.ts @@ -91,6 +91,8 @@ export enum AutomationErrorType { facebook_runJob_deleteWallPosts_ClickNextFailed = "facebook_runJob_deleteWallPosts_ClickNextFailed", facebook_runJob_deleteWallPosts_DialogNotFound = "facebook_runJob_deleteWallPosts_DialogNotFound", facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed = "facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed", + facebook_runJob_deleteWallPosts_SelectUntagOptionFailed = "facebook_runJob_deleteWallPosts_SelectUntagOptionFailed", + facebook_runJob_deleteWallPosts_SelectHideOptionFailed = "facebook_runJob_deleteWallPosts_SelectHideOptionFailed", facebook_runJob_deleteWallPosts_ClickDoneFailed = "facebook_runJob_deleteWallPosts_ClickDoneFailed", facebook_runJob_deleteWallPosts_CompletionTimeout = "facebook_runJob_deleteWallPosts_CompletionTimeout", } @@ -269,6 +271,10 @@ export const AutomationErrorTypeToMessage = { "Failed to open the Manage posts dialog on Facebook", [AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed]: "Failed to select the delete posts option on Facebook", + [AutomationErrorType.facebook_runJob_deleteWallPosts_SelectUntagOptionFailed]: + "Failed to select the untag posts option on Facebook", + [AutomationErrorType.facebook_runJob_deleteWallPosts_SelectHideOptionFailed]: + "Failed to select the hide posts option on Facebook", [AutomationErrorType.facebook_runJob_deleteWallPosts_ClickDoneFailed]: "Failed to click Done while deleting Facebook posts", [AutomationErrorType.facebook_runJob_deleteWallPosts_CompletionTimeout]: diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d71bbbe1..69b64c12 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -421,7 +421,7 @@ }, "finished": { "title": "Jobs Completed", - "wallPosts": "wall posts" + "wallPosts": "wall posts removed (deleted, untagged, or hidden)" }, "premium": { "readyToDelete": "You're all set! Let's continue to configure what you want to delete.", @@ -620,10 +620,10 @@ "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.", - "deletingWallPosts": "# I'm deleting all posts from your Facebook wall." + "deletingWallPosts": "# I'm removing all posts from your Facebook wall." }, "progress": { - "wallPostsDeleted": "Deleted {count} wall posts." + "wallPostsDeleted": "Removed {count} wall posts." } } } 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 5f134427..72de5659 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts @@ -16,6 +16,7 @@ import { mockElectronAPI, } from "../../test_util"; import * as DeleteJobs from "./jobs_delete"; +import { parseActions, getHighestPriority } from "./jobs_delete"; /** * Creates a mock FacebookJob for testing @@ -195,7 +196,7 @@ describe("FacebookViewModel Delete Jobs", () => { expect(vm.log).toHaveBeenCalledWith( "runJobDeleteWallPosts", - "No deletable items found, finishing", + "No actionable items found, finishing", ); }); @@ -339,5 +340,144 @@ describe("FacebookViewModel Delete Jobs", () => { 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 callCount = 0; + vi.mocked(mockWebview.executeJavaScript).mockImplementation(async () => { + callCount++; + // 1. clickManagePostsButton + if (callCount === 1) return true; + // 2. waitForManagePostsDialog + if (callCount === 2) return true; + // 3. getListsAndItems - two items + if (callCount === 3) + return [ + { listIndex: 0, itemIndex: 0 }, + { listIndex: 0, itemIndex: 1 }, + ]; + // 4. toggleCheckbox item 0 (check) + if (callCount === 4) return true; + // 5. getActionDescription after item 0 — supports delete + if (callCount === 5) + return "You can hide or delete the posts selected."; + // 6. toggleCheckbox item 1 (check) + if (callCount === 6) return true; + // 7. getActionDescription after item 0+1 — combined only supports hide + if (callCount === 7) return "You can hide the posts selected."; + // 8. toggleCheckbox item 1 (uncheck) + if (callCount === 8) return true; + // 9. clickNextButton + if (callCount === 9) return true; + // 10. selectDeletePostsOption + if (callCount === 10) return true; + // 11. clickDoneButton + if (callCount === 11) return true; + // 12. waitForManagePostsDialogToDisappear - dialog gone + if (callCount === 12) return false; + // 13. Second batch: clickManagePostsButton fails -> exit + if (callCount === 13) return false; + + return false; + }); + + await DeleteJobs.runJobDeleteWallPosts(vm, 3); + + expect(vm.log).toHaveBeenCalledWith( + "runJobDeleteWallPosts", + expect.stringContaining('changes priority from "delete" to "hide"'), + ); + 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 callCount = 0; + vi.mocked(mockWebview.executeJavaScript).mockImplementation(async () => { + callCount++; + // 1. clickManagePostsButton + if (callCount === 1) return true; + // 2. waitForManagePostsDialog + if (callCount === 2) return true; + // 3. getListsAndItems - one item + if (callCount === 3) return [{ listIndex: 0, itemIndex: 0 }]; + // 4. toggleCheckbox item 0 (check) + if (callCount === 4) return true; + // 5. getActionDescription — untag+hide available + if (callCount === 5) + return "You can untag yourself from or hide the posts selected."; + // 6. clickNextButton + if (callCount === 6) return true; + // 7. selectUntagPostsOption + if (callCount === 7) return true; + // 8. clickDoneButton + if (callCount === 8) return true; + // 9. waitForManagePostsDialogToDisappear - dialog gone + if (callCount === 9) return false; + // 10. Second batch: clickManagePostsButton fails -> exit + if (callCount === 10) return false; + + return false; + }); + + await DeleteJobs.runJobDeleteWallPosts(vm, 3); + + expect(vm.log).toHaveBeenCalledWith( + "runJobDeleteWallPosts", + 'First item sets batch action to "untag", checked 1/10', + ); + expect(vm.progress.wallPostsDeleted).toBe(1); + }); + }); + + 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(); + }); }); }); diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts index f346fee2..5f531a98 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts @@ -128,14 +128,32 @@ async function getActionDescription(vm: FacebookViewModel): Promise { return result.success ? result.value || "" : ""; } +type PostAction = "delete" | "untag" | "hide"; + /** - * Check if the action description allows deletion + * 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'] */ -function canDelete(actionDescription: string): boolean { - return ( - actionDescription.startsWith("You can") && - actionDescription.toLowerCase().includes("delete") - ); +export function parseActions(actionDescription: string): PostAction[] { + 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; } /** @@ -211,7 +229,8 @@ async function getListsAndItems( })()`, "getListsAndItems", ); - return result.success ? result.value : []; + if (!result.success || !Array.isArray(result.value)) return []; + return result.value; } /** @@ -278,6 +297,76 @@ async function selectDeletePostsOption( return result.success && result.value; } +/** + * Select the "Untag yourself" radio button in the action selection dialog + */ +async function selectUntagPostsOption(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')) { + 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; + })()`, + "selectUntagPostsOption", + ); + return result.success && result.value; +} + +/** + * Select the "Hide posts" radio button in the action selection dialog + */ +async function selectHidePostsOption(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('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; + } + } + } + + console.log('Could not find hide option'); + return false; + })()`, + "selectHidePostsOption", + ); + return result.success && result.value; +} + /** * Click the Done button in the dialog */ @@ -402,8 +491,11 @@ export async function runJobDeleteWallPosts( ); let checkedCount = 0; + let batchAction: PostAction | null = null; - // Loop through items and check those that can be deleted + // Loop through items, checking each one. Track the highest-priority action + // available for all checked items. 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); @@ -431,39 +523,62 @@ export async function runJobDeleteWallPosts( // Wait a moment for the UI to update await vm.sleep(300); - // Check the action description + // Read the combined action description (reflects all currently-checked items) const actionDescription = await getActionDescription(vm); vm.log( "runJobDeleteWallPosts", `Action description: "${actionDescription}"`, ); - if (canDelete(actionDescription)) { - // This item can be deleted, keep it checked + const combinedPriority = getHighestPriority( + parseActions(actionDescription), + ); + + if (batchAction === null) { + // First item: establish the batch action + 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 vm.sleep(300); + continue; + } + batchAction = combinedPriority; checkedCount++; vm.log( "runJobDeleteWallPosts", - `Checked deletable item ${checkedCount}/${maxToCheck}`, + `First item sets batch action to "${batchAction}", checked ${checkedCount}/${maxToCheck}`, + ); + } 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 { - // This item cannot be deleted, uncheck it + // Adding this item changes the priority — uncheck it and stop vm.log( "runJobDeleteWallPosts", - `Item [${listIndex}][${itemIndex}] cannot be deleted, unchecking`, + `Item [${listIndex}][${itemIndex}] changes priority from "${batchAction}" to "${combinedPriority}", unchecking and stopping`, ); await toggleCheckbox(vm, listIndex, itemIndex, false); await vm.sleep(300); + break; } } vm.log( "runJobDeleteWallPosts", - `Selected ${checkedCount} items for deletion`, + `Selected ${checkedCount} items for action "${batchAction}"`, ); // If nothing was checked, we're done if (checkedCount === 0) { - vm.log("runJobDeleteWallPosts", "No deletable items found, finishing"); + vm.log("runJobDeleteWallPosts", "No actionable items found, finishing"); break; } @@ -490,23 +605,40 @@ export async function runJobDeleteWallPosts( await vm.waitForPause(); - // Click the "Delete posts" radio button - vm.log("runJobDeleteWallPosts", "Selecting delete posts option"); - const deleteSelected = await selectDeletePostsOption(vm); - if (!deleteSelected) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, - { - batchNumber, - message: "Failed to select delete posts option", - }, - ); + // 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", "Delete posts option selected"); + vm.log("runJobDeleteWallPosts", `"${batchAction}" option selected`); await vm.waitForPause(); From efabc17350a98cd258016ae3a48425d5dacdd10f Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Wed, 8 Apr 2026 18:22:43 -0400 Subject: [PATCH 2/2] fix: untagging --- src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts index 5f531a98..488eb82c 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts @@ -310,7 +310,7 @@ async function selectUntagPostsOption(vm: FacebookViewModel): Promise { for (const div of divs) { const text = div.textContent?.toLowerCase() || ''; - if (text.includes('untag')) { + if (text.includes('untag') || text.includes('remove tags')) { if (div.getAttribute('aria-disabled') === 'false') { const radioButton = div.querySelector('i'); if (radioButton) {