diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts index 7c4b577c..e5ee19fb 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts @@ -5,6 +5,17 @@ import { checkRateLimit } from "./rate_limit"; import { AutomationErrorType } from "../../automation_errors"; const FACEBOOK_PROFILE_URL = "https://www.facebook.com/me/"; +const FACEBOOK_CATEGORY_KEYS = { + "comments": "COMMENTSCLUSTER", + "reactions": "LIKEDPOSTS", + "user_posts": "MANAGEPOSTSPHOTOSANDVIDEOS", + "posts_on_others": "POSTSONOTHERSTIMELINES", + "others_posts": "WALLCLUSTER", + "checkins": "CHECKINS", + "tagged_posts": "MANAGETAGSBYOTHERSCLUSTER", + "tagged_media": "TAGGEDPHOTOS", +} +const ACTIVITY_LOG_CHECKBOX_NAME = "comet_activity_log_select_all_checkbox" async function reportDeleteWallPostsError( vm: FacebookViewModel, @@ -301,58 +312,26 @@ export function getHighestPriority(actions: PostAction[]): PostAction | null { } /** - * Toggle a checkbox by index and return success + * Toggle a checkbox by name and return success */ -async function toggleCheckbox( - vm: FacebookViewModel, - listIndex: number, - itemIndex: number, - shouldCheck: boolean, -): Promise { +async function toggleSelectAllCheckbox(vm: FacebookViewModel, shouldCheck: boolean): Promise { const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; + const checkbox = document.querySelector('input[name="${ACTIVITY_LOG_CHECKBOX_NAME}"]'); + if (!checkbox) 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 isChecked = checkbox?.getAttribute('aria-checked') || checkbox.checked; 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(); + checkbox.click(); return true; } return true; })()`, - "toggleCheckbox", + "toggleSelectAllCheckbox", ); return result.success && result.value; } @@ -416,114 +395,30 @@ async function clickNextButton(vm: FacebookViewModel): Promise { } /** - * 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 + * Click the "Trash" button in the header section + * Looks for a div with role "button" and aria-label "trash" (case insensitive), checks it's not disabled, + * and clicks the button */ -async function selectDeletePostsOption( +async function clickDeletePostsOption( vm: FacebookViewModel, ): Promise { const result = await vm.safeExecuteJavaScript( `(() => { - const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); - if (!dialog) return false; + const deleteButton = document.querySelector('div[aria-label="Trash"][role="button"]'); + if (!deleteButton) 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; - } - } + if (deleteButton.getAttribute('aria-disabled') === 'false') { + deleteButton.click(); + return true; + } else { + console.log('Delete posts option is disabled'); + return false; } console.log('Could not find delete posts option'); return false; })()`, - "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') || 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; - })()`, - "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", + "clickDeletePostsOption", ); return result.success && result.value; } @@ -549,429 +444,79 @@ async function clickDoneButton(vm: FacebookViewModel): Promise { return result.success && result.value; } -export async function runJobDeleteWallPosts( +async function loadActivityLog( vm: FacebookViewModel, - jobIndex: number, + categoryKey: keyof typeof FACEBOOK_CATEGORY_KEYS ): Promise { - vm.runJobsState = RunJobsState.DeleteWallPosts; + if (vm.account.facebookAccount) { + vm.log("loadActivityLog", `Loading activity log for category key: ${categoryKey}`); - vm.showBrowser = true; - vm.showAutomationNotice = true; - vm.instructions = vm.t("viewModels.facebook.jobs.removingWallPosts"); - - vm.log("runJobDeleteWallPosts", "Loading profile page"); - - await vm.waitForPause(); + const FACEBOOK_ACTIVITY_LOG_URL = `https://www.facebook.com/${vm.account.facebookAccount.accountID}/\ +allactivity?activity_history=false&category_key=${FACEBOOK_CATEGORY_KEYS[categoryKey]}\ +&manage_mode=false&should_load_landing_page=false`; - // Load the user's profile page - await vm.loadURL(FACEBOOK_PROFILE_URL); - await vm.waitForLoadingToFinish(); - - await vm.waitForPause(); + await vm.loadURL(FACEBOOK_ACTIVITY_LOG_URL); + await vm.waitForLoadingToFinish(); - // 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; - - 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.pause(); 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; - } +export async function runJobDeleteWallPosts( + vm: FacebookViewModel, + jobIndex: number, +): Promise { + vm.runJobsState = RunJobsState.DeleteWallPosts; - vm.log("runJobDeleteWallPosts", "Dialog opened, waiting for posts to load"); + vm.showBrowser = true; + vm.showAutomationNotice = true; + vm.instructions = vm.t("viewModels.facebook.jobs.removingWallPosts"); + // TODO: might want to not hardcode but based on the options selected by users + const postCategoryKeys = [ + "user_posts", + "posts_on_others", + "others_posts", + "checkins", + "tagged_posts", + "tagged_media" + ] as (keyof typeof FACEBOOK_CATEGORY_KEYS)[]; + + for (const categoryKey of postCategoryKeys) { + // Load activity log page based on category key await vm.waitForPause(); + await loadActivityLog(vm, categoryKey); - // 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`, - ); - break; - } - await vm.sleep(pollInterval); - } - - if (allItems.length === 0) { - vm.log( - "runJobDeleteWallPosts", - `No items found after ${maxWaitTime}ms timeout, proceeding anyway`, - ); - } - - 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; - } + // Keep deleting posts until there are no more to delete + let totalDeleted = 0; + while (true) { + // Check for rate limits + await checkRateLimit(vm); - // Read the combined action description (reflects all currently-checked items) - const actionDescription = await waitForActionDescriptionStable(vm); + // Check select all checkbox + const toggled = await toggleSelectAllCheckbox(vm, true); + if (!toggled) { vm.log( "runJobDeleteWallPosts", - `Action description: "${actionDescription}"`, + `Failed to check "All" checkbox`, ); - - 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; - } + continue; } - vm.log( - "runJobDeleteWallPosts", - `Selected ${checkedCount} items for action "${batchAction}"`, - ); - - 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; - } + await vm.waitForPause(); - // If nothing was checked, see if more items get selected by next priority action in the list - if (batchAction !== "hide") { + // Click on trash + const deletedBtnClicked = await clickDeletePostsOption(vm); + if(deletedBtnClicked) { vm.log( "runJobDeleteWallPosts", - `No actionable items found for action "${batchAction}", checking next priority action`, + `Failed to click "Trash" button`, ); + continue; } } - - 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"); - break; - } - - await vm.waitForPause(); - - const batchActionReady = await waitForBatchAction(vm, batchAction); - if (!batchActionReady.success) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, - { - batchNumber, - message: `Action description did not settle on "${batchAction}" before clicking Next`, - actionDescription: batchActionReady.actionDescription, - }, - ); - return; - } - - // 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; - } - - // 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", - }, - ); - return; - } - - 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) { - await reportDeleteWallPostsError( - vm, - jobIndex, - AutomationErrorType.facebook_runJob_deleteWallPosts_ClickDoneFailed, - { - batchNumber, - message: "Failed to click Done button", - }, - ); - return; - } - - vm.log("runJobDeleteWallPosts", "Done button clicked"); - - await vm.waitForPause(); - - // 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"); - } - - // 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})`, - ); - - // 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, - ); - } - - // Submit progress to the API - vm.emitter?.emit(`facebook-submit-progress-${vm.account.id}`); - - await vm.waitForPause(); - - // Give Facebook a few seconds before refreshing - // It seems that this helps - await vm.sleep(3000); - - // 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(); } vm.log(