Files
MISF-Planner-Download/src/onedrive_planner.py
T
Lubna Fathima 291507c2f7 Add ECW/HTTP planner scripts, browser crash recovery, and Planner error-overlay handling
- Add ecw_download.py and onedrive_planner_http.py for ECW and HTTP-based Planner access
- Add build_exe.bat for packaging
- Restart Chrome automatically on crash mid-run in planner_download.py
- Detect and recover from Planner "something went wrong" error overlays
- Improve board-load waiting, sort-descending menu matching, and screenshot timestamp rendering in chrome.py/onedrive_planner.py
2026-07-09 08:41:28 -07:00

6450 lines
230 KiB
Python

# ================================================================
# OneDrive / Microsoft Planner — face-sheet download automation
# ================================================================
from __future__ import annotations
import base64
from dataclasses import dataclass, field
import json
import logging
import os
import re
import shutil
import time
from datetime import datetime
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
import pyotp
from chrome import DOWNLOADS_DIR as _CHROME_DOWNLOADS_DIR
from chrome import capture_browser_screenshot
from chrome import capture_fullscreen_screenshot
from config import DEFAULT_ONEDRIVE_URL, DEFAULT_PLANNER_URL, PROJECT_ROOT, TODAY_ISO_DATE
from debug_log import write_agent_session_debug as _agent_session_log
from debug_log import write_debug as _write_debug
from run_log import log_step, log_url
from totp_utils import validate_totp_secret
from selenium.common.exceptions import (
InvalidSelectorException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
_log = logging.getLogger(__name__)
_COOKIE_JAR = PROJECT_ROOT / "Output" / "session" / "cookies_planner.json"
WAIT_SHORT = 15
WAIT_MED = 30
WAIT_LONG = 60
POST_FACILITY_CLICK_WAIT = 30
POST_FACILITY_LOAD_DELAY = 3
PANEL_TAB_LOAD_DELAY = 2 # let task panel / tab content render before reads
FACILITY_LOAD_AFTER_REFRESH = 45
DOWNLOAD_POLL_INTERVAL = 0.5
DOWNLOAD_TIMEOUT = 120
MIN_PDF_BYTES = 5000
_ACTIVE_PAPERCLIP_XPATH = (
"//button[@data-testid='attachment-indicator']"
"[not(ancestor::*[contains(@aria-label,'Completed tasks')])]"
)
# Planner 2024+ board: complete control is often button[role=checkbox], not label.
_COMPLETION_CHECKBOX_UNCHECKED_XPATH = (
".//label[@role='checkbox' and @aria-checked='false']"
" | .//button[@role='checkbox' and @aria-checked='false']"
" | .//*[@role='checkbox' and @aria-checked='false']"
)
_COMPLETION_CHECKBOX_CHECKED_XPATH = (
".//label[@role='checkbox' and @aria-checked='true']"
" | .//button[@role='checkbox' and @aria-checked='true']"
)
_PLANNER_TASK_CARD_XPATHS = (
"./ancestor::*[@data-dnd-role='card'][1]",
"./ancestor::div[contains(@class,'planner-draggable-task-card')][1]",
"./ancestor::*[@role='listitem'][@data-dnd-role='card'][1]",
)
# Avoid duplicate processing within a single run (Shared list can contain duplicates).
_PROCESSED_SHARED_FACILITIES: set[str] = set()
FACILITY_NOT_APPEAR_MSG = "Facility did not appear in Shared plans search"
def reset_run_state() -> None:
"""Clear per-run module-level state. Call at the start of each workflow run."""
global _PROCESSED_SHARED_FACILITIES
_PROCESSED_SHARED_FACILITIES = set()
class FacilityNotFoundError(Exception):
"""Facility could not be opened from My plans -> Shared (list or filter)."""
_FILTER_BUTTON_LOCATORS = [
(By.CSS_SELECTOR, "button[aria-label='Filter by keyword']"),
(By.XPATH, "//button[@aria-label='Filter by keyword']"),
]
_FILTER_INPUT_LOCATORS = [
(By.CSS_SELECTOR, "input[aria-label='Filter by keyword']"),
(By.CSS_SELECTOR, "input[placeholder*='Filter'][placeholder*='keyword']"),
(By.XPATH, "//input[contains(@placeholder,'Filter') and contains(@placeholder,'keyword')]"),
(By.CSS_SELECTOR, "input[type='search']"),
(By.CSS_SELECTOR, "[role='searchbox']"),
]
_FILTER_EMPTY_STATE_XPATHS = [
"//*[contains(.,'Clear your filters')]",
"//*[contains(.,'No results')]",
"//*[contains(.,'Search more plans')]",
"//*[contains(.,'no plans')]",
]
# Optional corporate proxy for HTTP downloads (same as browser proxy).
_HTTP_PROXY: tuple[str, str, str, str] | None = None
def configure_http_download_proxy(
proxy_ip: str | None,
proxy_port: str | None,
proxy_username: str | None = None,
proxy_password: str | None = None,
) -> None:
"""Use the same proxy as Chromium for SharePoint HTTP downloads."""
global _HTTP_PROXY
if proxy_ip and proxy_port:
_HTTP_PROXY = (
proxy_ip.strip(),
str(proxy_port).strip(),
(proxy_username or "").strip(),
(proxy_password or "").strip(),
)
else:
_HTTP_PROXY = None
_agent_log = _write_debug
_agent_dbg_eb = _write_debug
_dbg = _write_debug
def _sanitize_folder_name(name: str) -> str:
cleaned = re.sub(r'[<>:"/\\|?*]', "_", name.strip())
return cleaned or "unknown_facility"
def _xpath_literal(text: str) -> str:
"""Build an XPath string literal safe for quotes in facility names."""
if "'" not in text:
return f"'{text}'"
if '"' not in text:
return f'"{text}"'
parts = text.split("'")
return "concat(" + ", \"'\", ".join(f"'{p}'" for p in parts) + ")"
def _wait(driver: WebDriver, timeout: int = WAIT_MED) -> WebDriverWait:
return WebDriverWait(driver, timeout)
def _is_facility_plan_url(url: str) -> bool:
u = (url or "").lower()
return "/plan/" in u or "planid=" in u
def _is_planner_hub_url(url: str) -> bool:
"""My Tasks / My plans list — not an open facility board."""
u = (url or "").lower()
if u in ("about:blank", "", "data:,"):
return False
if "planner.cloud.microsoft" not in u and "tasks.office.com" not in u:
return False
return not _is_facility_plan_url(u)
def consolidate_browser_to_single_tab(driver: WebDriver) -> None:
"""
Close extra tabs from Chromium session restore or about:blank at startup.
The automation expects one working tab before navigating to Planner.
"""
try:
handles = list(driver.window_handles)
if len(handles) <= 1:
return
def _tab_url(handle: str) -> str:
try:
driver.switch_to.window(handle)
return (driver.current_url or "").strip().lower()
except Exception:
return ""
keep = handles[-1]
for handle in reversed(handles):
url = _tab_url(handle)
if url not in ("about:blank", "", "data:,"):
keep = handle
break
closed = 0
for handle in handles:
if handle == keep:
continue
try:
driver.switch_to.window(handle)
driver.close()
closed += 1
except Exception:
pass
driver.switch_to.window(keep)
log_step("Consolidated browser to single tab", closed=closed)
except Exception as exc:
_log.warning("consolidate_browser_to_single_tab: %s", exc)
def focus_facility_plan_tab(driver: WebDriver) -> bool:
"""
Switch to the facility plan tab and close My Tasks / Shared list tabs.
Planner opens a plan in a new tab when clicked from My plans -> Shared,
leaving the original My Tasks tab open.
"""
try:
plan_handle = None
for handle in driver.window_handles:
driver.switch_to.window(handle)
if _is_facility_plan_url(driver.current_url or ""):
plan_handle = handle
break
if not plan_handle:
return False
closed = 0
for handle in list(driver.window_handles):
if handle == plan_handle:
continue
try:
driver.switch_to.window(handle)
url = (driver.current_url or "").strip().lower()
if url in ("about:blank", "", "data:,") or _is_planner_hub_url(url):
driver.close()
closed += 1
except Exception:
pass
driver.switch_to.window(plan_handle)
if closed:
log_step(
"Closed planner hub tab(s)",
count=closed,
plan_url=(driver.current_url or "")[:120],
)
return True
except Exception as exc:
_log.warning("focus_facility_plan_tab: %s", exc)
return False
def _save_cookies(driver: WebDriver) -> None:
try:
_COOKIE_JAR.parent.mkdir(parents=True, exist_ok=True)
cookies = driver.get_cookies()
# Do not log cookie values; just store to disk.
_COOKIE_JAR.write_text(json.dumps(cookies), encoding="utf-8")
except Exception as exc:
_log.warning("Could not save session cookies: %s", exc)
def _load_cookies(driver: WebDriver, *, domain_url: str) -> bool:
"""
Best-effort cookie restore. We must visit the domain first before adding cookies.
"""
if not _COOKIE_JAR.is_file():
return False
try:
driver.get(domain_url)
time.sleep(2)
cookies = json.loads(_COOKIE_JAR.read_text(encoding="utf-8"))
if not isinstance(cookies, list):
return False
added = 0
for c in cookies:
if not isinstance(c, dict):
continue
# Selenium rejects some keys; keep the common ones.
slim = {k: c.get(k) for k in ("name", "value", "domain", "path", "expiry", "secure", "httpOnly", "sameSite") if k in c}
if "name" in slim and "value" in slim:
try:
driver.add_cookie(slim)
added += 1
except Exception:
continue
if added:
driver.get(domain_url)
time.sleep(2)
return added > 0
except Exception as exc:
_log.warning("Could not restore session cookies: %s", exc)
return False
def _js_click(driver: WebDriver, element) -> None:
try:
tag = element.tag_name
label = (element.text or element.get_attribute("aria-label") or "")[:80]
log_step("Click element", tag=tag, label=label)
except Exception:
log_step("Click element")
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", element)
time.sleep(0.3)
driver.execute_script("arguments[0].click();", element)
def _first_clickable(driver: WebDriver, locators: list[tuple[str, str]], timeout: int = WAIT_MED):
end = time.time() + timeout
last_err = None
while time.time() < end:
for by, value in locators:
try:
el = driver.find_element(by, value)
if el.is_displayed() and el.is_enabled():
return el
except (NoSuchElementException, StaleElementReferenceException) as exc:
last_err = exc
time.sleep(0.4)
raise TimeoutException(f"No clickable element for locators (last: {last_err})")
def _click_any(driver: WebDriver, locators: list[tuple[str, str]], timeout: int = WAIT_MED) -> None:
el = _first_clickable(driver, locators, timeout=timeout)
_js_click(driver, el)
def _type_into(driver: WebDriver, locators: list[tuple[str, str]], text: str, clear: bool = True) -> None:
el = _first_clickable(driver, locators, timeout=WAIT_LONG)
_js_click(driver, el)
if clear:
el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE)
el.send_keys(text)
def _on_login_host(driver: WebDriver) -> bool:
url = (driver.current_url or "").lower()
return any(
h in url
for h in (
"login.microsoftonline.com",
"login.live.com",
"login.microsoft.com",
)
)
def _on_planner_app(driver: WebDriver) -> bool:
url = (driver.current_url or "").lower()
return any(
h in url
for h in (
"planner.cloud.microsoft",
"tasks.office.com",
"tasks.office365.us",
)
)
def _on_planner_plan_board(driver: WebDriver) -> bool:
url = (driver.current_url or "").lower()
return (
"planner.cloud.microsoft" in url
and "/plan/" in url
and ("board" in url or "grid" in url)
)
def _plan_load_error_present(driver: WebDriver) -> bool:
"""True when Planner shows any error overlay that blocks normal operation."""
phrases = (
"couldn't open your plan",
"could not open your plan",
"try again in a few minutes",
"something went wrong",
"something's not right",
"we couldn't load",
"please try again",
)
try:
blob = (driver.find_element(By.TAG_NAME, "body").text or "").lower()
return any(p in blob for p in phrases)
except Exception:
return False
def _recover_planner_error(driver: WebDriver) -> bool:
"""Detect and dismiss a Planner error overlay ('Something went wrong', etc).
Tries clicking the 'Try again' button first; falls back to a full page refresh.
Returns True if an error was detected (regardless of whether recovery succeeded).
"""
if not _plan_load_error_present(driver):
return False
log_step("Planner error overlay detected — attempting recovery")
if _click_plan_try_again(driver):
time.sleep(4)
if not _plan_load_error_present(driver):
log_step("Planner error overlay cleared via Try again")
return True
# Fallback: full page refresh
try:
log_step("Refreshing page to clear Planner error overlay")
driver.refresh()
time.sleep(6)
driver.switch_to.default_content()
_switch_to_planner_context(driver)
log_step("Page refreshed — error overlay cleared")
except Exception as exc:
log_step("Refresh failed during error recovery", error=str(exc)[:120])
return True
def _plan_page_ready(driver: WebDriver) -> bool:
"""Plan UI is usable — not the error page."""
if _plan_load_error_present(driver):
return False
url = (driver.current_url or "").lower()
if not ("/plan/" in url or "planid=" in url):
return False
return bool(
driver.find_elements(By.XPATH, "//*[@role='tab' and normalize-space()='Board']")
or driver.find_elements(By.XPATH, "//*[@role='tab' and normalize-space()='Grid']")
or driver.find_elements(
By.XPATH,
"//*[contains(@aria-label,'Add task') or normalize-space()='Add task']",
)
or driver.find_elements(By.CSS_SELECTOR, "[role='grid'].grid-container, .grid-container")
or driver.find_elements(
By.XPATH,
"//*[contains(@data-testid,'TaskCard') or contains(@class,'TaskCard')]",
)
)
def _click_plan_try_again(driver: WebDriver) -> bool:
"""Click Planner's 'Try again' button on the plan load error page."""
locators = [
(By.XPATH, "//button[normalize-space()='Try again']"),
(By.XPATH, "//*[self::button or self::a][normalize-space()='Try again']"),
]
try:
log_step("Click Try again on plan error page")
_click_any(driver, locators, timeout=8)
time.sleep(4)
return True
except TimeoutException:
return False
def _board_probes_present(driver: WebDriver) -> bool:
"""True when the plan board UI (task cards) is visible in the current frame."""
probes = [
"//button[@data-testid='attachment-indicator']",
"//label[@role='checkbox']",
"//*[@role='checkbox']",
"//*[normalize-space()='Add task']",
"//*[@role='tab' and normalize-space()='Board']",
]
for xpath in probes:
try:
for el in driver.find_elements(By.XPATH, xpath)[:12]:
if el.is_displayed():
return True
except Exception:
continue
return False
def _on_sharepoint_or_onedrive(driver: WebDriver) -> bool:
url = (driver.current_url or "").lower()
return any(
h in url
for h in ("sharepoint.com", "onedrive.live.com", "office.com", "office365.com")
)
def _on_m365_app(driver: WebDriver) -> bool:
return _on_planner_app(driver) or _on_sharepoint_or_onedrive(driver)
def _is_stay_signed_in_prompt(driver: WebDriver) -> bool:
try:
if driver.find_elements(By.ID, "idBtn_Back"):
return True
if driver.find_elements(
By.XPATH,
"//*[contains(translate(normalize-space(.),"
"'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),"
"'stay signed in')]",
):
return True
return "stay signed in" in (driver.page_source or "").lower()
except Exception:
return False
def _otp_input_visible(driver: WebDriver) -> bool:
try:
for by, sel in (
(By.CSS_SELECTOR, "input[name='otc']"),
(By.ID, "idTxtBx_SAOTCC_OTC"),
(By.CSS_SELECTOR, "input[type='tel']"),
):
for el in driver.find_elements(by, sel):
if el.is_displayed():
return True
except Exception:
pass
return False
def _email_form_visible(driver: WebDriver) -> bool:
"""True when the Microsoft login page shows an email / username field."""
try:
for by, sel in (
(By.CSS_SELECTOR, "input[type='email']"),
(By.NAME, "loginfmt"),
(By.ID, "i0116"),
):
for el in driver.find_elements(by, sel):
if el.is_displayed():
return True
except Exception:
pass
return False
def _password_form_visible(driver: WebDriver) -> bool:
"""True when the Microsoft login page shows a password field (with or without email step)."""
try:
for by, sel in (
(By.CSS_SELECTOR, "input[type='password']"),
(By.NAME, "passwd"),
(By.ID, "i0118"),
):
for el in driver.find_elements(by, sel):
if el.is_displayed():
return True
except Exception:
pass
return False
def _login_form_visible(driver: WebDriver) -> bool:
return _email_form_visible(driver) or _password_form_visible(driver)
def _needs_microsoft_login(driver: WebDriver) -> bool:
"""Planner launch may redirect to login or show password directly (remembered account)."""
return _on_login_host(driver) or _login_form_visible(driver)
def _planner_session_ready(driver: WebDriver) -> bool:
"""Signed in on Planner — no Microsoft login/password prompt visible."""
return (
_on_planner_app(driver)
and not _on_login_host(driver)
and not _login_form_visible(driver)
)
def _remove_empty_directory(path: Path) -> None:
"""Remove facility folder if it was created for downloads but stayed empty."""
try:
if path.is_dir() and not any(path.iterdir()):
path.rmdir()
except OSError:
pass
try:
parent = path.parent
if parent.is_dir() and parent != path and not any(parent.iterdir()):
parent.rmdir()
except OSError:
pass
def _ensure_download_folder(folder: Path) -> None:
"""Create Original/<facility>/ only when a download is about to start."""
folder.mkdir(parents=True, exist_ok=True)
def set_chrome_download_directory(
driver: WebDriver,
folder: Path,
*,
create_dir: bool = False,
) -> None:
"""Set download path for the current tab via CDP."""
if create_dir:
_ensure_download_folder(folder)
try:
driver.execute_cdp_cmd( # pyright: ignore[reportAttributeAccessIssue]
"Page.setDownloadBehavior",
{"behavior": "allow", "downloadPath": str(folder.resolve())},
)
except Exception as exc:
print(f"[WARN] Could not set CDP download path ({exc}); using default Downloads")
def _relocate_new_pdfs_from_dirs(
extra_dirs: list[Path],
dest_folder: Path,
before: set[str],
) -> list[Path]:
"""Move new PDFs from Chrome default download dirs into the facility folder."""
_ensure_download_folder(dest_folder)
moved: list[Path] = []
for folder in extra_dirs:
if not folder.exists():
continue
for src in sorted(folder.glob("*.pdf"), key=lambda p: p.stat().st_mtime):
if src.name in before:
continue
dest = _unique_file_path(dest_folder, src.name)
try:
shutil.move(str(src), str(dest))
before.add(dest.name)
moved.append(dest)
log_step("Relocated PDF from Chrome download dir", file=dest.name, from_dir=str(folder))
except Exception as exc:
_log.warning("Could not move %s -> %s: %s", src, dest, exc)
return moved
def _wait_for_new_download(
folder: Path,
before: set[str],
timeout: float = DOWNLOAD_TIMEOUT,
*,
extra_watch_dirs: list[Path] | None = None,
) -> list[Path]:
"""Poll folder until new PDF(s) appear and .crdownload files finish."""
deadline = time.time() + timeout
if not folder.exists():
folder.mkdir(parents=True, exist_ok=True)
watch = [folder]
if extra_watch_dirs:
watch.extend(extra_watch_dirs)
while time.time() < deadline:
partial: list[Path] = []
for d in watch:
if d.exists():
partial.extend(d.glob("*.crdownload"))
partial.extend(d.glob("*.tmp"))
if partial:
time.sleep(DOWNLOAD_POLL_INTERVAL)
continue
after = {p.name for p in folder.iterdir() if p.is_file()}
new_names = after - before
new_pdfs = [
folder / n
for n in new_names
if n.lower().endswith(".pdf")
]
if new_pdfs:
return sorted(new_pdfs, key=lambda p: p.stat().st_mtime)
relocated = _relocate_new_pdfs_from_dirs(
[d for d in watch if d != folder],
folder,
before,
)
if relocated:
return relocated
time.sleep(DOWNLOAD_POLL_INTERVAL)
return _relocate_new_pdfs_from_dirs(
[d for d in watch if d != folder],
folder,
before,
)
def _submit_microsoft_totp(driver: WebDriver, totp_secret: str) -> None:
raw = (totp_secret or "").strip()
secret_mapped = validate_totp_secret(raw)
_dbg(
"H1",
"src/onedrive_planner.py:_submit_microsoft_totp",
"TOTP secret sanitized",
{
"raw_len": len(raw),
"sanitized_len": len(secret_mapped),
"had_separators": bool(re.search(r"[\s\-]", raw)),
},
)
totp_input_locators = [
(By.CSS_SELECTOR, "input[name='otc']"),
(By.CSS_SELECTOR, "input[type='tel']"),
(By.CSS_SELECTOR, "input[inputmode='numeric']"),
(By.XPATH, "//input[contains(@aria-label,'code') or contains(@placeholder,'code')]"),
(By.ID, "idTxtBx_SAOTCC_OTC"),
]
for attempt in range(2):
if _is_stay_signed_in_prompt(driver):
_dbg(
"H4",
"src/onedrive_planner.py:_submit_microsoft_totp",
"Stay-signed-in before OTP entry",
{"attempt": attempt + 1},
)
return
if not _otp_input_visible(driver):
_dbg(
"H4",
"src/onedrive_planner.py:_submit_microsoft_totp",
"No OTP field visible",
{"attempt": attempt + 1, "url": (driver.current_url or "")[:120]},
)
return
code = pyotp.TOTP(secret_mapped).now()
print(f"[INFO] Entering Microsoft Authenticator code (attempt {attempt + 1})")
_type_into(driver, totp_input_locators, code, clear=True)
time.sleep(0.5)
verify_locators = [
(By.CSS_SELECTOR, "input[type='submit']"),
(By.XPATH, "//input[@type='submit' and (@value='Verify' or @value='Sign in')]"),
(By.XPATH, "//button[@type='submit']"),
(By.ID, "idSubmit_SAOTCC_Continue"),
(By.ID, "idSIButton9"),
]
try:
_click_any(driver, verify_locators, timeout=WAIT_SHORT)
except TimeoutException:
if _otp_input_visible(driver):
_first_clickable(driver, totp_input_locators).send_keys(Keys.ENTER)
time.sleep(4)
_dismiss_stay_signed_in(driver)
stay_prompt = _is_stay_signed_in_prompt(driver)
otp_visible = _otp_input_visible(driver)
on_login = _on_login_host(driver)
_dbg(
"H4",
"src/onedrive_planner.py:_submit_microsoft_totp",
"After OTP submit",
{
"attempt": attempt + 1,
"url": (driver.current_url or "")[:120],
"on_login_host": on_login,
"otp_field_present": otp_visible,
"stay_prompt_present": stay_prompt,
},
)
if stay_prompt or not on_login or not otp_visible:
return
print("[WARN] OTP still visible on login host — retrying with fresh code")
time.sleep(5)
def _dismiss_stay_signed_in(driver: WebDriver) -> None:
_dbg(
"H4",
"src/onedrive_planner.py:_dismiss_stay_signed_in",
"Checking stay signed in prompt",
{
"url": (driver.current_url or "")[:120],
},
)
locators = [
(By.ID, "idSIButton9"),
(By.ID, "idBtn_Back"),
(By.XPATH, "//input[@type='submit' and @value='Yes']"),
(By.XPATH, "//button[contains(.,'Yes')]"),
(By.XPATH, "//input[@type='submit' and @value='No']"),
(By.XPATH, "//button[contains(.,'No')]"),
]
try:
# Prefer "No" to reduce extra consent-style steps.
no_first = [
(By.ID, "idBtn_Back"),
(By.XPATH, "//input[@type='submit' and @value='No']"),
(By.XPATH, "//button[contains(.,'No')]"),
]
try:
_click_any(driver, no_first, timeout=3)
_dbg("H4", "src/onedrive_planner.py:_dismiss_stay_signed_in", "Clicked No", {})
except TimeoutException:
_click_any(driver, locators, timeout=8)
_dbg("H4", "src/onedrive_planner.py:_dismiss_stay_signed_in", "Clicked a stay-signed-in button", {})
time.sleep(2)
except TimeoutException:
pass
def _perform_microsoft_login(
driver: WebDriver,
username: str,
password: str,
totp_secret: str,
) -> None:
"""Username → password → TOTP → dismiss stay-signed-in.
Handles password-only prompts when Planner opens with a remembered account.
"""
email_locators = [
(By.CSS_SELECTOR, "input[type='email']"),
(By.NAME, "loginfmt"),
(By.ID, "i0116"),
]
next_locators = [
(By.ID, "idSIButton9"),
(By.CSS_SELECTOR, "input[type='submit']"),
(By.XPATH, "//input[@type='submit' and @value='Next']"),
]
password_locators = [
(By.CSS_SELECTOR, "input[type='password']"),
(By.NAME, "passwd"),
(By.ID, "i0118"),
]
signin_locators = [
(By.ID, "idSIButton9"),
(By.CSS_SELECTOR, "input[type='submit']"),
(By.XPATH, "//input[@type='submit' and @value='Sign in']"),
(By.XPATH, "//button[@type='submit']"),
]
if _email_form_visible(driver):
_log.info("Entering Microsoft username...")
log_step("Microsoft login — enter username")
_wait(driver, WAIT_LONG).until(
EC.element_to_be_clickable(email_locators[0])
)
_type_into(driver, email_locators, username.strip())
_click_any(driver, next_locators)
try:
_wait(driver, WAIT_MED).until(
lambda d: _password_form_visible(d) or not _on_login_host(d)
)
except TimeoutException:
pass
elif _password_form_visible(driver):
log_step("Microsoft login — password only (account already selected)")
_log.info("Password-only login prompt — skipping username entry")
if not _password_form_visible(driver):
if not _on_login_host(driver):
return
_log.warning("Expected password field on login page — retrying after brief wait")
try:
_wait(driver, WAIT_SHORT).until(_password_form_visible)
except TimeoutException:
_log.warning("Password field not found on Microsoft login page")
return
_log.info("Entering Microsoft password...")
log_step("Microsoft login — enter password")
_type_into(driver, password_locators, password, clear=True)
_click_any(driver, signin_locators)
if _on_login_host(driver):
try:
_wait(driver, WAIT_MED).until(
lambda d: _otp_input_visible(d)
or _is_stay_signed_in_prompt(d)
or not _on_login_host(d)
)
except TimeoutException:
pass
if _is_stay_signed_in_prompt(driver):
_dismiss_stay_signed_in(driver)
elif _on_login_host(driver) and _otp_input_visible(driver):
_log.info("MFA / additional verification detected")
_submit_microsoft_totp(driver, totp_secret)
_dismiss_stay_signed_in(driver)
_dismiss_stay_signed_in(driver)
def login_and_open_planner(
driver: WebDriver,
username: str,
password: str,
totp_secret: str,
planner_url: str = DEFAULT_PLANNER_URL,
) -> None:
"""
Open Planner directly (planner.cloud.microsoft), sign in if needed, land on Planner.
"""
if not username.strip() or not password:
raise ValueError("OneDrive Username and OneDrive Password are required in input.xlsx")
target = (planner_url or DEFAULT_PLANNER_URL).strip()
log_step("Open Planner URL", url=target[:200])
_dbg("H5", "src/onedrive_planner.py:login_and_open_planner", "Navigate", {"url": target[:160]})
if _load_cookies(driver, domain_url="https://planner.cloud.microsoft/"):
log_step("Loaded saved session cookies")
else:
log_step("No cookie jar — full login may be required")
driver.get(target)
try:
_wait(driver, WAIT_MED).until(
lambda d: _planner_session_ready(d) or _needs_microsoft_login(d)
)
except TimeoutException:
time.sleep(3)
log_url(driver, "after_navigate")
if _planner_session_ready(driver):
log_step("Already signed in (cookies/session)")
_switch_to_planner_context(driver)
return
if _needs_microsoft_login(driver):
log_step("Microsoft login required")
_perform_microsoft_login(driver, username, password, totp_secret)
_dismiss_stay_signed_in(driver)
if _needs_microsoft_login(driver):
log_step("Microsoft login still required — retrying")
_perform_microsoft_login(driver, username, password, totp_secret)
_dismiss_stay_signed_in(driver)
try:
_wait(driver, WAIT_LONG).until(_planner_session_ready)
except TimeoutException as exc:
_dbg(
"H5",
"src/onedrive_planner.py:login_and_open_planner",
"Planner not reached in time",
{"url": (driver.current_url or "")[:160]},
)
raise RuntimeError(
"Login did not reach Microsoft Planner. "
f"Current URL: {driver.current_url}"
) from exc
_dbg(
"H5",
"src/onedrive_planner.py:login_and_open_planner",
"Planner reached",
{"url": (driver.current_url or "")[:160]},
)
_switch_to_planner_context(driver)
_dbg(
"H5",
"src/onedrive_planner.py:login_and_open_planner",
"Planner ready",
{"url": (driver.current_url or "")[:160]},
)
log_step("Microsoft Planner login complete")
_save_cookies(driver)
log_step("Saved session cookies for next run")
def login_microsoft_onedrive(
driver: WebDriver,
username: str,
password: str,
totp_secret: str,
onedrive_url: str = DEFAULT_ONEDRIVE_URL,
) -> None:
"""Deprecated: sign in via OneDrive URL. Use :func:`login_and_open_planner` instead."""
if not username.strip() or not password:
raise ValueError("OneDrive Username and OneDrive Password are required in input.xlsx")
print(f"[INFO] Navigating to OneDrive → {onedrive_url[:90]}...")
driver.get(onedrive_url)
time.sleep(3)
if _on_m365_app(driver) and not _needs_microsoft_login(driver):
print("[INFO] Already signed in to Microsoft 365")
return
if _needs_microsoft_login(driver):
_perform_microsoft_login(driver, username, password, totp_secret)
try:
_wait(driver, WAIT_LONG).until(
lambda d: _on_m365_app(d) and not _on_login_host(d)
)
except TimeoutException as exc:
raise RuntimeError(
"Microsoft login did not complete. "
f"Current URL: {driver.current_url}"
) from exc
print("[SUCCESS] Microsoft login complete")
def _switch_to_planner_context(driver: WebDriver, *, retries: int = 3) -> bool:
"""
Switch into the iframe that hosts the Planner React app.
Planner uses different surfaces (My tasks grid, My plans list, plan board) and
the previous "search box" probe is not always present. This version searches
for a frame where *any* Planner UI probes exist and supports one nested iframe.
"""
probes: list[tuple[str, str]] = [
# Board task cards (planner.cloud.microsoft plan board)
(By.CSS_SELECTOR, "button[data-testid='attachment-indicator']"),
(By.XPATH, "//label[@role='checkbox']"),
(By.XPATH, "//*[@role='checkbox']"),
# My plans -> Shared filter box
(By.CSS_SELECTOR, "input[placeholder*='Filter'][placeholder*='keyword']"),
(By.XPATH, "//input[contains(@placeholder,'Filter') and contains(@placeholder,'keyword')]"),
# Left-nav / section labels
(By.XPATH, "//*[self::span or self::div][normalize-space()='My plans']"),
(By.XPATH, "//*[self::span or self::div][normalize-space()='My tasks']"),
# Plan view tabs
(By.XPATH, "//*[@role='tab' and (normalize-space()='Board' or normalize-space()='Grid')]"),
(By.XPATH, "//*[contains(@aria-label,'Add task') or normalize-space()='Add task']"),
# Generic search (some views)
(By.CSS_SELECTOR, "input[type='search']"),
(By.CSS_SELECTOR, "[role='searchbox']"),
]
def _has_probe() -> bool:
for by, sel in probes:
try:
if driver.find_elements(by, sel):
return True
except Exception:
continue
return False
def _try_frames(frames) -> bool:
for fr in frames:
try:
# Wait for frame availability (helps when iframe is present but still loading).
try:
_wait(driver, WAIT_SHORT).until(EC.frame_to_be_available_and_switch_to_it(fr))
except Exception:
driver.switch_to.frame(fr)
# Give the React app a moment to paint inside this frame.
end = time.time() + 3.0
while time.time() < end:
if _has_probe():
return True
time.sleep(0.2)
# One level of nesting (Planner sometimes nests)
nested = driver.find_elements(By.TAG_NAME, "iframe")
if nested:
for nfr in nested:
try:
driver.switch_to.frame(nfr)
end2 = time.time() + 2.0
while time.time() < end2:
if _has_probe():
return True
time.sleep(0.2)
driver.switch_to.parent_frame()
except Exception:
try:
driver.switch_to.parent_frame()
except Exception:
pass
driver.switch_to.default_content()
except Exception:
try:
driver.switch_to.default_content()
except Exception:
pass
return False
for attempt in range(1, retries + 1):
driver.switch_to.default_content()
if _has_probe() or (_on_planner_plan_board(driver) and _board_probes_present(driver)):
return True
iframes = driver.find_elements(By.TAG_NAME, "iframe")
if not iframes:
time.sleep(1)
continue
# Prefer likely Planner frames first, but verify using probes.
scored = []
for fr in iframes:
src = (fr.get_attribute("src") or "").lower()
title = (fr.get_attribute("title") or "").lower()
name = (fr.get_attribute("name") or "").lower()
portal = (fr.get_attribute("data-portal-node") or "").lower()
score = 0
if "planner" in src or "planner" in title or "planner" in name:
score += 3
if "myplans" in src or "/webui/" in src:
score += 2
if portal == "true":
score += 2
if "tasks" in src or "office" in src:
score += 1
scored.append((score, fr))
scored.sort(key=lambda x: x[0], reverse=True)
if _try_frames([fr for _, fr in scored]):
_log.info("Switched into Planner iframe (attempt %d)", attempt)
return True
time.sleep(1)
_log.warning("Planner iframe not detected after %d context attempts", retries)
driver.switch_to.default_content()
return False
_LEFT_NAV_TOGGLE_LOCATORS = [
(By.XPATH, "//button[@aria-label='Left navigation pane']"),
(By.XPATH, "//button[contains(@aria-label,'Left navigation pane')]"),
(By.XPATH, "//button[contains(@aria-label,'navigation pane')]"),
]
_MY_PLANS_NAV_LOCATORS = [
(By.XPATH, "//*[self::span or self::div][normalize-space()='My plans']"),
(By.XPATH, "//*[contains(@aria-label,'My plans') or contains(@title,'My plans')]"),
(By.XPATH, "//button[contains(.,'My plans')]"),
(By.XPATH, "//a[contains(.,'My plans')]"),
]
def _my_plans_nav_visible(driver: WebDriver) -> bool:
"""True when the left-nav My plans entry is visible and clickable."""
for by, sel in _MY_PLANS_NAV_LOCATORS:
try:
for el in driver.find_elements(by, sel):
if el.is_displayed() and el.is_enabled():
return True
except Exception:
continue
return False
def _left_nav_pane_collapsed(driver: WebDriver) -> bool:
"""True when the Planner hamburger menu shows aria-expanded=false."""
for by, sel in _LEFT_NAV_TOGGLE_LOCATORS:
try:
for btn in driver.find_elements(by, sel):
if not btn.is_displayed():
continue
expanded = (btn.get_attribute("aria-expanded") or "").strip().lower()
return expanded != "true"
except Exception:
continue
return False
def _ensure_left_nav_open(driver: WebDriver) -> None:
"""
Expand the left navigation pane when collapsed.
On My tasks grid the hamburger (aria-label='Left navigation pane') is often
closed — My plans is hidden until it is clicked.
"""
if _my_plans_nav_visible(driver) and not _left_nav_pane_collapsed(driver):
return
if _my_plans_nav_visible(driver):
return
log_step("Open left navigation pane")
_log.info("Left nav collapsed — clicking navigation pane toggle before My plans")
try:
_click_any(driver, _LEFT_NAV_TOGGLE_LOCATORS, timeout=WAIT_SHORT)
time.sleep(1)
except TimeoutException:
_log.warning("Left navigation pane toggle not found")
return
try:
_wait(driver, WAIT_MED).until(_my_plans_nav_visible)
log_step("Left navigation pane opened")
except TimeoutException:
_log.warning("My plans not visible after opening left navigation pane")
def open_my_plans_shared(driver: WebDriver) -> None:
"""
Left nav: My plans -> Shared tab.
This matches your current UI flow and avoids search-based plan opening.
"""
_recover_planner_error(driver)
log_step("Navigate: My plans -> Shared")
log_url(driver, "before")
driver.switch_to.default_content()
_switch_to_planner_context(driver)
_ensure_left_nav_open(driver)
log_step("Click: My plans")
_click_any(driver, _MY_PLANS_NAV_LOCATORS, timeout=WAIT_LONG)
time.sleep(2)
shared_locators = [
(By.XPATH, "//*[self::button or self::div or self::span][normalize-space()='Shared']"),
(By.XPATH, "//*[@role='tab' and normalize-space()='Shared']"),
]
log_step("Click: Shared tab")
_click_any(driver, shared_locators, timeout=WAIT_LONG)
time.sleep(2)
log_url(driver, "shared_list")
log_step("Opened My plans -> Shared")
def _facility_search_terms(facility_name: str) -> list[str]:
"""Filter queries to try: full name, then first 3 words, then first 2."""
name = (facility_name or "").strip()
if not name:
return []
words = name.split()
terms: list[str] = []
seen: set[str] = set()
def _add(term: str) -> None:
t = term.strip()
if not t:
return
key = t.lower()
if key not in seen:
seen.add(key)
terms.append(t)
_add(name)
if len(words) >= 3:
_add(" ".join(words[:3]))
if len(words) >= 2:
_add(" ".join(words[:2]))
return terms
def _shared_list_data_rows(driver: WebDriver) -> list:
"""Visible data rows on Shared plans grid (exclude header)."""
rows: list = []
for row in driver.find_elements(By.CSS_SELECTOR, "[role='row']"):
try:
if not row.is_displayed():
continue
txt = (row.text or "").strip()
if not txt:
continue
low = txt.lower()
if low.startswith("name") and "shared" in low:
continue
rows.append(row)
except StaleElementReferenceException:
continue
return rows
def _row_display_name(row) -> str:
return ((row.text or "").strip().splitlines() or [""])[0].strip()
def _row_matches_facility_search(row_name: str, facility_name: str, search_term: str) -> bool:
"""True when a filter/list row matches the facility we want."""
name = (row_name or "").strip().lower()
if not name:
return False
full = (facility_name or "").strip().lower()
term = (search_term or "").strip().lower()
if name == full or full in name or name in full:
return True
if term and (term in name or name.startswith(term)):
return True
words = (facility_name or "").split()
if len(words) >= 2:
short2 = " ".join(words[:2]).lower()
if short2 in name:
return True
if len(words) >= 3:
short3 = " ".join(words[:3]).lower()
if short3 in name:
return True
return False
def _filter_shows_no_results(driver: WebDriver) -> bool:
for xpath in _FILTER_EMPTY_STATE_XPATHS:
try:
for el in driver.find_elements(By.XPATH, xpath):
if el.is_displayed():
return True
except Exception:
continue
return len(_shared_list_data_rows(driver)) == 0
def _type_facility_filter(driver: WebDriver, query: str) -> None:
"""Type a keyword into the Shared plans filter box."""
try:
_click_any(driver, _FILTER_BUTTON_LOCATORS, timeout=3)
time.sleep(0.4)
except TimeoutException:
pass
typed = False
for by, sel in _FILTER_INPUT_LOCATORS:
try:
el = driver.find_element(by, sel)
if el.is_displayed() and el.is_enabled():
_js_click(driver, el)
el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE)
el.send_keys(query)
try:
el.send_keys(Keys.ENTER)
except Exception:
pass
typed = True
break
except Exception:
continue
if not typed:
_type_into(driver, _FILTER_INPUT_LOCATORS, query, clear=True)
try:
_first_clickable(driver, _FILTER_INPUT_LOCATORS, timeout=3).send_keys(Keys.ENTER)
except Exception:
pass
time.sleep(1.2)
try:
_wait(driver, WAIT_MED).until(
lambda d: bool(_shared_list_data_rows(d))
or _filter_shows_no_results(d)
)
except TimeoutException:
pass
def _click_row_open_plan(driver: WebDriver, row) -> None:
for xpath in (
".//a[1]",
".//button[1]",
".//*[@role='gridcell']//a[1]",
".//*[@role='gridcell']//button[1]",
".//*[@role='gridcell']",
):
try:
el = row.find_element(By.XPATH, xpath)
if el.is_displayed() and el.is_enabled():
_js_click(driver, el)
return
except Exception:
continue
ActionChains(driver).double_click(row).perform()
def _open_facility_from_shared_list(driver: WebDriver, facility_name: str) -> None:
"""
From My plans -> Shared list, try clicking the facility directly from the list
(scrolling if needed). If it cannot be found, fall back to Filter by keyword
(type + Enter) and click the first row.
After clicking a facility, wait 30s for the plan page. If it is still not ready,
refresh and reopen the facility once.
"""
log_step("Open facility from Shared list", facility=facility_name)
log_url(driver, "shared")
_agent_log(
"F1",
"src/onedrive_planner.py:_open_facility_from_shared_list",
"Enter",
{"facility": facility_name[:80], "url": (driver.current_url or "")[:160]},
)
def _plan_loaded(d: WebDriver) -> bool:
return _plan_page_ready(d)
def _click_filter_result_row(search_term: str) -> bool:
rows = _shared_list_data_rows(driver)
if not rows:
return False
chosen = None
for row in rows:
name = _row_display_name(row)
if _row_matches_facility_search(name, facility_name, search_term):
chosen = row
break
if chosen is None:
chosen = rows[0]
row_name = _row_display_name(chosen)
log_step(
"Click filter result row",
facility=facility_name,
row=row_name[:60],
query=search_term[:40],
)
_agent_dbg_eb(
"F3",
"onedrive_planner:_open_facility_from_shared_list",
"filter_row_click",
{"row": row_name[:80], "query": search_term[:40]},
)
_click_row_open_plan(driver, chosen)
time.sleep(0.5)
return True
def _try_filter_search() -> bool:
for term in _facility_search_terms(facility_name):
log_step("Filter by keyword", facility=facility_name, query=term[:60])
_type_facility_filter(driver, term)
if _filter_shows_no_results(driver):
log_step("Filter returned no results", query=term[:60], facility=facility_name)
# A Planner error overlay may be blocking the search UI — try to recover
if _recover_planner_error(driver):
log_step("Recovered from error overlay during filter search", facility=facility_name)
# Re-open My plans -> Shared after the page refreshed
_click_any(driver, _MY_PLANS_NAV_LOCATORS, timeout=WAIT_LONG)
time.sleep(2)
shared_locators = [
(By.XPATH, "//*[self::button or self::div or self::span][normalize-space()='Shared']"),
(By.XPATH, "//*[@role='tab' and normalize-space()='Shared']"),
]
try:
_click_any(driver, shared_locators, timeout=WAIT_LONG)
time.sleep(2)
except TimeoutException:
pass
_type_facility_filter(driver, term)
if _filter_shows_no_results(driver):
continue
else:
continue
rows = _shared_list_data_rows(driver)
log_step(
"Filter results",
rows=len(rows),
query=term[:60],
facility=facility_name,
)
if rows and _click_filter_result_row(term):
return True
return False
def _click_facility_from_list() -> bool:
"""
Try to click the facility in the visible Shared list without typing.
Scrolls the grid to load more rows.
"""
target = facility_name.strip().lower()
if not target:
return False
grid_body_locators = [
(By.XPATH, "//*[contains(@class,'DataGridBody')]"),
(By.XPATH, "//*[@role='rowgroup']"),
]
try:
grid = _first_clickable(driver, grid_body_locators, timeout=5)
except Exception:
grid = None
def _rows():
# Some builds don't expose aria-rowindex. Use role=row and filter out header-like rows.
rows = driver.find_elements(By.CSS_SELECTOR, "[role='row']")
filtered = []
for r in rows:
try:
if not r.is_displayed():
continue
txt = (r.text or "").strip().lower()
if not txt:
continue
# Skip header rows that contain column names
if txt.startswith("name") and "shared" in txt:
continue
filtered.append(r)
except Exception:
continue
return filtered
for _ in range(30):
for row in _rows():
try:
if not row.is_displayed():
continue
txt = (row.text or "").strip()
if not txt:
continue
name = txt.splitlines()[0].strip()
key = name.lower()
if key in _PROCESSED_SHARED_FACILITIES:
continue
if not _row_matches_facility_search(name, facility_name, facility_name):
continue
log_step("Matched facility in Shared list", facility=facility_name, row=name)
_agent_log(
"F2",
"src/onedrive_planner.py:_open_facility_from_shared_list",
"Matched facility in list",
{"facility": facility_name[:80], "row_name": name[:80]},
)
_click_row_open_plan(driver, row)
_PROCESSED_SHARED_FACILITIES.add(key)
return True
except StaleElementReferenceException:
continue
try:
if grid is not None:
driver.execute_script("arguments[0].scrollTop = arguments[0].scrollTop + 700;", grid)
else:
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.PAGE_DOWN)
except Exception:
pass
time.sleep(0.6)
return False
driver.switch_to.default_content()
ctx_ok = _switch_to_planner_context(driver)
_agent_log(
"F1",
"src/onedrive_planner.py:_open_facility_from_shared_list",
"After switch_to_planner_context",
{"ok": bool(ctx_ok), "url": (driver.current_url or "")[:160]},
)
def _click_facility_open() -> None:
opened = False
if _click_facility_from_list():
log_step("Opened facility from visible list", facility=facility_name)
opened = True
elif _try_filter_search():
log_step("Opened facility from filter search", facility=facility_name)
opened = True
if not opened:
tried = _facility_search_terms(facility_name)
log_step(
"Facility not appear in Shared search",
facility=facility_name,
queries="; ".join(tried[:3]),
)
_agent_dbg_eb(
"F4",
"onedrive_planner:_open_facility_from_shared_list",
"not_appear",
{"facility": facility_name[:80], "queries": tried},
)
raise FacilityNotFoundError(f"{FACILITY_NOT_APPEAR_MSG}: {facility_name}")
print(f"[MISF] Facility clicked: {facility_name}", flush=True)
log_step("Facility clicked", facility=facility_name)
def _wait_after_facility_click() -> None:
print(
f"[MISF] Waiting up to {POST_FACILITY_CLICK_WAIT}s for plan page to load "
f"({facility_name})...",
flush=True,
)
log_step(
"Wait after facility click",
facility=facility_name,
seconds=POST_FACILITY_CLICK_WAIT,
)
remaining = POST_FACILITY_CLICK_WAIT
poll_secs = 2
while remaining > 0:
if _plan_load_error_present(driver):
log_step("Plan load error during wait", facility=facility_name)
_click_plan_try_again(driver)
if _plan_loaded(driver):
waited = POST_FACILITY_CLICK_WAIT - remaining
print(
f"[MISF] Plan page loaded after {waited}s (stopped early).",
flush=True,
)
log_step(
"Plan loaded during wait",
facility=facility_name,
waited_secs=waited,
)
return
chunk = min(poll_secs, remaining)
time.sleep(chunk)
remaining -= chunk
if remaining > 0:
print(f"[MISF] ... {remaining}s remaining", flush=True)
print(f"[MISF] Done waiting {POST_FACILITY_CLICK_WAIT} seconds.", flush=True)
log_step("Finished wait after facility click", facility=facility_name, seconds=POST_FACILITY_CLICK_WAIT)
def _finish_if_plan_ready() -> bool:
if _plan_load_error_present(driver):
log_step("Plan load error before finish", facility=facility_name)
_click_plan_try_again(driver)
time.sleep(4)
if not _plan_loaded(driver):
focus_facility_plan_tab(driver)
if _plan_load_error_present(driver):
_click_plan_try_again(driver)
time.sleep(4)
if not _plan_loaded(driver):
return False
focus_facility_plan_tab(driver)
log_url(driver, "plan_loaded")
log_step("Plan loaded", facility=facility_name)
time.sleep(POST_FACILITY_LOAD_DELAY)
log_step("Post-load stabilize complete", seconds=POST_FACILITY_LOAD_DELAY)
return True
def _refresh_and_reopen_facility() -> None:
log_step("Plan still not ready — refreshing page", facility=facility_name)
_log.warning("Plan not ready after %ss wait — refreshing: %s", POST_FACILITY_CLICK_WAIT, facility_name)
driver.refresh()
time.sleep(5)
driver.switch_to.default_content()
_switch_to_planner_context(driver)
open_my_plans_shared(driver)
_click_facility_open()
_wait_after_facility_click()
# Primary: click from list (scroll). Fallback: filter + first row.
try:
_click_facility_open()
except Exception:
log_step("Facility open failed — will refresh and retry", facility=facility_name)
_log.warning("Facility open failed; refreshing and retrying: %s", facility_name)
driver.refresh()
time.sleep(5)
driver.switch_to.default_content()
_switch_to_planner_context(driver)
open_my_plans_shared(driver)
_click_facility_open()
_wait_after_facility_click()
if _finish_if_plan_ready():
return
_refresh_and_reopen_facility()
if _finish_if_plan_ready():
log_step("Plan loaded after refresh", facility=facility_name)
return
log_step("Wait for plan after refresh", timeout_secs=FACILITY_LOAD_AFTER_REFRESH)
try:
_wait(driver, FACILITY_LOAD_AFTER_REFRESH).until(_plan_loaded)
time.sleep(POST_FACILITY_LOAD_DELAY)
log_step("Plan loaded after refresh (waited)", facility=facility_name)
except TimeoutException as exc:
raise TimeoutException(
f"Plan did not load for facility '{facility_name}' after "
f"{POST_FACILITY_CLICK_WAIT}s wait + refresh"
) from exc
def _ensure_board_view(driver: WebDriver) -> None:
board_locators = [
(By.XPATH, "//*[@role='tab' and normalize-space()='Board']"),
(By.XPATH, "//*[self::button or self::div][normalize-space()='Board']"),
]
try:
log_step("Switch to Board view")
_click_any(driver, board_locators, timeout=8)
time.sleep(2)
log_url(driver, "board")
except TimeoutException:
log_step("Board tab not found (continuing on current view)")
def _wait_for_board_loaded(driver: WebDriver, timeout: int = 20) -> None:
"""Switch to Board tab and wait until board content is visible before screenshotting."""
board_locators = [
(By.XPATH, "//*[@role='tab' and normalize-space()='Board']"),
(By.XPATH, "//*[self::button or self::div][normalize-space()='Board']"),
]
# Click the Board tab (ignore if already on board or tab not found)
try:
tab = _first_clickable(driver, board_locators, timeout=8)
# Only click if the tab isn't already selected
selected = (tab.get_attribute("aria-selected") or "").lower()
if selected != "true":
log_step("Switch to Board view")
_js_click(driver, tab)
else:
log_step("Board tab already active")
except TimeoutException:
log_step("Board tab not found (continuing on current view)")
# Poll until board content is visible (task cards, bucket columns, or Add task)
content_xpaths = [
"//button[@data-testid='attachment-indicator']",
"//*[@role='checkbox']",
"//label[@role='checkbox']",
"//*[contains(@data-testid,'TaskCard') or contains(@class,'TaskCard')]",
"//*[@role='list' and ancestor::*[@role='group']]",
"//*[normalize-space()='Add task' and ancestor::*[@role='group']]",
"//*[normalize-space()='Add task']",
]
log_step("Waiting for board content to load")
deadline = time.time() + timeout
while time.time() < deadline:
for xpath in content_xpaths:
try:
for el in driver.find_elements(By.XPATH, xpath)[:6]:
if el.is_displayed():
log_step("Board content visible — ready for screenshot")
return
except Exception:
continue
time.sleep(0.5)
log_step("Board content wait timed out — proceeding with screenshot anyway")
def _ensure_grid_view(driver: WebDriver) -> None:
grid_locators = [
(By.CSS_SELECTOR, "button#gridViewPivot"),
(By.XPATH, "//*[@role='tab' and normalize-space()='Grid']"),
(By.XPATH, "//button[@value='grid' and @id='gridViewPivot']"),
(By.XPATH, "//*[self::button or self::div][normalize-space()='Grid']"),
]
try:
log_step("Switch to Grid view")
_click_any(driver, grid_locators, timeout=8)
time.sleep(PANEL_TAB_LOAD_DELAY)
log_url(driver, "grid")
except TimeoutException:
log_step("Grid tab not found (continuing on current view)")
def _status_column_header_locators() -> list[tuple[str, str]]:
return [
(By.CSS_SELECTOR, "#ColumnHeaderCell_status"),
(By.CSS_SELECTOR, "#ColumnHeaderCell_progress"),
(By.XPATH, "//div[@role='columnheader' and @aria-label='Status']"),
(By.XPATH, "//div[@role='columnheader' and @aria-label='Progress']"),
]
def _is_status_sorted_descending(header) -> bool:
sort = (header.get_attribute("aria-sort") or "").strip().lower()
return sort == "descending"
def _sort_descending_menu_selected(driver: WebDriver) -> bool:
"""True when the column header menu shows Sort descending as active."""
probes = [
"//button[@name='Sort descending' and @aria-checked='true']",
"//button[@name='Sort descending' and contains(@class,'is-checked')]",
"//button[@name='Sort descending']//*[@data-icon-name='CheckMark']",
(
"//button[@name='Sort descending']"
"/ancestor::li[contains(@class,'is-checked') or contains(@class,'is-selected')]"
),
]
for xpath in probes:
try:
for el in driver.find_elements(By.XPATH, xpath):
if el.is_displayed():
return True
except StaleElementReferenceException:
continue
return False
def _dismiss_open_menu(driver: WebDriver) -> None:
try:
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(0.3)
except Exception:
pass
def _ensure_status_sort_descending(driver: WebDriver) -> None:
"""Open Status column menu and apply Sort Descending when not already set."""
log_step("Ensure Status column sorted descending")
try:
header = _first_clickable(driver, _status_column_header_locators(), timeout=8)
except TimeoutException:
log_step("Status column header not found (continuing on current sort)")
return
if _is_status_sorted_descending(header):
log_step("Status column already sorted descending")
return
_js_click(driver, header)
time.sleep(0.5)
if _sort_descending_menu_selected(driver):
log_step("Status column already sorted descending (menu check)")
_dismiss_open_menu(driver)
return
sort_locators = [
# Exact match — actual Planner DOM uses lowercase 'd'
(By.XPATH, "//button[@name='Sort descending' and @role='menuitem']"),
(By.XPATH, "//ul[contains(@class,'ContextualMenu')]//button[normalize-space()='Sort descending']"),
(By.XPATH, "//button[@role='menuitem' and normalize-space()='Sort descending']"),
(By.XPATH, "//span[normalize-space()='Sort descending']/ancestor::button[@role='menuitem']"),
# Uppercase fallback (in case Planner localises differently)
(By.XPATH, "//button[@name='Sort Descending' and @role='menuitem']"),
(By.XPATH, "//button[@role='menuitem' and normalize-space()='Sort Descending']"),
# Broad contains — catches any capitalisation
(By.XPATH, "//*[@role='menuitem' and contains(normalize-space(),'Sort descending')]"),
(By.XPATH, "//*[@role='menuitem' and contains(normalize-space(),'Sort Descending')]"),
(By.XPATH, "//li[contains(@class,'ms-ContextualMenu-item')]//button[contains(normalize-space(),'escending')]"),
]
try:
_click_any(driver, sort_locators, timeout=5)
time.sleep(1)
try:
header = _first_clickable(driver, _status_column_header_locators(), timeout=3)
if _is_status_sorted_descending(header):
log_step("Status column sorted descending")
else:
log_step("Status column sort descending applied (aria-sort not confirmed)")
except TimeoutException:
log_step("Status column sort descending clicked")
except TimeoutException:
log_step("Sort descending menu item not found via XPath — trying JS click")
clicked = False
try:
clicked = driver.execute_script(
"""
var items = Array.from(document.querySelectorAll(
'[role="menuitem"], [role="menuitem"] button, button[role="menuitem"]'
));
var target = items.find(function(el) {
var t = (el.innerText || el.textContent || '').trim().toLowerCase();
return t === 'sort descending';
});
if (target) { target.click(); return true; }
return false;
"""
)
except Exception:
pass
if clicked:
time.sleep(1)
log_step("Sort descending clicked via JS")
else:
log_step("Sort descending not found (continuing on current sort)")
_dismiss_open_menu(driver)
def _grid_probes_present(driver: WebDriver) -> bool:
probes = (
"[role='grid'].grid-container",
".grid-container[role='grid']",
"div.grid-container",
"button#gridViewPivot",
)
for sel in probes:
for el in driver.find_elements(By.CSS_SELECTOR, sel):
if el.is_displayed():
return True
return False
def _grid_scrollable_element(driver: WebDriver):
for sel in (
".grid-virtualized-container",
"[role='grid'].grid-container",
".grid-container[role='grid']",
):
for el in driver.find_elements(By.CSS_SELECTOR, sel):
if el.is_displayed():
return el
return None
def _grid_visible_data_rows(driver: WebDriver) -> list:
rows: list = []
for row in driver.find_elements(By.CSS_SELECTOR, "div[role='row'][aria-rowindex]"):
try:
if not row.is_displayed():
continue
idx_raw = row.get_attribute("aria-rowindex") or ""
if idx_raw.isdigit() and int(idx_raw) < 1:
continue
rows.append(row)
except StaleElementReferenceException:
continue
return rows
def _parse_status_blob(blob: str) -> str | None:
low = (blob or "").strip().lower()
if not low:
return None
if "not started" in low or low in ("notstarted", "not_started"):
return "not started"
if "in progress" in low or low in ("inprogress", "in_progress"):
return "in progress"
if "completed" in low or low == "completed":
return "completed"
return None
def _grid_row_status(row) -> str | None:
"""Read Status column: not started / in progress / completed."""
try:
for cell in row.find_elements(By.XPATH, ".//*[@role='gridcell']"):
aria = (cell.get_attribute("aria-label") or "").strip()
if not aria.lower().startswith("status"):
continue
for btn in cell.find_elements(By.XPATH, ".//button[@role='combobox']"):
if not btn.is_displayed():
continue
parsed = _parse_status_blob(
(btn.get_attribute("value") or "") + " " + (btn.text or "")
)
if parsed:
return parsed
parsed = _parse_status_blob((cell.text or "") + " " + aria)
if parsed:
return parsed
for btn in row.find_elements(By.XPATH, ".//button[@role='combobox']"):
if not btn.is_displayed():
continue
parsed = _parse_status_blob(
(btn.get_attribute("value") or "") + " " + (btn.text or "")
)
if parsed:
return parsed
for cell in row.find_elements(By.XPATH, ".//*[@role='gridcell']"):
parsed = _parse_status_blob(cell.text or "")
if parsed:
return parsed
except StaleElementReferenceException:
return None
return None
def _grid_row_task_name(row) -> str:
try:
for cell in row.find_elements(By.XPATH, ".//*[@role='gridcell']"):
aria = (cell.get_attribute("aria-label") or "").strip()
if aria.lower().startswith("task name"):
# e.g. "Task Name Missing Insurance Info"
prefix = "task name"
rest = _clean_grid_task_title(aria[len(prefix) :].strip())
if rest:
return rest
for span in cell.find_elements(By.XPATH, ".//span[normalize-space()]"):
text = _clean_grid_task_title((span.text or "").strip())
if not text:
continue
low = text.lower()
if low in ("not started", "in progress", "completed", "to do", "important", "medium"):
continue
if len(text) > 2:
return text
for line in (row.text or "").splitlines():
line = _clean_grid_task_title(line.strip())
if line and line.lower() not in ("not started", "in progress", "completed", "to do"):
return line
except StaleElementReferenceException:
return ""
return ""
def _grid_task_name_cell(row):
try:
for cell in row.find_elements(By.XPATH, ".//*[@role='gridcell']"):
aria = (cell.get_attribute("aria-label") or "").lower()
if aria.startswith("task name"):
return cell
cells = row.find_elements(By.XPATH, ".//*[@role='gridcell']")
return cells[0] if cells else None
except StaleElementReferenceException:
return None
def _open_grid_row_details(driver: WebDriver, row) -> bool:
"""Hover task name, click Open details icon."""
try:
name_cell = _grid_task_name_cell(row)
if name_cell is None:
return False
_hover_element(driver, name_cell)
time.sleep(0.6)
open_xpaths = (
".//button[contains(@aria-label,'Open details')]",
".//*[@data-testid='quick-actions-wrapper']//button[1]",
".//button[contains(@aria-label,'details')]",
)
for xpath in open_xpaths:
for btn in row.find_elements(By.XPATH, xpath):
if not btn.is_displayed():
continue
_js_click(driver, btn)
time.sleep(PANEL_TAB_LOAD_DELAY)
if _task_details_panel_open(driver):
time.sleep(PANEL_TAB_LOAD_DELAY)
log_step("Opened task via grid Open details")
return True
for btn in name_cell.find_elements(By.XPATH, ".//button"):
if not btn.is_displayed():
continue
aria = (btn.get_attribute("aria-label") or "").lower()
if "open details" in aria or aria == "open details (alt+i)":
_js_click(driver, btn)
time.sleep(PANEL_TAB_LOAD_DELAY)
if _task_details_panel_open(driver):
time.sleep(PANEL_TAB_LOAD_DELAY)
log_step("Opened task via grid Open details (name cell)")
return True
except StaleElementReferenceException:
return False
return False
def _scroll_grid_to_top(driver: WebDriver) -> None:
scroll_el = _grid_scrollable_element(driver)
if scroll_el is not None:
driver.execute_script("arguments[0].scrollTop = 0;", scroll_el)
time.sleep(0.4)
def _scroll_grid_down(driver: WebDriver, pixels: int = 450) -> None:
scroll_el = _grid_scrollable_element(driver)
if scroll_el is not None:
driver.execute_script("arguments[0].scrollTop += arguments[1];", scroll_el, pixels)
else:
driver.execute_script("window.scrollBy(0, arguments[0]);", pixels)
time.sleep(0.4)
def _scan_grid_not_started_titles(driver: WebDriver, *, max_scrolls: int = 100) -> list[tuple[str, int]]:
"""Scroll the virtualized grid and collect (title, aria-rowindex) for Not started tasks.
Uses aria-rowindex as the unique key so duplicate task names are all captured.
"""
_scroll_grid_to_top(driver)
seen_indices: set[int] = set()
tasks: list[tuple[str, int]] = []
stagnant = 0
for _ in range(max_scrolls):
new_this_pass = 0
for row in _grid_visible_data_rows(driver):
status = _grid_row_status(row)
if status != "not started":
continue
idx_raw = row.get_attribute("aria-rowindex") or ""
row_idx = int(idx_raw) if idx_raw.isdigit() else -1
if row_idx < 1 or row_idx in seen_indices:
continue
title = _grid_row_task_name(row).strip()
if not title or _is_weak_task_title_key(title.lower()):
continue
seen_indices.add(row_idx)
tasks.append((title, row_idx))
new_this_pass += 1
if new_this_pass == 0:
stagnant += 1
if stagnant >= 4:
break
else:
stagnant = 0
_scroll_grid_down(driver)
_scroll_grid_to_top(driver)
log_step("Grid not-started tasks scanned", count=len(tasks))
return tasks
def _find_grid_row_by_index(driver: WebDriver, row_index: int, *, max_scrolls: int = 100):
"""Find a grid row by its aria-rowindex — stable even when multiple rows share the same title."""
if row_index < 1:
return None
_scroll_grid_to_top(driver)
for _ in range(max_scrolls):
for row in _grid_visible_data_rows(driver):
idx_raw = row.get_attribute("aria-rowindex") or ""
if idx_raw.isdigit() and int(idx_raw) == row_index:
return row
_scroll_grid_down(driver)
return None
def _find_grid_row_by_title(driver: WebDriver, title: str, *, max_scrolls: int = 100):
want = _clean_grid_task_title(title).strip().lower()
if not want:
return None
_scroll_grid_to_top(driver)
for _ in range(max_scrolls):
for row in _grid_visible_data_rows(driver):
name = _grid_row_task_name(row).strip().lower()
if name == want or (name and (want in name or name in want)):
return row
_scroll_grid_down(driver)
return None
def _panel_task_status(driver: WebDriver) -> str | None:
"""Read Not started / In progress / Completed from the open task panel."""
_task_details_tab_locators = [
(By.XPATH, "//*[@role='tab' and normalize-space()='Task details']"),
(By.XPATH, "//*[@role='tab' and contains(normalize-space(),'Task details')]"),
(By.XPATH, "//button[normalize-space()='Task details']"),
(By.XPATH, "//*[self::button or self::div][normalize-space()='Task details']"),
]
panel_status = None
for _ps_round in range(2):
try:
_click_any(driver, _task_details_tab_locators, timeout=WAIT_SHORT)
time.sleep(PANEL_TAB_LOAD_DELAY)
except Exception:
log_step("Task details tab not clickable for status check")
for _ps_attempt in range(4):
panel_status = _task_details_progress_status(driver)
if panel_status:
break
time.sleep(1.0)
if panel_status:
break
return panel_status
def _ensure_board_scan_context(driver: WebDriver) -> str:
"""
Leave WebDriver focused on the document/frame that contains the plan board.
planner.cloud.microsoft plan/board often renders in default_content, not an iframe.
"""
driver.switch_to.default_content()
if _board_probes_present(driver):
log_step("Board scan context", frame="default_content")
return "default_content"
if _switch_to_planner_context(driver) and _board_probes_present(driver):
log_step("Board scan context", frame="planner_iframe")
return "planner_iframe"
driver.switch_to.default_content()
for idx, fr in enumerate(driver.find_elements(By.TAG_NAME, "iframe")):
try:
driver.switch_to.default_content()
driver.switch_to.frame(fr)
if _board_probes_present(driver):
log_step("Board scan context", frame=f"iframe_{idx}")
return f"iframe_{idx}"
except Exception:
continue
driver.switch_to.default_content()
log_step("Board scan context", frame="default_content_fallback")
return "default_content_fallback"
def _find_in_best_board_context(driver: WebDriver, xpath: str) -> list:
"""Search default content and all iframes; stay in the frame with the most matches."""
best: list = []
best_switch = None
def _count_in_current() -> list:
visible = []
for el in driver.find_elements(By.XPATH, xpath):
try:
if el.is_displayed():
visible.append(el)
except StaleElementReferenceException:
continue
return visible
driver.switch_to.default_content()
default_hits = _count_in_current()
if len(default_hits) >= len(best):
best = default_hits
best_switch = lambda: driver.switch_to.default_content()
driver.switch_to.default_content()
for idx, fr in enumerate(driver.find_elements(By.TAG_NAME, "iframe")):
try:
driver.switch_to.default_content()
driver.switch_to.frame(fr)
hits = _count_in_current()
if len(hits) > len(best):
def _make_switch(f=fr):
def _sw():
driver.switch_to.default_content()
driver.switch_to.frame(f)
return _sw
best = hits
best_switch = _make_switch()
except Exception:
continue
if best_switch is not None:
best_switch()
return best
def _wait_for_board_ready(driver: WebDriver, timeout: float = 45.0) -> None:
"""Wait until task cards or attachment indicators appear on the board."""
_ensure_board_scan_context(driver)
deadline = time.time() + timeout
while time.time() < deadline:
if _find_in_best_board_context(
driver,
"//button[@data-testid='attachment-indicator']",
):
return
if _find_in_best_board_context(driver, "//label[@role='checkbox' and @aria-checked='false']"):
return
if _find_in_best_board_context(driver, "//*[@role='checkbox' and @aria-checked='false']"):
return
if driver.find_elements(By.CSS_SELECTOR, "[data-testid='task-board-column-body']"):
return
time.sleep(0.5)
log_step("Board task elements not visible yet (continuing scan)")
_TASK_CARD_ANCESTOR_XPATHS = [
*_PLANNER_TASK_CARD_XPATHS,
# UiPath: single card = checkbox + title textbox (tightest match).
"./ancestor::div[(.//label[@role='checkbox'] or .//button[@role='checkbox']) and .//*[@role='textbox']][1]",
"./ancestor::button[(.//label[@role='checkbox'] or .//button[@role='checkbox']) and .//*[@role='textbox']][1]",
"./ancestor::div[.//button[@data-testid='attachment-indicator']][(.//label[@role='checkbox'] or .//button[@role='checkbox'])][1]",
"./ancestor::div[(.//label[@role='checkbox'] or .//button[@role='checkbox']) and (.//*[@role='textbox'] or .//*[contains(@class,'textbox')])][1]",
"./ancestor::div[.//button[@data-testid='attachment-indicator']][1]",
"./ancestor::*[contains(@data-testid,'task')][1]",
"./ancestor::button[contains(@data-testid,'task') or contains(@class,'Task')][1]",
"./ancestor::div[contains(@class,'TaskCard') or contains(@class,'taskCard')][1]",
"./ancestor::div[contains(@class,'baseCard')][1]",
# Sibling-layout: checkbox and indicator share a parent/grandparent without nesting
# (occurs when task-card is a <button> and the indicator is moved outside by the browser).
"./parent::*[.//button[@data-testid='attachment-indicator']]",
"./parent::*/parent::*[.//button[@data-testid='attachment-indicator'] and .//label[@role='checkbox']]",
]
def _is_probable_single_task_card(card) -> bool:
"""Reject board/column wrappers that contain many tasks (false Completed-task matches)."""
try:
if not card.is_displayed():
return False
indicators = [
e
for e in card.find_elements(By.XPATH, ".//button[@data-testid='attachment-indicator']")
if e.is_displayed()
]
checkboxes = [
e
for e in card.find_elements(
By.XPATH,
".//label[@role='checkbox'] | .//button[@role='checkbox']",
)
if e.is_displayed()
]
textboxes = [
e for e in card.find_elements(By.XPATH, ".//*[@role='textbox']") if e.is_displayed()
]
# Each task has exactly one completion checkbox; more than one means a column wrapper.
# Multiple indicators are fine — a task can have many attachments.
# Cap at 8 indicators: a real task won't have more; a column wrapper with 8+ tasks
# passes this even when only 1 checkbox is rendered (virtualized column).
if len(checkboxes) > 1 or len(textboxes) > 1 or len(indicators) > 8:
return False
if indicators or checkboxes or textboxes:
return True
# Clickable card body without nested structure counts.
tag = (card.tag_name or "").lower()
return tag in ("button", "div", "a")
except StaleElementReferenceException:
return False
def _is_planner_bucket_column(el) -> bool:
"""Planner bucket column wrapper (not a single task card)."""
try:
blob = (
(el.get_attribute("aria-label") or "")
+ " "
+ (el.text or "")[:240]
).lower()
if "use tab key to access a task card" in blob:
return True
title = _bucket_column_title(el)
if not title or not _is_active_bucket_title(title):
return False
markers = [
m
for m in el.find_elements(
By.XPATH,
".//button[@data-testid='attachment-indicator'] | .//label[@role='checkbox']",
)
if m.is_displayed()
]
return len(markers) >= 2
except StaleElementReferenceException:
return False
def _is_attachment_indicator_button(el) -> bool:
try:
return (el.get_attribute("data-testid") or "").strip() == "attachment-indicator"
except StaleElementReferenceException:
return False
def _is_weak_task_title_key(title_key: str) -> bool:
"""Ignore paperclip counts and bucket a11y text when deduping tasks."""
t = (title_key or "").strip().lower()
if not t:
return True
if t.isdigit():
return True
if "use tab key" in t:
return True
return _is_panel_placeholder_text(t)
def _is_panel_placeholder_text(text: str) -> bool:
"""Planner panel placeholders — not real task titles."""
low = (text or "").strip().lower()
if not low:
return True
placeholders = (
"type a description",
"add notes here",
"enter a task name",
"press alt",
"open task details",
)
return any(p in low for p in placeholders)
def _clean_grid_task_title(text: str) -> str:
"""Strip grid a11y hints (e.g. 'Press Alt + I to open task details')."""
t = (text or "").strip()
for sep in (". Press Alt", ". press alt", " Press Alt", " press alt"):
if sep in t:
t = t.split(sep, 1)[0].strip()
return t[:80]
def _clickable_from_attachment_indicator(ind) -> object:
"""Smallest single-task card ancestor for a paperclip button (never the To do column)."""
candidates: list = []
xpaths = [
*_PLANNER_TASK_CARD_XPATHS,
"./ancestor::button[.//button[@data-testid='attachment-indicator']][1]",
"./ancestor::div[.//button[@data-testid='attachment-indicator']"
" and (.//label[@role='checkbox'] or .//button[@role='checkbox'])][1]",
"./ancestor::div[(.//label[@role='checkbox'] or .//button[@role='checkbox'])"
" and .//*[@role='textbox']][1]",
"./ancestor::*[contains(@data-testid,'task')][.//button[@data-testid='attachment-indicator']][1]",
"./parent::*[(.//label[@role='checkbox'] or .//button[@role='checkbox'])]",
"./parent::*/parent::*[(.//label[@role='checkbox'] or .//button[@role='checkbox'])"
" and .//button[@data-testid='attachment-indicator']]",
]
for xpath in xpaths:
try:
el = ind.find_element(By.XPATH, xpath)
if el.is_displayed():
candidates.append(el)
except NoSuchElementException:
continue
for el in candidates:
if _is_planner_bucket_column(el):
continue
if _is_probable_single_task_card(el):
return el
for el in candidates:
if not _is_planner_bucket_column(el):
return el
try:
parent = ind.find_element(By.XPATH, "./parent::*")
if parent.is_displayed() and not _is_planner_bucket_column(parent):
return parent
except NoSuchElementException:
pass
return ind
def _task_card_in_progress_on_board(task_el) -> bool:
"""Half-blue progress ring on the board card — skip without opening the panel."""
if _task_has_in_progress_status_button(task_el):
return True
try:
for cb in task_el.find_elements(
By.XPATH,
".//label[@role='checkbox'] | .//button[@role='checkbox']",
):
if not cb.is_displayed():
continue
if (cb.get_attribute("aria-checked") or "").lower() == "mixed":
return True
except StaleElementReferenceException:
return False
return False
def _filter_reject_reason(
task_el,
*,
from_indicator: bool = False,
) -> str | None:
"""Why a candidate was skipped (for debug logs).
When from_indicator=True we reject completed tasks and in-progress cards
(half-blue ring) on the board so panels are not opened unnecessarily.
Panel-level status check remains as a safety net for any edge cases.
"""
if _is_planner_bucket_column(task_el):
return "bucket_column"
if _task_is_completed(task_el):
return "task_completed"
if from_indicator:
if _is_in_completed_tasks_section(task_el):
return "completed_bucket"
if _task_card_in_progress_on_board(task_el):
return "in_progress"
return None
if not _is_in_active_planner_column_group(task_el) and _is_in_completed_tasks_section(
task_el
):
return "completed_bucket"
if _task_is_in_progress_scoped(task_el) or _task_has_in_progress_status_button(task_el):
return "in_progress"
if not _task_is_strictly_not_started(task_el):
return "not_not_started"
if not _is_probable_single_task_card(task_el):
return "not_single_card"
if not _card_has_attachment(task_el):
return "no_attachment"
if not _task_has_unchecked_complete_checkbox(task_el):
return "no_unchecked_checkbox"
return None
def _task_has_in_progress_status_button(task_el) -> bool:
"""
Half-blue progress ring on a Planner card.
DevTools: <button aria-label="In progress"> (separate from the complete checkbox).
"""
try:
for btn in task_el.find_elements(
By.XPATH,
".//button[contains(@aria-label,'In progress') or contains(@aria-label,'In-progress')]",
):
if not btn.is_displayed():
continue
aria = (btn.get_attribute("aria-label") or "").strip().lower()
if aria in ("in progress", "in-progress") or aria.startswith("in progress"):
return True
except StaleElementReferenceException:
return False
return False
def _task_has_not_started_status_button(task_el) -> bool:
"""Progress ring shows Not started (empty circle) on this card."""
try:
for btn in task_el.find_elements(
By.XPATH,
".//button[contains(@aria-label,'Not started')]",
):
if not btn.is_displayed():
continue
aria = (btn.get_attribute("aria-label") or "").strip().lower()
if aria == "not started" or aria.startswith("not started"):
return True
except StaleElementReferenceException:
return False
return False
def _task_is_strictly_not_started(task_el) -> bool:
"""
Board card is Not started only — never In progress or Completed.
In progress = half-blue button aria-label 'In progress' or mixed checkbox.
"""
if _task_is_completed(task_el) or _task_is_in_progress_scoped(task_el):
return False
if _task_has_in_progress_status_button(task_el):
return False
if _task_has_not_started_status_button(task_el):
return True
if _task_has_unchecked_complete_checkbox(task_el):
return True
return False
def _task_is_in_progress_scoped(task_el) -> bool:
"""In progress on this card only — not sibling tasks inside a bucket column."""
if _is_planner_bucket_column(task_el):
return False
try:
if _task_has_in_progress_status_button(task_el):
return True
card_aria = (task_el.get_attribute("aria-label") or "").lower()
if "in progress" in card_aria or "in-progress" in card_aria:
return True
for cb in task_el.find_elements(
By.XPATH,
".//label[@role='checkbox'] | .//button[@role='checkbox'] | .//*[@role='checkbox']",
):
if not cb.is_displayed():
continue
checked = (cb.get_attribute("aria-checked") or "").lower()
if checked == "mixed":
return True
aria = (cb.get_attribute("aria-label") or "").lower()
if "in progress" in aria or "in-progress" in aria:
return True
for cb in task_el.find_elements(By.XPATH, ".//*[@role='checkbox']"):
if not cb.is_displayed():
continue
checked = (cb.get_attribute("aria-checked") or "").lower()
if checked == "mixed":
return True
for box in task_el.find_elements(By.XPATH, ".//*[@role='textbox']"):
if not box.is_displayed():
continue
aria = (box.get_attribute("aria-label") or "").lower()
if "in progress" in aria or "in-progress" in aria:
return True
first_line = ((task_el.text or "").split("\n") or [""])[0].lower()
if "in progress" in first_line or "in-progress" in first_line:
return True
except StaleElementReferenceException:
return False
return False
def _ancestor_task_card(element):
"""Map a checkbox or attachment-indicator to one clickable Planner task card."""
candidates: list = []
for xpath in _TASK_CARD_ANCESTOR_XPATHS:
try:
card = element.find_element(By.XPATH, xpath)
if card.is_displayed():
candidates.append(card)
except NoSuchElementException:
continue
for card in candidates:
if _is_probable_single_task_card(card):
return card
if candidates:
return candidates[0]
return element
def _is_completed_tasks_header(text: str) -> bool:
"""Match only the Completed tasks bucket header, not the whole board text."""
t = (text or "").strip().lower()
if not t or len(t) > 80:
return False
if t in ("completed tasks", "completed task"):
return True
return bool(re.fullmatch(r"completed tasks\s*\d*", t))
def _bucket_column_title(container) -> str | None:
"""Bucket header on a Planner column (direct children only — not whole board)."""
try:
for child in container.find_elements(
By.XPATH,
"./child::*[self::button or self::div or self::span or self::h2 or self::h3]",
):
if not child.is_displayed():
continue
text = (child.text or child.get_attribute("aria-label") or "").strip()
if text and len(text) <= 80:
return text
except StaleElementReferenceException:
return None
return None
def _element_rect_left(driver: WebDriver, element) -> float:
try:
return float(
driver.execute_script(
"return arguments[0].getBoundingClientRect().left;",
element,
)
)
except Exception:
return float(element.location.get("x", 0))
def _scroll_board_to_left(driver: WebDriver) -> None:
"""Scroll the board horizontally to the left so column 1 is at x=0."""
try:
for sel in ("[data-testid='taskboard']", "[class*='Taskboard']", "[class*='taskboard']"):
for board in driver.find_elements(By.CSS_SELECTOR, sel):
if not board.is_displayed():
continue
driver.execute_script("arguments[0].scrollLeft = 0;", board)
time.sleep(0.3)
return
except Exception:
pass
def _parent_element(element) -> object | None:
"""
Parent DOM node, or None at document root.
ChromeDriver raises InvalidSelectorException when ``..`` would return
HTMLDocument (e.g. parent of ``<html>``).
"""
try:
tag = (getattr(element, "tag_name", None) or "").lower()
if tag == "html":
return None
parent = element.find_element(By.XPATH, "..")
except (NoSuchElementException, InvalidSelectorException):
return None
if parent is element:
return None
return parent
def _dedupe_top_level_columns(driver: WebDriver, cols: list) -> list:
top_level: list = []
for col in cols:
if any(
other is not col and _element_in_board_container(col, other)
for other in cols
):
continue
top_level.append(col)
top_level.sort(key=lambda e: _element_rect_left(driver, e))
return top_level
def _column_wrapper_from_element(start) -> object | None:
"""Walk up from a bucket header or task to the column wrapper."""
try:
current = start
for _ in range(24):
blob = (current.get_attribute("aria-label") or "").lower()
if "use tab key to access a task card" in blob:
return current
title = _bucket_column_title(current)
if title and _is_active_bucket_title(title):
markers = current.find_elements(
By.XPATH,
".//button[@data-testid='attachment-indicator']"
" | .//label[@role='checkbox']",
)
if any(m.is_displayed() for m in markers):
return current
parent = _parent_element(current)
if parent is None:
break
current = parent
except StaleElementReferenceException:
return None
return None
def _find_columns_by_bucket_headers(driver: WebDriver) -> list:
"""Find columns via visible bucket titles (e.g. 'No bucket', 'To do')."""
cols: list = []
seen: set[int] = set()
header_xpath = (
"//*[self::button or self::div or self::span or self::h2 or self::h3]"
"[normalize-space()='No bucket' or normalize-space()='To do' or "
"normalize-space()='TO DO' or normalize-space()='New Facesheets' or "
"normalize-space()='New Facesheet' or "
"contains(translate(normalize-space(),"
"'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'facesheet')]"
)
for header in driver.find_elements(By.XPATH, header_xpath):
try:
if not header.is_displayed():
continue
col = _column_wrapper_from_element(header)
if col is None:
continue
cid = id(col)
if cid in seen:
continue
seen.add(cid)
cols.append(col)
except StaleElementReferenceException:
continue
return _dedupe_top_level_columns(driver, cols)
def _find_board_bucket_columns_from_taskboard(driver: WebDriver) -> list:
"""Fallback: bucket columns inside the Planner taskboard."""
cols: list = []
column_xpaths = (
"./div",
".//div[contains(@class,'bucket') or contains(@class,'Bucket')]",
".//div[contains(@class,'Column') or contains(@class,'column')]",
".//*[contains(@aria-label,'Use tab key to access a task card')]",
)
for sel in ("[data-testid='taskboard']", "[class*='Taskboard']", "[class*='taskboard']"):
for board in driver.find_elements(By.CSS_SELECTOR, sel):
if not board.is_displayed():
continue
for rel in column_xpaths:
for child in board.find_elements(By.XPATH, rel):
try:
if not child.is_displayed():
continue
has_tasks = child.find_elements(
By.XPATH,
".//button[@data-testid='attachment-indicator']"
" | .//label[@role='checkbox']",
)
if not any(t.is_displayed() for t in has_tasks):
continue
cols.append(child)
except StaleElementReferenceException:
continue
if cols:
return _dedupe_top_level_columns(driver, cols)
return []
_FIRST_COLUMN_CLUSTER_WIDTH_PX = 220.0
def _leftmost_paperclip_indicators(driver: WebDriver) -> list:
"""
When bucket column wrappers are not found, keep paperclips in the leftmost
visual cluster (first board column on screen).
"""
pairs: list[tuple[float, object]] = []
for ind in _find_in_best_board_context(
driver,
"//button[@data-testid='attachment-indicator']",
):
try:
if not ind.is_displayed() or _is_in_completed_tasks_section(ind):
continue
pairs.append((_element_rect_left(driver, ind), ind))
except StaleElementReferenceException:
continue
if not pairs:
return []
pairs.sort(key=lambda item: item[0])
min_left = pairs[0][0]
cluster = [
ind
for left, ind in pairs
if left - min_left <= _FIRST_COLUMN_CLUSTER_WIDTH_PX
]
log_step(
"Leftmost paperclip cluster",
total=len(pairs),
cluster=len(cluster),
min_left=int(min_left),
)
return cluster
def _all_page_paperclip_indicators(driver: WebDriver) -> list:
"""
All visible paperclip indicators on the entire page (not restricted to any column).
Returns every visible attachment-indicator button. Status filtering is
deferred to the panel-level check after opening each task.
"""
out: list = []
for ind in _find_in_best_board_context(
driver,
"//button[@data-testid='attachment-indicator']",
):
try:
if not ind.is_displayed():
continue
out.append(ind)
except StaleElementReferenceException:
continue
log_step(
"All-page paperclip indicators",
total=len(out),
)
return out
def _element_in_board_container(element, container) -> bool:
"""True when element is the container or a descendant of it."""
if container is None:
return True
try:
current = element
for _ in range(40):
if current == container:
return True
parent = _parent_element(current)
if parent is None:
break
current = parent
except StaleElementReferenceException:
return False
return False
def _find_columns_by_board_testids(driver: WebDriver) -> list:
"""Planner board columns: data-testid=task-board-column-body (left-to-right)."""
cols: list = []
for el in driver.find_elements(By.CSS_SELECTOR, "[data-testid='task-board-column-body']"):
try:
if el.is_displayed():
cols.append(el)
except StaleElementReferenceException:
continue
return _dedupe_top_level_columns(driver, cols) if cols else []
def _active_tasks_scope_in_column(column) -> object:
"""Active (non-completed) task group inside one column — e.g. taskBoardColumnGroup-unbucketed."""
try:
for el in column.find_elements(
By.CSS_SELECTOR,
"[data-testid='taskBoardColumnGroup-unbucketed'], "
"[data-testid^='taskBoardColumnGroup-']",
):
testid = (el.get_attribute("data-testid") or "").lower()
if not el.is_displayed() or "completed" in testid:
continue
return el
except StaleElementReferenceException:
pass
return column
def _is_in_active_planner_column_group(element) -> bool:
"""
True inside an active Planner column group (e.g. taskBoardColumnGroup-unbucketed).
Uses XPath ancestors — parent walks from paperclip buttons often miss data-testid.
"""
try:
for ancestor in element.find_elements(
By.XPATH,
"./ancestor::*[starts-with(@data-testid,'taskBoardColumnGroup-')]",
):
testid = (ancestor.get_attribute("data-testid") or "")
if testid.endswith("-completed"):
return False
return True
except StaleElementReferenceException:
return False
return False
def _is_in_completed_column_group(element) -> bool:
"""True inside taskBoardColumnGroup-*-completed (collapsed completed section)."""
try:
return bool(
element.find_elements(
By.XPATH,
"./ancestor::*[starts-with(@data-testid,'taskBoardColumnGroup-')"
" and contains(@data-testid,'-completed')]",
)
)
except StaleElementReferenceException:
return False
def _find_board_bucket_columns(driver: WebDriver) -> list:
"""Left-to-right Planner bucket columns on the board."""
_ensure_board_scan_context(driver)
_scroll_board_to_left(driver)
top_level = _find_columns_by_board_testids(driver)
if top_level:
# region agent log
_agent_session_log(
"H3",
"onedrive_planner:_find_board_bucket_columns",
"columns_by_testid",
{
"count": len(top_level),
"titles": [
(_bucket_column_title(c) or (c.get_attribute("data-testid") or ""))[:40]
for c in top_level[:5]
],
},
)
# endregion
return top_level
cols: list = []
for el in driver.find_elements(
By.XPATH,
"//*[contains(@aria-label,'Use tab key to access a task card')]",
):
try:
if el.is_displayed() and _is_planner_bucket_column(el):
cols.append(el)
except StaleElementReferenceException:
continue
top_level = _dedupe_top_level_columns(driver, cols)
if top_level:
return top_level
top_level = _find_columns_by_bucket_headers(driver)
if top_level:
return top_level
top_level = _find_board_bucket_columns_from_taskboard(driver)
if top_level:
return top_level
# Infer one column from the leftmost paperclip cluster.
cluster = _leftmost_paperclip_indicators(driver)
if not cluster:
return []
col = _column_wrapper_from_element(cluster[0])
return [col] if col is not None else []
def _bucket_column_root_for_element(element, board_columns: list):
"""Which bucket column contains this task element."""
for col in board_columns:
if _element_in_board_container(element, col):
return col
return None
def _first_board_column(driver: WebDriver):
"""First (leftmost) bucket column."""
cols = _find_board_bucket_columns(driver)
if not cols:
log_step("First board column not found — cannot scan tasks")
return None
col = cols[0]
title = _bucket_column_title(col) or (col.get_attribute("aria-label") or "")[:60]
log_step(
"First board column",
title=title.strip(),
columns=len(cols),
left=int(_element_rect_left(driver, col)),
)
return col
# Planner bucket column headers (Board view) — NOT the "Completed tasks" subsection.
# Examples from live boards: "To do" / "TO DO", "New Facesheets", "Heather gets facesheets".
_ACTIVE_BUCKET_HEADERS = frozenset(
{
"to do",
"todo",
"new facesheets",
"new facesheet",
"not started",
"in progress",
"no bucket",
}
)
_ACTIVE_BUCKET_RE = re.compile(
r"^("
r"to\s*do|todo|"
r"new\s*facesheets?|"
r".*\bfacesheets?\b.*|" # facility-specific, e.g. "Heather gets facesheets"
r"not\s*started|in\s*progress|no\s*bucket"
r")(\s+\d+)?$",
re.IGNORECASE,
)
def _normalize_bucket_header(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def _is_active_bucket_title(text: str) -> bool:
"""
True for active board columns where we download not-started tasks.
Active: To do / TO DO, New Facesheets, or any *facesheet* bucket name.
Not active: Completed tasks (subsection header inside a column).
"""
t = _normalize_bucket_header(text)
if not t or _is_completed_tasks_header(text):
return False
if t in _ACTIVE_BUCKET_HEADERS:
return True
if "facesheet" in t:
return True
return bool(_ACTIVE_BUCKET_RE.match(t))
def _is_in_completed_tasks_section(element) -> bool:
"""
True only when the task lives under a Completed tasks column.
Walk up parent chain; when a level looks like a bucket column (has a short
direct-child title), use that title. Do not search all descendants — that
marks every task on the board as completed when the board contains a
Completed tasks column.
"""
# region agent log
in_active_group = _is_in_active_planner_column_group(element)
in_completed_group = _is_in_completed_column_group(element)
_agent_session_log(
"H1",
"onedrive_planner:_is_in_completed_tasks_section:entry",
"completed_section_check",
{
"in_active_group": in_active_group,
"in_completed_group": in_completed_group,
},
)
# endregion
if in_active_group:
return False
if in_completed_group:
return True
try:
current = element
for depth in range(22):
parent = _parent_element(current)
if parent is None:
break
tag = (getattr(parent, "tag_name", None) or "").lower()
if tag == "body":
break
parent_testid = (parent.get_attribute("data-testid") or "")[:60]
title = _bucket_column_title(parent)
if title:
if _is_completed_tasks_header(title):
# region agent log
_agent_session_log(
"H1",
"onedrive_planner:_is_in_completed_tasks_section:walk",
"false_positive_parent_title",
{
"depth": depth,
"parent_testid": parent_testid,
"title": title[:60],
},
)
# endregion
return True
if _is_active_bucket_title(title):
return False
aria = (parent.get_attribute("aria-label") or "").strip()
if aria and len(aria) <= 80:
if _is_completed_tasks_header(aria):
return True
if _is_active_bucket_title(aria):
return False
current = parent
except StaleElementReferenceException:
return False
return False
def _task_has_unchecked_complete_checkbox(task_el) -> bool:
"""
Planner not-started tasks use label[role=checkbox] aria-checked=false
(empty white circle — see completeButtonIcon SVG in DevTools).
Accept unchecked checkboxes including the progress ring (aria-label
"Not started"). Skip only in-progress (% complete / mixed) widgets.
"""
try:
for cb in task_el.find_elements(By.XPATH, _COMPLETION_CHECKBOX_UNCHECKED_XPATH):
if not cb.is_displayed():
continue
checked = (cb.get_attribute("aria-checked") or "").lower()
if checked in ("true", "mixed"):
continue
aria = (cb.get_attribute("aria-label") or "").lower()
if aria == "not started" or aria.startswith("not started"):
return True
if "in progress" in aria or "in-progress" in aria or "% complete" in aria:
continue
return True
except StaleElementReferenceException:
return False
return False
def _qualifies_not_started_with_attachment(task_el, *, from_indicator: bool = False) -> bool:
"""Active-board task with attachment suitable for download.
Only Not Started tasks (never In Progress or Completed).
"""
if _is_planner_bucket_column(task_el):
return False
if from_indicator:
if not _is_in_active_planner_column_group(
task_el
) and _is_in_completed_tasks_section(task_el):
return False
return _task_is_strictly_not_started(task_el)
if not _is_probable_single_task_card(task_el):
return False
if _is_in_completed_tasks_section(task_el):
return False
if not _task_is_strictly_not_started(task_el):
return False
return _card_has_attachment(task_el) and _task_has_unchecked_complete_checkbox(task_el)
def _card_has_attachment(task_el) -> bool:
"""True when the board card shows a paperclip / attachment (e.g. paperclip 1)."""
try:
blob = ((task_el.text or "") + " " + (task_el.get_attribute("aria-label") or "")).lower()
if "attachment" in blob or ".pdf" in blob:
return True
if re.search(r"\d+\s*attachment", blob):
return True
attachment_xpaths = [
".//button[@data-testid='attachment-indicator']",
".//*[contains(@aria-label,'Attachment') or contains(@aria-label,'attachment')]",
".//*[contains(@aria-label,' attachment')]",
".//button[contains(@aria-label,'Attachment')]",
".//*[@data-icon-name='Attach' or @data-icon-name='Attach12']",
".//svg[contains(@class,'Attach')]/ancestor::*[self::button or self::div][1]",
]
for xpath in attachment_xpaths:
for el in task_el.find_elements(By.XPATH, xpath):
if el.is_displayed():
return True
except StaleElementReferenceException:
return False
return False
def _task_is_in_progress(task_el) -> bool:
"""Skip half-filled progress circle / In progress tasks."""
return _task_is_in_progress_scoped(task_el)
def _task_is_not_started(task_el) -> bool:
"""
Not started = label[role=checkbox] aria-checked=false (empty circle on board).
Cards like '5.29.26' with paperclip do not include the words 'not started' in text.
"""
return _qualifies_not_started_with_attachment(task_el, from_indicator=False)
def _scroll_board_surface(driver: WebDriver) -> None:
"""Scroll board so lazy-loaded task cards and indicators render."""
try:
for sel in (
"[data-testid='taskboard']",
"[class*='Taskboard']",
"[class*='board']",
"main",
):
for el in driver.find_elements(By.CSS_SELECTOR, sel):
if el.is_displayed():
driver.execute_script(
"arguments[0].scrollTop = 0; arguments[0].scrollTop += 400;",
el,
)
time.sleep(0.4)
return
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.PAGE_DOWN)
time.sleep(0.4)
except Exception:
pass
def _scroll_board_columns(driver: WebDriver, first_col=None, all_columns=None) -> None:
"""Scroll board columns so lazy-loaded task cards render.
When *all_columns* is provided, every column is scrolled individually
so that tasks in ALL buckets are rendered (not just the first one).
"""
_scroll_board_to_left(driver)
_scroll_board_surface(driver)
try:
columns_to_scroll = all_columns if all_columns else ([first_col] if first_col else [])
if columns_to_scroll:
for col in columns_to_scroll:
if col is None:
continue
for top in (0, 500, 1000, 1500, 0):
driver.execute_script(
"arguments[0].scrollTop = arguments[1];",
col,
top,
)
time.sleep(0.15)
return
for sel in ("[data-testid='taskboard']", "[class*='Taskboard']", "[class*='board']"):
for board in driver.find_elements(By.CSS_SELECTOR, sel):
if not board.is_displayed():
continue
driver.execute_script("arguments[0].scrollLeft = 0;", board)
time.sleep(0.25)
return
except Exception:
pass
def _iter_not_started_tasks_with_attachments(driver: WebDriver) -> list:
"""
On Board view: not-started (aria-checked=false) tasks with attachments.
Scans ALL board columns (not just the first/left one).
Primary: button[data-testid='attachment-indicator'] (paperclip on card).
"""
_wait_for_board_ready(driver, timeout=45.0)
_ensure_board_scan_context(driver)
board_columns = _find_board_bucket_columns(driver)
first_col = board_columns[0] if board_columns else None
use_paperclip_cluster = first_col is None
if board_columns:
titles = []
for col in board_columns[:5]:
t = _bucket_column_title(col) or (col.get_attribute("aria-label") or "")[:40]
if t.strip():
titles.append(t.strip())
log_step(
"Board columns found",
columns=len(board_columns),
titles="; ".join(titles) or "(unnamed)",
)
_scroll_board_columns(driver, all_columns=board_columns)
else:
log_step("Bucket column wrapper not found — scanning entire page")
_scroll_board_to_left(driver)
_scroll_board_surface(driver)
time.sleep(1)
_ensure_board_scan_context(driver)
seen_keys: set[str] = set()
filtered: list = []
def _try_add_card(
card,
*,
from_indicator: bool = False,
reject_reasons: dict[str, int] | None = None,
) -> bool:
unique_key = _task_card_unique_key(card)
if unique_key in seen_keys:
if reject_reasons is not None:
reject_reasons["duplicate"] = reject_reasons.get("duplicate", 0) + 1
return False
reason = _filter_reject_reason(
card,
from_indicator=from_indicator,
)
if reason:
if reject_reasons is not None:
reject_reasons[reason] = reject_reasons.get(reason, 0) + 1
# region agent log
_agent_session_log(
"H4",
"onedrive_planner:_try_add_card",
"card_rejected",
{
"reason": reason,
"title": _task_card_snippet(card)[:40],
"unique_key": unique_key[:60],
"from_indicator": from_indicator,
},
)
# endregion
_agent_dbg_eb(
"H1",
"onedrive_planner:_try_add_card",
"card_rejected",
{
"reason": reason,
"from_indicator": from_indicator,
"tag": getattr(card, "tag_name", ""),
"snippet": ((card.text or "")[:60]),
},
)
return False
title_key = _task_card_snippet(card).lower()
if title_key and not _is_weak_task_title_key(title_key):
for existing in filtered:
ex_key = _task_card_snippet(existing).lower()
if ex_key and ex_key == title_key:
return False
seen_keys.add(unique_key)
filtered.append(card)
return True
# Scan entire page for paperclip indicators (all columns).
indicators = _all_page_paperclip_indicators(driver)
log_step("Board attachment indicators", count=len(indicators))
_agent_dbg_eb(
"H3",
"onedrive_planner:_iter_not_started_tasks",
"indicators_found",
{"count": len(indicators)},
)
rejected = 0
reject_reasons: dict[str, int] = {}
for idx, ind in enumerate(indicators):
try:
card = _clickable_from_attachment_indicator(ind)
if not _try_add_card(card, from_indicator=True, reject_reasons=reject_reasons):
rejected += 1
except StaleElementReferenceException:
continue
if indicators and not filtered:
reasons = "; ".join(f"{k}={v}" for k, v in sorted(reject_reasons.items()))
log_step(
"All paperclip indicators rejected",
indicators=len(indicators),
rejected=rejected,
reasons=reasons or "unknown",
)
if filtered:
if rejected > 0 and reject_reasons:
reasons = "; ".join(f"{k}={v}" for k, v in sorted(reject_reasons.items()))
log_step(
"Board scan skips (not opened)",
skipped=rejected,
reasons=reasons,
)
log_step("Not-started tasks (paperclip indicators)", count=len(filtered))
_agent_dbg_eb(
"H3",
"onedrive_planner:_iter_not_started_tasks",
"filtered_ok",
{"count": len(filtered), "rejected": rejected},
)
return filtered
if not use_paperclip_cluster and board_columns:
# Single superset XPath covers both label and non-label checkboxes.
unchecked_xpath = ".//*[@role='checkbox' and @aria-checked='false']"
checkboxes: list = []
for _col in board_columns:
try:
checkboxes.extend(
cb for cb in _col.find_elements(By.XPATH, unchecked_xpath) if cb.is_displayed()
)
except Exception:
continue
log_step("Board checkboxes (unchecked, all columns)", count=len(checkboxes))
for cb in checkboxes:
try:
if _is_in_completed_tasks_section(cb):
continue
_try_add_card(_ancestor_task_card(cb), reject_reasons=reject_reasons)
except StaleElementReferenceException:
continue
if filtered:
log_step("Not-started tasks with attachments", count=len(filtered))
return filtered
if use_paperclip_cluster or first_col is None:
log_step("No tasks matched across entire page")
return filtered
log_step("Attachment/checkbox scan found 0 — fallback task card scan")
fallback_xpaths = [
".//div[.//label[@role='checkbox' and @aria-checked='false']"
" and .//button[@data-testid='attachment-indicator']]",
".//div[.//label[@role='checkbox' and @aria-checked='false']"
" and .//*[contains(@aria-label,'attachment') or contains(@aria-label,'Attachment')]]",
".//button[.//*[contains(@aria-label,'Attachment') or contains(@aria-label,'attachment')"
" or contains(@aria-label,'.pdf') or contains(.,'.pdf')]]",
".//div[contains(@class,'TaskCard') or contains(@data-testid,'task')]"
"[.//button[@data-testid='attachment-indicator']]",
]
for xpath in fallback_xpaths:
for _col in board_columns:
try:
for t in _col.find_elements(By.XPATH, xpath):
try:
if not t.is_displayed():
continue
_try_add_card(_ancestor_task_card(t), reject_reasons=reject_reasons)
except StaleElementReferenceException:
continue
except Exception:
continue
if filtered:
break
log_step("Not-started tasks with attachments", count=len(filtered))
return filtered
def _not_started_attachment_indicators(driver: WebDriver) -> list:
"""Fresh paperclip buttons across all board columns (re-query each task)."""
_ensure_board_scan_context(driver)
out: list = []
cols = _find_board_bucket_columns(driver)
if cols:
for col in cols:
try:
scope = _active_tasks_scope_in_column(col)
for ind in scope.find_elements(By.XPATH, ".//button[@data-testid='attachment-indicator']"):
try:
if ind.is_displayed() and not _is_in_completed_tasks_section(ind):
out.append(ind)
except StaleElementReferenceException:
continue
except Exception:
continue
else:
for ind in _find_in_best_board_context(driver, "//button[@data-testid='attachment-indicator']"):
try:
if ind.is_displayed() and not _is_in_completed_tasks_section(ind):
out.append(ind)
except StaleElementReferenceException:
continue
return out
def _count_active_paperclips(driver: WebDriver) -> int:
"""Count paperclips across all board columns (exclude Completed tasks subsection)."""
_ensure_board_scan_context(driver)
cols = _find_board_bucket_columns(driver)
if cols:
total = 0
for col in cols:
try:
scope = _active_tasks_scope_in_column(col)
total += len([
ind for ind in scope.find_elements(
By.XPATH, ".//button[@data-testid='attachment-indicator']"
)
if ind.is_displayed() and not _is_in_completed_tasks_section(ind)
])
except Exception:
continue
return total
return len(_find_in_best_board_context(driver, _ACTIVE_PAPERCLIP_XPATH))
def _count_not_started_tasks_with_attachments(driver: WebDriver) -> int:
"""Count not-started tasks with attachments (excludes In progress / mixed checkbox)."""
return len(_iter_not_started_tasks_with_attachments(driver))
def _paperclip_xpath_at_index(index: int) -> str:
"""1-based XPath for the Nth active-board paperclip (fresh query each click)."""
return f"({_ACTIVE_PAPERCLIP_XPATH})[{index + 1}]"
def _open_not_started_task_by_indicator_index(driver: WebDriver, index: int) -> bool:
"""Open task N via indexed XPath (avoids stale cached WebElements)."""
if index < 0:
return False
xpath = _paperclip_xpath_at_index(index)
for attempt in range(3):
_close_task_panel(driver)
_ensure_board_scan_context(driver)
_scroll_board_surface(driver)
time.sleep(0.5)
count = _count_active_paperclips(driver)
_agent_dbg_eb(
"H7",
"onedrive_planner:_open_not_started_task_by_indicator_index",
"refind_indicators",
{"index": index, "count": count, "attempt": attempt + 1},
)
if index >= count:
return False
try:
ind = WebDriverWait(driver, WAIT_SHORT).until(
EC.element_to_be_clickable((By.XPATH, xpath))
)
driver.execute_script(
"arguments[0].scrollIntoView({block:'center',inline:'nearest'});",
ind,
)
driver.execute_script("arguments[0].click();", ind)
time.sleep(2)
if _task_details_panel_open(driver):
log_step("Opened task via paperclip (refind)", index=str(index + 1))
return True
except (StaleElementReferenceException, TimeoutException):
log_step("Paperclip open retry", index=str(index + 1), attempt=str(attempt + 1))
time.sleep(0.5)
continue
return False
def _resolve_task_card(element) -> object:
"""Tightest single-task card for a paperclip indicator or card element."""
if _is_attachment_indicator_button(element):
return _clickable_from_attachment_indicator(element)
return _ancestor_task_card(element)
def _next_unprocessed_paperclip(
driver: WebDriver, processed_titles: set[str]
) -> object | None:
"""Next unprocessed paperclip across all board columns.
Falls back to a full-page indicator scan when board column wrappers
cannot be found (e.g. DOM re-rendered after a panel close).
"""
_ensure_board_scan_context(driver)
cols = _find_board_bucket_columns(driver)
def _check_ind(ind) -> object | None:
try:
if not ind.is_displayed():
return None
card = _clickable_from_attachment_indicator(ind)
title_key = _task_card_snippet(card).strip().lower()
if title_key and title_key in processed_titles:
return None
reason = _filter_reject_reason(card, from_indicator=True)
if reason:
_agent_session_log(
"H4",
"onedrive_planner:_next_unprocessed_paperclip",
"skip_candidate",
{"title": title_key[:40], "reason": reason},
)
return None
log_step(
"Next unprocessed paperclip",
title=title_key[:40] or _task_card_unique_key(card)[:40],
)
return ind
except StaleElementReferenceException:
return None
if cols:
for col in cols:
try:
scope = _active_tasks_scope_in_column(col)
for ind in scope.find_elements(
By.XPATH,
".//button[@data-testid='attachment-indicator']",
):
result = _check_ind(ind)
if result is not None:
return result
except Exception:
continue
else:
# Column wrappers not found (board re-rendered) — page-wide fallback.
log_step("_next_unprocessed_paperclip: no columns, using page-wide scan")
for ind in _all_page_paperclip_indicators(driver):
result = _check_ind(ind)
if result is not None:
return result
return None
def _find_card_with_paperclip_by_title(driver: WebDriver, title: str):
"""Fresh DOM lookup: the task card whose title matches, with a paperclip."""
want = (title or "").strip().lower()
if not want:
return None
for ind in _find_in_best_board_context(
driver,
"//button[@data-testid='attachment-indicator']",
):
try:
if not ind.is_displayed():
continue
card = _clickable_from_attachment_indicator(ind)
if _task_card_matches_title(card, title):
return card
except StaleElementReferenceException:
continue
return None
def _panel_title_matches(panel_title: str, expected_title: str) -> bool:
p = (panel_title or "").strip().lower()
e = (expected_title or "").strip().lower()
if not p or not e:
return True # cannot verify — accept
return p == e or e in p or p in e
def _open_task_card_via_title_click(driver: WebDriver, card) -> bool:
"""Click the task title textbox on a board card — reliable open path."""
try:
for box in card.find_elements(By.XPATH, ".//*[@role='textbox']"):
if not box.is_displayed():
continue
line = ((box.text or "").strip().splitlines() or [""])[0].strip()
if not line or _is_weak_task_title_key(line.lower()):
continue
driver.execute_script(
"arguments[0].scrollIntoView({block:'center',inline:'nearest'});",
box,
)
time.sleep(0.3)
driver.execute_script("arguments[0].click();", box)
time.sleep(2)
if _task_details_panel_open(driver):
return True
except StaleElementReferenceException:
pass
return False
def _open_not_started_task_element(driver: WebDriver, element) -> bool:
"""Open task details from a board card.
Clicks the task title textbox first (paperclip clicks often open the
wrong task on virtualized Planner boards). Verifies panel title matches.
"""
card = _resolve_task_card(element)
expected_title = _task_card_snippet(card).strip()
can_verify = bool(expected_title) and not _is_weak_task_title_key(expected_title.lower())
_close_task_panel(driver)
_ensure_board_scan_context(driver)
_scroll_board_surface(driver)
time.sleep(0.5)
# Title click first, then card body. Paperclip is not used — it often
# opens the wrong task on virtualized Planner boards.
open_strategies = ("title", "card")
for attempt in range(2):
target_card = card
if attempt > 0 and can_verify:
fresh = _find_card_with_paperclip_by_title(driver, expected_title)
if fresh is not None:
target_card = fresh
else:
log_step(
"Card not re-found by title on retry",
title=expected_title[:60],
attempt=attempt + 1,
)
for strategy in open_strategies:
opened = False
try:
driver.execute_script(
"arguments[0].scrollIntoView({block:'center',inline:'nearest'});",
target_card,
)
time.sleep(0.3)
if strategy == "title":
opened = _open_task_card_via_title_click(driver, target_card)
elif strategy == "card":
_open_task_detail(driver, target_card)
opened = _task_details_panel_open(driver)
else:
opened = _open_task_card_via_paperclip(driver, target_card)
except StaleElementReferenceException:
continue
if not opened:
continue
if can_verify:
panel_title = _task_title_from_open_panel(driver)
if not _panel_title_matches(panel_title, expected_title):
log_step(
"Wrong task opened — trying next open method",
expected=expected_title[:50],
got=(panel_title or "")[:50],
method=strategy,
attempt=attempt + 1,
)
_close_task_panel(driver)
time.sleep(1.0)
_ensure_board_scan_context(driver)
continue
log_step(
"Opened task",
title=expected_title[:60] or "(untitled)",
method=strategy,
)
return True
return False
def _chrome_extra_download_dirs(dest_folder: Path) -> list[Path]:
"""Dirs where Chrome may save when CDP path does not apply."""
user_downloads = Path.home() / "Downloads"
dirs = [
_CHROME_DOWNLOADS_DIR,
user_downloads,
dest_folder.parent,
dest_folder.parent.parent,
]
unique: list[Path] = []
seen: set[str] = set()
for d in dirs:
key = str(d.resolve())
if key not in seen:
seen.add(key)
unique.append(d)
return unique
def _hrefs_from_attachment_rows(rows: list) -> list[str]:
"""Extract SharePoint URLs while rows are still valid (avoid stale row loop)."""
hrefs: list[str] = []
seen: set[str] = set()
for row in rows:
try:
url = _url_from_attachment_element(row)
if url and url not in seen:
seen.add(url)
hrefs.append(url)
except StaleElementReferenceException:
continue
return hrefs
def _direct_download_url(url: str) -> str:
"""SharePoint viewer links (web=1) -> direct download query."""
u = (url or "").strip()
if not u:
return u
if "web=1" in u:
return u.replace("web=1", "download=1")
if "download=1" in u:
return u
if "?" in u:
return u + "&download=1"
return u + "?download=1"
def _viewer_preview_url(url: str) -> str:
"""Open PDF in browser viewer tab (web=1) for Download button / Ctrl+S fallbacks."""
u = (url or "").strip()
if not u:
return u
if "web=1" in u:
return u
if "download=1" in u:
return u.replace("download=1", "web=1")
if "?" in u:
return u + "&web=1"
return u + "?web=1"
_VIEWER_DOWNLOAD_LOCATORS = [
(By.XPATH, "//button[contains(@aria-label,'Download') or contains(@title,'Download')]"),
(By.XPATH, "//*[@role='menuitem' and contains(.,'Download')]"),
(By.XPATH, "//a[contains(@aria-label,'Download') or normalize-space()='Download']"),
(By.CSS_SELECTOR, "button[data-automationid='downloadCommand'], #DownloadCommand"),
(By.XPATH, "//*[contains(@data-automationid,'download')]/ancestor::button[1]"),
]
def _sharepoint_download_candidates(url: str) -> list[str]:
"""URL variants to fetch PDF bytes without opening the in-browser viewer."""
u = (url or "").strip()
if not u:
return []
seen: set[str] = set()
out: list[str] = []
def _add(candidate: str) -> None:
c = (candidate or "").strip()
if c and c not in seen:
seen.add(c)
out.append(c)
_add(u)
_add(_direct_download_url(u))
base = u.split("?", 1)[0]
_add(base)
parsed = urllib.parse.urlparse(base)
if "sharepoint.com" in (parsed.netloc or "").lower() and "/sites/" in parsed.path:
site_tail = parsed.path.split("/sites/", 1)[1]
site_name = site_tail.split("/", 1)[0]
site_root = f"{parsed.scheme}://{parsed.netloc}/sites/{site_name}"
_add(
f"{site_root}/_layouts/15/download.aspx?"
f"SourceUrl={urllib.parse.quote(base, safe='')}"
)
return out
def _is_pdf_bytes(data: bytes) -> bool:
"""Real face-sheet PDFs are much larger than HTML error stubs (~500 bytes)."""
return len(data) >= MIN_PDF_BYTES and data[:4] == b"%PDF"
def _is_html_or_viewer_bytes(data: bytes) -> bool:
head = data[:800].lstrip().lower()
return head.startswith(b"<!") or b"<html" in head or b"admissionrecord" in head[:200]
def _filename_from_url(url: str) -> str:
path = urllib.parse.unquote(urllib.parse.urlparse(url).path)
name = path.rsplit("/", 1)[-1].strip()
if not name:
name = "attachment.pdf"
if not name.lower().endswith(".pdf"):
name = f"{name}.pdf"
return _sanitize_folder_name(name)
def _filename_from_content_disposition(header: str | None) -> str | None:
if not header:
return None
match = re.search(r"filename\*?=(?:UTF-8''|\"?)([^\";\n]+)", header, re.I)
if not match:
return None
name = urllib.parse.unquote(match.group(1).strip().strip('"'))
return _sanitize_folder_name(name) if name else None
def _unique_file_path(folder: Path, filename: str) -> Path:
dest = folder / filename
if not dest.exists():
return dest
stem = dest.stem
suffix = dest.suffix or ".pdf"
n = 2
while (folder / f"{stem}_{n}{suffix}").exists():
n += 1
return folder / f"{stem}_{n}{suffix}"
def _cookie_header_for_driver(driver: WebDriver) -> str:
parts: list[str] = []
for c in driver.get_cookies():
name = c.get("name")
value = c.get("value")
if name and value is not None:
parts.append(f"{name}={value}")
return "; ".join(parts)
def _build_http_opener() -> urllib.request.OpenerDirector:
handlers: list = []
if _HTTP_PROXY:
ip, port, user, pwd = _HTTP_PROXY
if user and pwd:
proxy_url = f"http://{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(pwd, safe='')}@{ip}:{port}"
else:
proxy_url = f"http://{ip}:{port}"
handlers.append(urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}))
handlers.append(urllib.request.HTTPSHandler())
return urllib.request.build_opener(*handlers)
def _http_get_bytes(driver: WebDriver, url: str) -> tuple[bytes, dict[str, str]] | None:
"""GET SharePoint file bytes using browser session cookies (no navigation)."""
try:
ua = driver.execute_script("return navigator.userAgent;") or ""
except Exception:
ua = "Mozilla/5.0"
headers = {
"User-Agent": ua,
"Cookie": _cookie_header_for_driver(driver),
"Accept": "application/pdf,application/octet-stream,*/*",
}
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with _build_http_opener().open(req, timeout=DOWNLOAD_TIMEOUT) as resp:
data = resp.read()
hdrs = {k.lower(): v for k, v in resp.headers.items()}
return data, hdrs
except urllib.error.HTTPError as exc:
_log.warning("HTTP download %s -> %s", url[:80], exc.code)
return None
except Exception as exc:
_log.warning("HTTP download failed for %s: %s", url[:80], exc)
return None
def _sharepoint_site_root(href: str) -> str:
"""e.g. https://tenant.sharepoint.com/sites/ArborHills from a file URL."""
parsed = urllib.parse.urlparse(href)
if "/sites/" not in parsed.path:
return f"{parsed.scheme}://{parsed.netloc}"
site = parsed.path.split("/sites/", 1)[1].split("/", 1)[0]
return f"{parsed.scheme}://{parsed.netloc}/sites/{site}"
def _download_href_via_browser_fetch(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""
Download PDF via fetch() on a SharePoint-origin tab (session cookies, same-site).
Planner-origin fetch often returns tiny error PDFs (~500 bytes); site tab fixes that.
"""
dl_url = _direct_download_url(href)
site_url = _sharepoint_site_root(href)
log_step("Download (browser fetch)", url=dl_url[:120], site=site_url[:80])
_ensure_download_folder(dest_folder)
handle = planner_handle or driver.current_window_handle
handles_before = list(driver.window_handles)
try:
driver.switch_to.window(handle)
driver.execute_script("window.open('about:blank','_blank');")
time.sleep(0.8)
new_handles = [h for h in driver.window_handles if h not in handles_before]
if not new_handles:
return []
driver.switch_to.window(new_handles[-1])
set_chrome_download_directory(driver, dest_folder, create_dir=True)
driver.get(site_url)
time.sleep(2)
result = driver.execute_async_script(
"""
const url = arguments[0];
const done = arguments[arguments.length - 1];
fetch(url, {credentials: 'include', redirect: 'follow'})
.then(async (r) => {
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (!r.ok) {
done({ok: false, status: r.status, ct, size: 0});
return;
}
const buf = await r.arrayBuffer();
const bytes = new Uint8Array(buf);
if (
!ct.includes('pdf')
&& !ct.includes('octet-stream')
&& bytes.length < 5000
) {
done({ok: false, status: r.status, ct, size: bytes.length});
return;
}
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunk),
);
}
done({ok: true, b64: btoa(binary), ct, size: bytes.length});
})
.catch((e) => done({ok: false, error: String(e), size: 0}));
""",
dl_url,
)
if not result or not result.get("ok") or not result.get("b64"):
_agent_dbg_eb(
"H6",
"onedrive_planner:_download_href_via_browser_fetch",
"fetch_failed",
{"url": dl_url[:80], "result": str(result)[:120]},
)
return []
try:
data = base64.b64decode(result["b64"])
except Exception as exc:
_log.warning("Browser fetch: invalid base64 payload from %s: %s", dl_url[:80], exc)
return []
if _is_html_or_viewer_bytes(data) or not _is_pdf_bytes(data):
log_step(
"Browser fetch not PDF",
bytes=len(data),
ct=(result.get("ct") or "")[:40],
url=dl_url[:80],
)
_agent_dbg_eb(
"H6",
"onedrive_planner:_download_href_via_browser_fetch",
"not_pdf",
{"bytes": len(data), "ct": result.get("ct")},
)
return []
name = _filename_from_url(dl_url)
if name in before:
stem, suffix = Path(name).stem, Path(name).suffix
name = f"{stem}_{int(time.time())}{suffix}"
dest = _unique_file_path(dest_folder, name)
dest.write_bytes(data)
log_step("Downloaded PDF via browser fetch", file=dest.name, bytes=len(data))
_agent_dbg_eb(
"H6",
"onedrive_planner:_download_href_via_browser_fetch",
"saved",
{"file": dest.name, "bytes": len(data)},
)
return [dest]
except Exception as exc:
_log.warning("Browser fetch download failed: %s", exc)
return []
finally:
_close_extra_download_tabs(driver, handle)
def _download_href_via_http(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
) -> list[Path]:
"""
Save PDF to disk via HTTP — never opens SharePoint viewer in the browser.
Workable copy is created later by planner_download._build_workable_files.
"""
_ensure_download_folder(dest_folder)
for dl_url in _sharepoint_download_candidates(href):
result = _http_get_bytes(driver, dl_url)
if not result:
continue
data, hdrs = result
if _is_html_or_viewer_bytes(data) or not _is_pdf_bytes(data):
log_step(
"HTTP response not PDF (skip)",
url=dl_url[:120],
bytes=len(data),
)
continue
name = _filename_from_content_disposition(hdrs.get("content-disposition"))
if not name:
name = _filename_from_url(dl_url)
if name in before:
stem, suffix = Path(name).stem, Path(name).suffix
name = f"{stem}_{int(time.time())}{suffix}"
dest = _unique_file_path(dest_folder, name)
dest.write_bytes(data)
log_step("Downloaded PDF via HTTP", file=dest.name, bytes=len(data), url=dl_url[:120])
return [dest]
return []
_PDF_FILENAME_RE = re.compile(r"[^\s<>\"|]+?\.pdf", re.IGNORECASE)
def _normalize_pdf_filename(name: str) -> str:
return (name or "").strip()
def _extract_pdf_filenames(text: str) -> list[str]:
"""Unique .pdf filenames found in visible text (exact tokens)."""
seen: set[str] = set()
names: list[str] = []
for match in _PDF_FILENAME_RE.finditer(text or ""):
fn = _normalize_pdf_filename(match.group(0))
key = fn.lower()
if fn and key not in seen:
seen.add(key)
names.append(fn)
return names
def _discover_listed_attachment_names(driver: WebDriver, *, root=None) -> list[str]:
"""PDF filenames listed in the open Attachments panel (exact row titles)."""
scope = root if root is not None else driver
names: list[str] = []
seen: set[str] = set()
row_xpaths = [
".//*[@role='tabpanel']//li[contains(translate(.,'PDF','pdf'),'.pdf')]",
".//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,'.pdf')]",
".//*[@role='tabpanel']//a[contains(@href,'http')]",
".//*[@role='tabpanel']//*[self::a or self::button or self::div or self::span]",
".//div[@role='link'][contains(@aria-label,'.pdf')]",
".//a[contains(@href,'http')]",
]
if root is None:
row_xpaths = [p.lstrip(".") for p in row_xpaths]
row_xpaths.extend(
[
"//*[@role='tabpanel']//li[contains(translate(.,'PDF','pdf'),'.pdf')]",
"//*[@role='dialog']//div[@role='link'][contains(@aria-label,'.pdf')]",
"//aside//div[@role='link'][contains(@aria-label,'.pdf')]",
]
)
for xpath in row_xpaths:
try:
elements = scope.find_elements(By.XPATH, xpath) if root is not None else driver.find_elements(By.XPATH, xpath)
except Exception:
continue
for el in elements:
try:
if not el.is_displayed():
continue
blob = (el.text or "") + " " + (el.get_attribute("aria-label") or "")
if "show in board" in blob.lower():
continue
for fn in _extract_pdf_filenames(blob):
key = fn.lower()
if key not in seen and len(fn) <= 200:
seen.add(key)
names.append(fn)
except StaleElementReferenceException:
continue
return names
def _click_exact_attachment_name(
driver: WebDriver,
filename: str,
*,
root=None,
) -> bool:
"""Click a listed attachment row/link when the filename appears exactly."""
fn = _normalize_pdf_filename(filename)
if not fn:
return False
lit = _xpath_literal(fn)
prefix = "." if root is not None else "//"
locators = [
(By.XPATH, f"{prefix}*[@role='tabpanel']//a[contains(@href,'http') and normalize-space()={lit}]"),
(By.XPATH, f"{prefix}*[@role='tabpanel']//a[contains(@href,'http') and contains(normalize-space(), {lit})]"),
(By.XPATH, f"{prefix}*[@role='tabpanel']//*[normalize-space()={lit}]"),
(By.XPATH, f"{prefix}a[contains(@href,'http') and normalize-space()={lit}]"),
(By.XPATH, f"{prefix}*[normalize-space()={lit} and contains(translate(., 'PDF', 'pdf'), '.pdf')]"),
]
search_root = root if root is not None else driver
for by, xpath in locators:
try:
elements = search_root.find_elements(by, xpath)
except Exception:
continue
for el in elements:
try:
if not el.is_displayed():
continue
_js_click(driver, el)
time.sleep(0.5)
log_step("Clicked listed attachment", name=fn)
return True
except StaleElementReferenceException:
continue
return False
def _click_card_listed_attachment_or_icon(driver: WebDriver, task_el) -> bool:
"""
On the board card: click an attachment listed by exact .pdf name, else the paperclip icon.
"""
blob = (task_el.text or "") + " " + (task_el.get_attribute("aria-label") or "")
for fn in _extract_pdf_filenames(blob):
if _click_exact_attachment_name(driver, fn, root=task_el):
time.sleep(1.5)
return True
card_icon_locators = [
(By.XPATH, ".//button[@data-testid='attachment-indicator']"),
(By.XPATH, ".//button[contains(@aria-label,'attachment')]"),
(By.XPATH, ".//button[contains(@aria-label,'Attachment')]"),
(By.XPATH, ".//*[@role='button' and contains(@aria-label,'Attachment')]"),
(By.XPATH, ".//*[@data-icon-name='Attach' or @data-icon-name='Attach12']/ancestor::button[1]"),
]
try:
icon = _first_clickable_within(task_el, card_icon_locators, timeout=4.0)
_js_click(driver, icon)
time.sleep(1.5)
log_step("Opened task via board attachment icon")
return True
except (TimeoutException, NoSuchElementException, StaleElementReferenceException):
return False
def _open_task_via_attachment(driver: WebDriver, task_el) -> None:
"""Open task: paperclip indicator, else listed .pdf on card, else card body."""
log_step("Open task — listed attachment or icon")
if _is_attachment_indicator_button(task_el):
try:
driver.execute_script(
"arguments[0].scrollIntoView({block:'center',inline:'nearest'});",
task_el,
)
_js_click(driver, task_el)
time.sleep(2)
log_step("Opened task via paperclip indicator")
if _task_details_panel_open(driver):
return
except StaleElementReferenceException:
pass
if _is_planner_bucket_column(task_el):
log_step("Cannot open task from bucket column — skip card click")
return
if _click_card_listed_attachment_or_icon(driver, task_el):
if _task_details_panel_open(driver):
return
_open_task_detail(driver, task_el)
def _task_title_from_open_panel(driver: WebDriver) -> str:
"""Task title from the open details panel — skips description placeholders."""
try:
for box in driver.find_elements(By.XPATH, "//*[@role='textbox']"):
if not box.is_displayed():
continue
aria = (box.get_attribute("aria-label") or "").lower()
if "description" in aria or "notes" in aria:
continue
line = ((box.text or "").strip().splitlines() or [""])[0].strip()
if line and not _is_weak_task_title_key(line) and not _is_panel_placeholder_text(line):
return line[:80]
except Exception:
pass
return ""
def _attachment_tab_count(driver: WebDriver) -> int:
"""Parse 'Attachments (2)' from the task panel tab label."""
for el in driver.find_elements(
By.XPATH,
"//*[@role='tab' and contains(.,'Attachments')]"
"|//button[contains(.,'Attachments')]",
):
try:
if not el.is_displayed():
continue
text = (el.text or "") + " " + (el.get_attribute("aria-label") or "")
match = re.search(r"attachments\s*\(\s*(\d+)\s*\)", text, re.I)
if match:
return int(match.group(1))
except StaleElementReferenceException:
continue
return 0
def _is_attachments_empty(driver: WebDriver) -> bool:
"""True when the open task has no downloadable attachment files."""
if _attachment_tab_count(driver) > 0:
return False
if _discover_listed_attachment_names(driver):
return False
if _list_attachment_rows(driver):
return False
empty_markers = (
"select, upload, or add a link",
"add attachment",
)
for panel in driver.find_elements(By.XPATH, "//*[@role='tabpanel']"):
try:
if not panel.is_displayed():
continue
blob = (panel.text or "").lower()
if any(marker in blob for marker in empty_markers):
return True
except StaleElementReferenceException:
continue
return True
def _open_attachments_view(driver: WebDriver) -> bool:
"""
Open the Attachments list in task details.
Always clicks the Attachments tab when present so every file row is rendered.
"""
log_step("Open Attachments — listed file or tab/icon")
tab_locators = [
(By.XPATH, "//*[@role='tab' and normalize-space()='Attachments']"),
(By.XPATH, "//button[@role='tab' and normalize-space()='Attachments']"),
(By.XPATH, "//*[@role='tab' and starts-with(normalize-space(),'Attachments')]"),
(By.XPATH, "//button[starts-with(normalize-space(),'Attachments')]"),
]
for by, xpath in tab_locators:
try:
el = _first_clickable(driver, [(by, xpath)], timeout=4)
_js_click(driver, el)
time.sleep(PANEL_TAB_LOAD_DELAY)
listed = _discover_listed_attachment_names(driver)
log_step("Opened Attachments tab", files=len(listed))
return True
except TimeoutException:
continue
listed = _discover_listed_attachment_names(driver)
if listed:
log_step("Attachments list already visible", files=len(listed))
return True
for fn in _discover_listed_attachment_names(driver):
if _click_exact_attachment_name(driver, fn):
time.sleep(PANEL_TAB_LOAD_DELAY)
return True
attachment_icon_locators = [
(By.XPATH, "//button[contains(@aria-label,'Attach')]"),
(By.XPATH, "//button[contains(@aria-label,'attachment')]"),
(By.XPATH, "//*[@data-icon-name='Attach' or @data-icon-name='Attach12']"),
]
try:
_click_any(driver, attachment_icon_locators, timeout=5)
time.sleep(PANEL_TAB_LOAD_DELAY)
log_step("Opened attachments via icon")
return True
except TimeoutException:
log_step("Attachments tab/icon not found — scanning visible links")
return False
def _scroll_attachments_panel(driver: WebDriver, *, passes: int = 6) -> None:
"""Scroll the attachments list so off-screen files (e.g. 8 PDFs) become visible."""
panel_locators = [
(By.XPATH, "//*[@role='tabpanel']"),
(By.XPATH, "//*[contains(@class,'Attachment') or contains(@class,'attachment')]"),
]
for by, sel in panel_locators:
try:
for panel in driver.find_elements(by, sel):
if not panel.is_displayed():
continue
for _ in range(passes):
driver.execute_script(
"arguments[0].scrollTop = arguments[0].scrollTop + 500;",
panel,
)
time.sleep(0.35)
return
except Exception:
continue
try:
body = driver.find_element(By.TAG_NAME, "body")
for _ in range(passes):
body.send_keys(Keys.PAGE_DOWN)
time.sleep(0.35)
except Exception:
pass
_SHAREPOINT_URL_RE = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
def _is_attachment_href(href: str) -> bool:
low = (href or "").lower()
if not low.startswith("http"):
return False
if any(skip in low for skip in ("javascript:", "mailto:", "#")):
return False
return (
"sharepoint" in low
or "onedrive" in low
or ".pdf" in low
or "download" in low
or "/:b:/" in low
or "/:f:/" in low
)
def _extract_sharepoint_url(text: str) -> str | None:
"""Pull a SharePoint/OneDrive URL from aria-label or visible text."""
blob = (text or "").strip()
if not blob:
return None
if _is_attachment_href(blob):
return blob
match = _SHAREPOINT_URL_RE.search(blob)
if match and _is_attachment_href(match.group(0)):
return match.group(0)
return None
def _url_from_attachment_element(el) -> str | None:
"""Read download URL from href, aria-label (Planner span), or link text."""
try:
for attr in ("href", "aria-label", "title"):
url = _extract_sharepoint_url(el.get_attribute(attr) or "")
if url:
return url
url = _extract_sharepoint_url(el.text or "")
if url:
return url
for child in el.find_elements(
By.XPATH,
".//*[contains(@aria-label,'sharepoint') or contains(@aria-label,'onedrive')"
" or contains(@aria-label,'http')]",
):
for attr in ("aria-label", "href", "title"):
url = _extract_sharepoint_url(child.get_attribute(attr) or "")
if url:
return url
url = _extract_sharepoint_url(child.text or "")
if url:
return url
except StaleElementReferenceException:
return None
return None
def _href_for_listed_attachment(driver: WebDriver, filename: str) -> str | None:
"""Resolve SharePoint URL for a row whose title matches the filename exactly."""
fn = _normalize_pdf_filename(filename)
if not fn:
return None
lit = _xpath_literal(fn)
link_xpaths = [
f"//*[@role='tabpanel']//li[contains(.,{lit})]//span[contains(@aria-label,'http')]",
f"//*[@role='tabpanel']//li[contains(.,{lit})]//span[contains(@aria-label,'sharepoint')]",
f"//*[@role='tabpanel']//li[contains(.,{lit})]//div[@role='link']",
f"//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,{lit})]",
f"//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,'.pdf') and contains(@aria-label,{lit})]",
f"//*[@role='tabpanel']//a[contains(@href,'http') and (normalize-space()={lit} or contains(normalize-space(), {lit}))]",
f"//*[@role='tabpanel']//*[normalize-space()={lit} or contains(normalize-space(), {lit})]//a[contains(@href,'http')]",
f"//*[@role='tabpanel']//*[contains(normalize-space(), {lit})]/following::a[contains(@href,'http')][1]",
]
for xpath in link_xpaths:
for el in driver.find_elements(By.XPATH, xpath):
try:
if not el.is_displayed():
continue
url = _url_from_attachment_element(el)
if url:
return url
href = (el.get_attribute("href") or "").strip()
if href and _is_attachment_href(href):
return href
except StaleElementReferenceException:
continue
return None
def _collect_attachment_download_urls(driver: WebDriver) -> list[str]:
"""
Click each attachment row in the panel, read SharePoint URL from DOM (aria-label on span).
Planner stores the file URL in aria-label on the link row / inner span — no new tab needed.
"""
seen: set[str] = set()
urls: list[str] = []
def _add(url: str | None) -> None:
if url and url not in seen and _is_attachment_href(url):
seen.add(url)
urls.append(url)
row_xpaths = [
"//*[@role='tabpanel']//li[.//span[contains(@aria-label,'sharepoint') or contains(@aria-label,'http')]]//span[contains(@aria-label,'http')]",
"//*[@role='tabpanel']//li[.//span[contains(@aria-label,'sharepoint')]]//span[contains(@aria-label,'http')]",
"//*[@role='tabpanel']//div[@role='link'][contains(translate(@aria-label,'PDF','pdf'),'.pdf')]",
"//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,'.pdf')]",
"//*[@role='tabpanel']//*[contains(@aria-label,'.pdf')][@role='link']",
"//*[@role='dialog']//div[@role='link'][contains(@aria-label,'.pdf')]",
"//aside//div[@role='link'][contains(@aria-label,'.pdf')]",
]
for xpath in row_xpaths:
for row in driver.find_elements(By.XPATH, xpath):
try:
if not row.is_displayed():
continue
_add(_url_from_attachment_element(row))
except StaleElementReferenceException:
continue
scan_xpaths = [
"//*[@role='tabpanel']//*[contains(@aria-label,'sharepoint.com') or contains(@aria-label,'onedrive')]",
"//*[@role='tabpanel']//span[contains(@aria-label,'http')]",
"//*[@role='tabpanel']//a[contains(@href,'http')]",
"//*[@role='dialog']//div[@role='link'][contains(@aria-label,'.pdf')]",
"//*[@role='dialog']//*[contains(@aria-label,'sharepoint.com') or contains(@aria-label,'onedrive')]",
"//aside//div[@role='link'][contains(@aria-label,'.pdf')]",
"//*[contains(@aria-label,'sharepoint.com') or contains(@aria-label,'onedrive')]",
]
for xpath in scan_xpaths:
for el in driver.find_elements(By.XPATH, xpath):
try:
if not el.is_displayed():
continue
_add(_url_from_attachment_element(el))
except StaleElementReferenceException:
continue
return urls
def _collect_attachment_hrefs(driver: WebDriver) -> list[str]:
"""Collect unique SharePoint download URLs from the Attachments panel."""
urls = _collect_attachment_download_urls(driver)
seen = set(urls)
for fn in _discover_listed_attachment_names(driver):
href = _href_for_listed_attachment(driver, fn)
if href and href not in seen:
seen.add(href)
urls.append(href)
xpaths = [
"//*[@role='tabpanel']//a[contains(@href,'http')]",
"//a[contains(@href,'http') and (contains(@href,'sharepoint') or contains(@href,'onedrive'))]",
]
for xpath in xpaths:
for el in driver.find_elements(By.XPATH, xpath):
try:
if not el.is_displayed():
continue
href = _url_from_attachment_element(el) or (el.get_attribute("href") or "").strip()
if href and href not in seen and _is_attachment_href(href):
seen.add(href)
urls.append(href)
except StaleElementReferenceException:
continue
return urls
def _click_download_in_page_frames(driver: WebDriver) -> bool:
"""Click Download in SharePoint/Office viewer (search main document + iframes)."""
frames: list = [None]
try:
frames.extend(driver.find_elements(By.TAG_NAME, "iframe"))
except Exception:
pass
for frame in frames:
try:
driver.switch_to.default_content()
if frame is not None:
driver.switch_to.frame(frame)
for by, sel in _VIEWER_DOWNLOAD_LOCATORS:
for el in driver.find_elements(by, sel):
try:
if el.is_displayed() and el.is_enabled():
_js_click(driver, el)
log_step("Clicked Download in PDF viewer")
return True
except StaleElementReferenceException:
continue
except Exception:
continue
try:
driver.switch_to.default_content()
except Exception:
pass
return False
def _save_active_tab_via_ctrl_s(
driver: WebDriver,
dest_folder: Path,
filename: str,
before: set[str],
) -> list[Path]:
"""Save the open PDF tab with Ctrl+S (Windows Save dialog via pyautogui when available)."""
_ensure_download_folder(dest_folder)
dest = _unique_file_path(dest_folder, filename)
path_str = str(dest.resolve())
log_step("Download fallback (Ctrl+S save)", file=dest.name)
try:
driver.switch_to.active_element
except Exception:
pass
try:
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.CONTROL, "s")
except Exception as exc:
_log.warning("Ctrl+S key send failed: %s", exc)
return []
time.sleep(1.5)
try:
import pyautogui
pyautogui.FAILSAFE = True
pyautogui.hotkey("ctrl", "a")
time.sleep(0.2)
pyautogui.write(path_str, interval=0.015)
time.sleep(0.3)
pyautogui.press("enter")
time.sleep(2.5)
except ImportError:
log_step("pyautogui not installed — cannot complete Save dialog after Ctrl+S")
return []
except Exception as exc:
_log.warning("Save dialog automation failed: %s", exc)
return []
if dest.exists():
data = dest.read_bytes()
if _is_pdf_bytes(data):
log_step("Saved PDF via Ctrl+S", file=dest.name, bytes=len(data))
return [dest]
new_files = _wait_for_new_download(dest_folder, before, timeout=45)
if new_files:
log_step("Saved PDF via Ctrl+S (folder poll)", files=len(new_files))
return new_files
def _download_href_viewer_new_tab(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""
Open PDF preview in a new tab, then try viewer Download, direct URL, Ctrl+S, HTTP retry.
"""
viewer_url = _viewer_preview_url(href)
filename = _filename_from_url(href)
log_step("Download fallback (PDF viewer tab)", url=viewer_url[:200])
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
handle = planner_handle or driver.current_window_handle
try:
driver.switch_to.window(handle)
except Exception:
pass
saved_planner_url = driver.current_url or ""
handles_before = list(driver.window_handles)
try:
driver.execute_script("window.open(arguments[0], '_blank');", viewer_url)
time.sleep(4)
new_handles = [h for h in driver.window_handles if h not in handles_before]
if not new_handles:
return []
driver.switch_to.window(new_handles[-1])
time.sleep(2)
if _click_download_in_page_frames(driver):
new_files = _wait_for_new_download(dest_folder, before, timeout=60)
if new_files:
return new_files
dl_url = _direct_download_url(href)
log_step("Viewer tab — navigate direct download", url=dl_url[:120])
try:
driver.get(dl_url)
time.sleep(3)
new_files = _wait_for_new_download(dest_folder, before, timeout=60)
if new_files:
return new_files
except Exception as exc:
_log.warning("Direct download in viewer tab failed: %s", exc)
new_files = _save_active_tab_via_ctrl_s(driver, dest_folder, filename, before)
if new_files:
return new_files
log_step("Viewer tab — retry HTTP after browser session")
return _download_href_via_http(driver, dest_folder, href, before)
finally:
_close_extra_download_tabs(driver, handle)
def _close_extra_download_tabs(driver: WebDriver, planner_handle: str) -> None:
"""Close SharePoint/PDF tabs opened for download; keep the Planner task tab."""
try:
for handle in list(driver.window_handles):
if handle != planner_handle:
driver.switch_to.window(handle)
driver.close()
driver.switch_to.window(planner_handle)
except Exception as exc:
_log.warning("Could not close extra download tabs: %s", exc)
def _return_to_planner_after_download(
driver: WebDriver,
planner_handle: str,
saved_url: str,
) -> None:
"""Close extra tabs and restore the Planner task/board URL (end of task only)."""
try:
_close_extra_download_tabs(driver, planner_handle)
if saved_url and any(
token in saved_url.lower()
for token in ("planner", "tasks.office", "cloud.microsoft")
):
if (driver.current_url or "") != saved_url:
driver.get(saved_url)
elif saved_url:
driver.back()
time.sleep(1)
_switch_to_planner_context(driver)
except Exception as exc:
_log.warning("Could not return to Planner after download: %s", exc)
def _download_one_href_browser_same_tab(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""UiPath-style: navigate to ?download=1 in Planner tab; wait for Chrome save."""
dl_url = _direct_download_url(href)
log_step("Download (browser same tab)", url=dl_url[:200])
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
handle = planner_handle or driver.current_window_handle
try:
driver.switch_to.window(handle)
except Exception:
pass
saved_task_url = driver.current_url or ""
try:
driver.get(dl_url)
time.sleep(3)
new_files = _wait_for_new_download(dest_folder, before)
if new_files:
return new_files
finally:
_close_extra_download_tabs(driver, handle)
try:
driver.switch_to.window(handle)
if saved_task_url and (driver.current_url or "") != saved_task_url:
driver.get(saved_task_url)
time.sleep(1)
_switch_to_planner_context(driver)
except Exception:
pass
return []
def _finish_sharepoint_tab_download(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
) -> list[Path]:
"""
Complete download in the active SharePoint tab (UiPath GoToUrl ?download=1).
Span click often opens a viewer tab; Chrome does not save until we navigate download=1.
"""
base = (href or driver.current_url or "").strip()
if not base.startswith("http"):
return []
dl_url = _direct_download_url(base)
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
cur = (driver.current_url or "").strip()
try:
if cur != dl_url:
log_step("SharePoint tab — navigate download=1", url=dl_url[:120])
driver.get(dl_url)
time.sleep(2)
if _click_download_in_page_frames(driver):
time.sleep(1)
new_files = _wait_for_new_download(
dest_folder,
before,
timeout=45,
extra_watch_dirs=_chrome_extra_download_dirs(dest_folder),
)
_agent_dbg_eb(
"H6",
"onedrive_planner:_finish_sharepoint_tab_download",
"download_poll",
{"files": len(new_files), "url": dl_url[:80]},
)
return new_files
except Exception as exc:
_log.warning("SharePoint tab download failed: %s", exc)
return []
def _download_one_href_browser_new_tab(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""
Primary browser download (matches UiPath GoToUrl + WaitForNewDownloads).
Opens ?download=1 in a new tab using the logged-in Chrome session.
"""
dl_url = _direct_download_url(href)
log_step("Download (browser new tab)", url=dl_url[:200])
_ensure_download_folder(dest_folder)
handle = planner_handle or driver.current_window_handle
try:
driver.switch_to.window(handle)
except Exception:
pass
handles_before = list(driver.window_handles)
try:
driver.execute_script("window.open('about:blank', '_blank');")
time.sleep(1)
new_handles = [h for h in driver.window_handles if h not in handles_before]
if not new_handles:
return []
driver.switch_to.window(new_handles[-1])
return _finish_sharepoint_tab_download(driver, dest_folder, href, before)
except Exception as exc:
_log.warning("Browser new-tab download failed: %s", exc)
finally:
_close_extra_download_tabs(driver, handle)
return []
def _download_attachment_by_href(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""
Download one file by URL: browser fetch, new tab, link click; HTTP last.
Avoids re-clicking stale attachment row elements.
"""
handle = planner_handle or driver.current_window_handle
try:
driver.switch_to.window(handle)
except Exception:
pass
new_files = _download_href_via_browser_fetch(
driver,
dest_folder,
href,
before,
planner_handle=handle,
)
if new_files:
return new_files
new_files = _download_one_href_browser_new_tab(
driver,
dest_folder,
href,
before,
planner_handle=handle,
)
if new_files:
return new_files
new_files = _download_via_attachment_link_click(
driver,
dest_folder,
href,
before,
row=None,
)
if new_files:
return new_files
# Try (...) menu → Download for the matching attachment row.
fn = _filename_from_url(href).strip().lower()
try:
for row in _list_attachment_rows(driver):
try:
row_name = _attachment_filename_key(row).strip().lower()
if row_name and (fn == row_name or fn in row_name or row_name in fn):
log_step("Trying row menu (...) download", file=fn[:60])
new_files = _download_row_via_card_menu(driver, row, dest_folder, before)
if new_files:
return new_files
break
except StaleElementReferenceException:
continue
except Exception:
pass
log_step("Browser methods failed — try HTTP", url=href[:80])
return _download_href_via_http(driver, dest_folder, href, before)
def _attachment_filename_key(el) -> str:
"""Stable dedupe key from PDF name in aria-label or visible text."""
blob = (el.get_attribute("aria-label") or "") + " " + (el.text or "")
names = _extract_pdf_filenames(blob)
if names:
return names[0].lower()
url = _extract_sharepoint_url(blob)
if url:
fn = _filename_from_url(url)
if fn:
return fn.lower()
low = blob.lower()
if ".pdf" in low:
for part in re.split(r"[\s\n\r]+", blob):
if ".pdf" in part.lower():
return part.strip().lower()
return ""
def _resolve_attachment_click_target(row) -> object | None:
"""
Clickable href span inside an Attachments list item (not the (...) menu).
Planner DOM: li > ... > span with SharePoint URL in aria-label (user-recorded XPath).
"""
try:
tag = (row.tag_name or "").lower()
if tag == "span" and _url_from_attachment_element(row):
return row
span_xpaths = [
".//span[contains(@aria-label,'sharepoint') or contains(@aria-label,'onedrive')]",
".//span[contains(@aria-label,'http')]",
".//span[contains(translate(.,'PDF','pdf'),'.pdf')]",
]
for xpath in span_xpaths:
for span in row.find_elements(By.XPATH, xpath):
if not span.is_displayed():
continue
if _url_from_attachment_element(span):
return span
if _url_from_attachment_element(row):
return row
except StaleElementReferenceException:
return None
return None
def _list_attachment_rows(driver: WebDriver) -> list:
"""One click target per PDF in Attachments tab (span href link inside li, not (...))."""
_switch_to_planner_context(driver)
time.sleep(0.8)
rows: list = []
seen_ids: set[int] = set()
seen_names: set[str] = set()
def _add_click_target(container) -> None:
target = _resolve_attachment_click_target(container)
if target is None:
return
name_key = _attachment_filename_key(target)
if name_key and name_key in seen_names:
return
eid = id(target)
if eid in seen_ids:
return
seen_ids.add(eid)
if name_key:
seen_names.add(name_key)
rows.append(target)
# Primary: li list items with href span (Planner Attachments tab).
li_xpaths = [
"//*[@role='tabpanel']//li[contains(translate(.,'PDF','pdf'),'.pdf')]",
"//*[@role='tabpanel']//li[.//span[contains(@aria-label,'sharepoint') or contains(@aria-label,'http')]]",
"//*[@role='tabpanel']//li[.//span[contains(translate(.,'PDF','pdf'),'.pdf')]]",
"//li[.//span[contains(@aria-label,'sharepoint') or contains(@aria-label,'http')]]",
]
for xpath in li_xpaths:
for li in driver.find_elements(By.XPATH, xpath):
try:
if not li.is_displayed():
continue
_add_click_target(li)
except StaleElementReferenceException:
continue
if rows:
_agent_dbg_eb(
"H5",
"onedrive_planner:_list_attachment_rows",
"li_span_targets",
{"count": len(rows), "names": list(seen_names)[:6]},
)
return rows
fallback_xpaths = [
"//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,'.pdf')]",
"//*[@role='tabpanel']//span[contains(@aria-label,'sharepoint')]",
"//div[@role='link'][contains(@aria-label,'sharepoint.com')]",
]
for xpath in fallback_xpaths:
for el in driver.find_elements(By.XPATH, xpath):
try:
if not el.is_displayed():
continue
_add_click_target(el)
except StaleElementReferenceException:
continue
return rows
def _download_row_via_card_menu(
driver: WebDriver,
row,
dest_folder: Path,
before: set[str],
) -> list[Path]:
"""Click (...) on one attachment row -> Download (Planner attachments panel)."""
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
menu_xpaths = [
".//button[contains(@aria-label,'More') or contains(@aria-label,'options') or contains(@aria-label,'Show more')]",
"./following-sibling::button[contains(@aria-label,'More') or contains(@aria-label,'options')]",
"./ancestor::div[1]//button[contains(@aria-label,'More') or contains(@aria-label,'options')]",
]
download_item = [
(By.XPATH, "//*[@role='menuitem' and contains(.,'Download')]"),
(By.XPATH, "//button[contains(.,'Download')]"),
(By.XPATH, "//li[contains(.,'Download')]"),
]
label = (row.get_attribute("aria-label") or row.text or "")[:80]
for xpath in menu_xpaths:
try:
for btn in row.find_elements(By.XPATH, xpath):
if not btn.is_displayed():
continue
log_step("Download via row menu (...)", name=label[:50])
_js_click(driver, btn)
time.sleep(0.6)
_click_any(driver, download_item, timeout=4)
time.sleep(1.5)
new_files = _wait_for_new_download(dest_folder, before)
if new_files:
return new_files
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(0.3)
except Exception:
continue
return []
def _download_via_attachment_link_click(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
row=None,
) -> list[Path]:
"""Click the attachment href span (opens/downloads PDF); do not use (...) menu."""
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
filename = _filename_from_url(href)
targets: list = []
if row is not None:
click_el = _resolve_attachment_click_target(row)
if click_el is not None:
targets.append(click_el)
if not targets:
lit = _xpath_literal(filename)
lit_url = _xpath_literal(href[:80])
row_locators = [
(
By.XPATH,
f"//*[@role='tabpanel']//li[.//span[contains(@aria-label,{lit})]]"
f"//span[contains(@aria-label,'http')]",
),
(By.XPATH, f"//*[@role='tabpanel']//span[contains(@aria-label,{lit})]"),
(By.XPATH, f"//*[@role='tabpanel']//span[contains(@aria-label,{lit_url})]"),
(By.XPATH, f"//*[@role='tabpanel']//div[@role='link'][contains(@aria-label,{lit})]"),
]
for by, xpath in row_locators:
targets.extend(driver.find_elements(by, xpath))
handles_before = list(driver.window_handles)
planner_handle = driver.current_window_handle
for el in targets:
try:
if el is None or not el.is_displayed():
continue
label = filename or (el.get_attribute("aria-label") or el.text or "")[:80]
log_step("Download (click attachment link)", name=label[:60])
_agent_dbg_eb(
"H5",
"onedrive_planner:_download_via_attachment_link_click",
"click_span",
{"tag": getattr(el, "tag_name", ""), "file": filename[:40]},
)
_js_click(driver, el)
time.sleep(2)
new_handles = [h for h in driver.window_handles if h not in handles_before]
if new_handles:
driver.switch_to.window(new_handles[-1])
new_files = _finish_sharepoint_tab_download(
driver, dest_folder, href, before
)
_close_extra_download_tabs(driver, planner_handle)
try:
driver.switch_to.window(planner_handle)
except Exception:
pass
if new_files:
return new_files
else:
cur = (driver.current_url or "").strip()
if _is_attachment_href(cur):
new_files = _finish_sharepoint_tab_download(
driver, dest_folder, href, before
)
if new_files:
return new_files
_close_extra_download_tabs(driver, planner_handle)
try:
driver.switch_to.window(planner_handle)
except Exception:
pass
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(0.5)
except Exception:
continue
return []
def _download_attachment_row(
driver: WebDriver,
dest_folder: Path,
row,
before: set[str],
*,
planner_handle: str | None = None,
href: str | None = None,
) -> list[Path]:
"""Download one attachment by href (row element optional; may be stale)."""
url = href or _url_from_attachment_element(row)
if not url:
return []
return _download_attachment_by_href(
driver,
dest_folder,
url,
before,
planner_handle=planner_handle,
)
def _download_href_with_fallback(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
*,
planner_handle: str | None = None,
) -> list[Path]:
"""
Per-URL fallbacks when row-based download did not run.
Order matches legacy UiPath: browser session first, HTTP last (HTTP often returns HTML login).
"""
new_files = _download_one_href_browser_new_tab(
driver, dest_folder, href, before, planner_handle=planner_handle
)
if new_files:
return new_files
new_files = _download_one_href_browser_same_tab(
driver, dest_folder, href, before, planner_handle=planner_handle
)
if new_files:
return new_files
new_files = _download_via_attachment_link_click(driver, dest_folder, href, before)
if new_files:
return new_files
log_step("Browser methods failed — PDF viewer tab", url=href[:120])
new_files = _download_href_viewer_new_tab(
driver, dest_folder, href, before, planner_handle=planner_handle
)
if new_files:
return new_files
log_step("Trying HTTP download (last resort)", url=href[:120])
return _download_href_via_http(driver, dest_folder, href, before)
def _download_one_href_same_tab(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
) -> list[Path]:
"""Primary HTTP + legacy browser fallbacks."""
return _download_href_with_fallback(driver, dest_folder, href, before)
def _download_one_href_new_tab(
driver: WebDriver,
dest_folder: Path,
href: str,
before: set[str],
) -> list[Path]:
"""Same as _download_href_with_fallback (kept for API compatibility)."""
return _download_href_with_fallback(driver, dest_folder, href, before)
def _try_download_via_card_menu(driver: WebDriver, dest_folder: Path, before: set[str]) -> list[Path]:
"""Fallback: (...) on attachment card -> Download."""
downloaded: list[Path] = []
menu_buttons = driver.find_elements(
By.XPATH,
"//*[@role='tabpanel']//button[contains(@aria-label,'More') or contains(@aria-label,'options')]",
)
download_item = [
(By.XPATH, "//*[@role='menuitem' and contains(.,'Download')]"),
(By.XPATH, "//button[contains(.,'Download')]"),
]
for btn in menu_buttons[:20]:
try:
if not btn.is_displayed():
continue
_js_click(driver, btn)
time.sleep(0.5)
_click_any(driver, download_item, timeout=3)
time.sleep(1)
new_files = _wait_for_new_download(dest_folder, before)
if new_files:
downloaded.extend(new_files)
before |= {p.name for p in new_files}
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(0.3)
except Exception:
continue
return downloaded
def _merge_attachment_hrefs(*sources: list[str]) -> list[str]:
"""Dedupe attachment URLs by full URL and by PDF filename."""
merged: list[str] = []
seen_urls: set[str] = set()
seen_names: set[str] = set()
def _add(url: str | None) -> None:
if not url or not _is_attachment_href(url):
return
if url in seen_urls:
return
name_key = _filename_from_url(url).strip().lower()
if name_key and name_key in seen_names:
return
seen_urls.add(url)
if name_key:
seen_names.add(name_key)
merged.append(url)
for source in sources:
for url in source:
_add(url)
return merged
def _collect_all_attachment_hrefs(driver: WebDriver) -> list[str]:
"""
Collect every attachment URL on the open task (N files per task).
Uses listed filenames first (one li per PDF), then row scan, then broad DOM scan.
"""
_scroll_attachments_panel(driver, passes=6)
expected = _attachment_tab_count(driver)
filename_urls: list[str] = []
for fn in _discover_listed_attachment_names(driver):
url = _href_for_listed_attachment(driver, fn)
if url:
filename_urls.append(url)
rows = _list_attachment_rows(driver)
row_urls = _hrefs_from_attachment_rows(rows)
broad_urls = _collect_attachment_hrefs(driver)
hrefs = _merge_attachment_hrefs(filename_urls, row_urls, broad_urls)
if expected > len(hrefs):
_scroll_attachments_panel(driver, passes=8)
retry_names = _discover_listed_attachment_names(driver)
retry_urls: list[str] = []
for fn in retry_names:
url = _href_for_listed_attachment(driver, fn)
if url:
retry_urls.append(url)
hrefs = _merge_attachment_hrefs(hrefs, retry_urls)
log_step(
"Collected attachment URLs",
count=len(hrefs),
expected=expected or len(hrefs),
listed_names=len(_discover_listed_attachment_names(driver)),
)
return hrefs
def _should_mark_task_in_progress(downloaded: list[Path], expected_count: int) -> bool:
"""Mark In Progress only when ALL expected attachments have been saved.
If expected_count is 0 or unknown, any download is sufficient.
Otherwise require all files so tasks with multiple attachments are not
prematurely marked and lost on subsequent runs.
"""
if not downloaded:
return False
if expected_count <= 0:
return True
return len(downloaded) >= expected_count
def _download_attachments_new_tab(driver: WebDriver, dest_folder: Path) -> tuple[list[Path], int]:
"""
Download all attachments for the open task (Attachments tab).
Returns (downloaded_paths, expected_attachment_count).
Status must not be changed until all expected files are saved.
Primary flow (manual / user UI):
1. Click each attachment href span in the li list (not (...) menu)
2. Browser ?download=1 tab and viewer fallbacks; HTTP last
Keeps the task panel open between files — only closes extra SharePoint tabs.
"""
downloaded: list[Path] = []
before = {p.name for p in dest_folder.iterdir() if p.is_file()} if dest_folder.exists() else set()
_open_attachments_view(driver)
time.sleep(1.5)
_scroll_attachments_panel(driver)
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=True)
planner_handle = driver.current_window_handle
saved_planner_url = driver.current_url or ""
hrefs = _collect_all_attachment_hrefs(driver)
expected_count = len(hrefs) or _attachment_tab_count(driver)
rows = _list_attachment_rows(driver)
log_step(
"Attachment rows on panel",
count=len(rows),
urls=len(hrefs),
expected=expected_count,
dest=str(dest_folder),
)
_agent_dbg_eb(
"H4",
"onedrive_planner:_download_attachments_new_tab",
"hrefs_collected",
{"hrefs": len(hrefs), "rows": len(rows)},
)
downloaded_names = {p.name.lower() for p in downloaded}
for idx, href in enumerate(hrefs, start=1):
name = _filename_from_url(href).lower()
if name in downloaded_names:
continue
try:
log_step(
"Download attachment",
index=f"{idx}/{len(hrefs)}",
file=_filename_from_url(href)[:50],
)
new_files = _download_attachment_by_href(
driver,
dest_folder,
href,
before,
planner_handle=planner_handle,
)
if new_files:
downloaded.extend(new_files)
before |= {p.name for p in new_files}
downloaded_names |= {p.name.lower() for p in new_files}
else:
log_step("Attachment download failed", index=str(idx))
except Exception as exc:
_log.warning("Attachment %d failed: %s", idx, exc)
_close_extra_download_tabs(driver, planner_handle)
try:
driver.switch_to.window(planner_handle)
_open_attachments_view(driver)
except Exception:
pass
_close_extra_download_tabs(driver, planner_handle)
try:
driver.switch_to.window(planner_handle)
if saved_planner_url and "/task/" in saved_planner_url:
if (driver.current_url or "") != saved_planner_url:
driver.get(saved_planner_url)
time.sleep(1)
_open_attachments_view(driver)
_switch_to_planner_context(driver)
except Exception:
pass
log_step(
"Attachment download complete",
files=len(downloaded),
expected=expected_count,
)
return downloaded, expected_count
def open_planner_in_my_plans(
driver: WebDriver,
*,
planner_url: str | None = None,
) -> None:
"""
Deprecated: waffle-menu navigation. Use :func:`login_and_open_planner` with ``Planner URL``.
"""
print("[INFO] Opening Microsoft Planner...")
driver.switch_to.default_content()
time.sleep(2)
# Hypothesis H5: Direct navigation to planner.cloud.microsoft is more stable
# than the app-launcher click path (waffle + Planner).
target = (planner_url or "").strip()
if not target:
target = "https://planner.cloud.microsoft/webui/"
_dbg(
"H5",
"src/onedrive_planner.py:open_planner_in_my_plans",
"Navigating to Planner URL",
{"planner_url": target[:160]},
)
try:
driver.get(target)
time.sleep(5)
except Exception as exc:
_dbg(
"H5",
"src/onedrive_planner.py:open_planner_in_my_plans",
"Direct Planner navigation failed",
{"error": str(exc)[:200]},
)
current = (driver.current_url or "").lower()
if _on_planner_app(driver) or "tasks.office.com" in current:
print("[INFO] Already on Planner web UI")
else:
waffle_locators = [
(By.ID, "O365_MainLink_NavMenu"),
(By.CSS_SELECTOR, "button[aria-label*='App launcher']"),
(By.CSS_SELECTOR, "button[aria-label*='Waffle']"),
(By.XPATH, "//button[contains(@aria-label,'App launcher')]"),
]
try:
_click_any(driver, waffle_locators, timeout=WAIT_MED)
time.sleep(2)
except TimeoutException:
print("[WARN] App launcher not found — trying direct Planner URL")
driver.get("https://tasks.office.com/")
time.sleep(5)
planner_link_locators = [
(By.XPATH, "//a[contains(@href,'tasks.office.com') or contains(.,'Planner')]"),
(By.XPATH, "//button[contains(.,'Planner')]"),
(By.XPATH, "//*[contains(@aria-label,'Planner')]"),
(By.PARTIAL_LINK_TEXT, "Planner"),
]
try:
_click_any(driver, planner_link_locators, timeout=WAIT_MED)
time.sleep(5)
except TimeoutException:
driver.get("https://tasks.office.com/")
time.sleep(5)
_switch_to_planner_context(driver)
_ensure_left_nav_open(driver)
try:
_click_any(driver, _MY_PLANS_NAV_LOCATORS, timeout=WAIT_MED)
time.sleep(3)
print("[INFO] Opened 'My plans' view")
except TimeoutException:
print("[WARN] 'My plans' link not found — continuing on current Planner view")
driver.switch_to.default_content()
_switch_to_planner_context(driver)
def _search_facility_plan(driver: WebDriver, facility_name: str) -> None:
"""Type facility name into Planner search / filter."""
if not _switch_to_planner_context(driver):
driver.switch_to.default_content()
_switch_to_planner_context(driver)
search_locators = [
(By.CSS_SELECTOR, "input[aria-label*='Search']"),
(By.CSS_SELECTOR, "input[placeholder*='Search']"),
(By.XPATH, "//input[contains(@aria-label,'Search') or contains(@placeholder,'Search')]"),
(By.CSS_SELECTOR, "input[type='search']"),
(By.CSS_SELECTOR, "[role='searchbox']"),
]
def _search_visible(drv: WebDriver) -> bool:
for by, sel in search_locators:
try:
el = drv.find_element(by, sel)
if el.is_displayed():
return True
except NoSuchElementException:
continue
return False
_wait(driver, WAIT_MED).until(_search_visible)
_type_into(driver, search_locators, facility_name, clear=True)
_first_clickable(driver, search_locators).send_keys(Keys.ENTER)
time.sleep(2)
def _open_facility_plan(driver: WebDriver, facility_name: str) -> None:
_search_facility_plan(driver, facility_name)
lit = _xpath_literal(facility_name)
plan_locators = [
(By.XPATH, f"//a[contains(., {lit})]"),
(By.XPATH, f"//button[contains(., {lit})]"),
(By.XPATH, f"//span[contains(., {lit})]"),
]
if len(facility_name) <= 40:
plan_locators.append((By.PARTIAL_LINK_TEXT, facility_name))
try:
_click_any(driver, plan_locators, timeout=WAIT_MED)
except TimeoutException:
name_lower = facility_name.lower()
for el in driver.find_elements(By.XPATH, "//a|//button|//span"):
try:
if not el.is_displayed():
continue
label = (el.text or el.get_attribute("aria-label") or "").strip()
if name_lower in label.lower():
_js_click(driver, el)
time.sleep(3)
return
except StaleElementReferenceException:
continue
results = driver.find_elements(
By.XPATH,
"//a[contains(@href,'planId') or contains(@href,'/plan/')]",
)
if results:
_js_click(driver, results[0])
else:
raise RuntimeError(
f"No Planner plan found for facility '{facility_name}'"
)
_log.info("Opened plan for facility: %s", facility_name)
time.sleep(2)
def _task_is_completed(task_el) -> bool:
"""Skip tasks whose complete checkbox is checked (aria-checked=true)."""
try:
for cb in task_el.find_elements(By.XPATH, _COMPLETION_CHECKBOX_CHECKED_XPATH):
if not cb.is_displayed():
continue
aria = (cb.get_attribute("aria-label") or "").lower()
if "complete" in aria or aria == "":
return True
# Card-level text only — do not scan all descendants (board noise).
label = (
(task_el.text or "")
+ " "
+ (task_el.get_attribute("aria-label") or "")
).lower()
if re.search(r"\bcompleted\b", label) and "not started" not in label:
return True
except StaleElementReferenceException:
return False
return False
def _iter_task_cards_with_attachments(driver: WebDriver) -> list:
"""Return open task elements that appear to have PDF/attachment indicators."""
xpath_tasks = (
"//div[contains(@class,'task') or contains(@data-testid,'task')]"
"[.//*[contains(@aria-label,'Attachment') or contains(@aria-label,'attachment')"
" or contains(@aria-label,'.pdf') or contains(.,'.pdf')]]"
)
tasks = driver.find_elements(By.XPATH, xpath_tasks)
if not tasks:
tasks = driver.find_elements(
By.XPATH,
"//button[contains(@data-testid,'task') or contains(@class,'TaskCard')]"
"[.//*[contains(@aria-label,'Attachment') or contains(.,'.pdf')]]",
)
open_tasks = [t for t in tasks if t.is_displayed() and not _task_is_completed(t)]
if len(open_tasks) < len(tasks):
_log.info(
"Skipped %d completed task(s)",
len(tasks) - len(open_tasks),
)
return open_tasks
def _open_task_detail(driver: WebDriver, task_el) -> None:
_js_click(driver, task_el)
time.sleep(2)
def _download_attachments_from_task(
driver: WebDriver,
dest_folder: Path,
) -> list[Path]:
downloaded: list[Path] = []
before = {p.name for p in dest_folder.iterdir() if p.is_file()} if dest_folder.exists() else set()
attachment_locators = [
(By.XPATH, "//a[contains(@aria-label,'Download') or contains(.,'Download')]"),
(By.XPATH, "//button[contains(@aria-label,'Download')]"),
(By.XPATH, "//a[contains(@href,'.pdf') or contains(.,'.pdf')]"),
(By.XPATH, "//*[contains(@class,'attachment')]//a"),
]
links = []
for by, xpath in attachment_locators:
links.extend(driver.find_elements(by, xpath))
seen = set()
unique_links = []
for link in links:
key = link.get_attribute("href") or link.text
if key and key not in seen and link.is_displayed():
seen.add(key)
unique_links.append(link)
if not unique_links:
return downloaded
pdf_links = []
for link in unique_links:
href = (link.get_attribute("href") or "").lower()
label = (link.text or link.get_attribute("aria-label") or "").lower()
if ".pdf" in href or ".pdf" in label or "download" in label:
pdf_links.append(link)
if not pdf_links:
pdf_links = unique_links
for link in pdf_links:
try:
before_dl = {p.name for p in dest_folder.iterdir() if p.is_file()}
_js_click(driver, link)
new_files = _wait_for_new_download(dest_folder, before_dl)
downloaded.extend(new_files)
before |= {p.name for p in new_files}
except Exception as exc:
_log.warning("Attachment download click failed: %s", exc)
return downloaded
def _set_task_in_process(driver: WebDriver) -> None:
"""Legacy: set current task bucket/status to In-Process."""
bucket_locators = [
(By.XPATH, "//button[contains(@aria-label,'Bucket') or contains(@aria-label,'bucket')]"),
(By.XPATH, "//button[contains(.,'Bucket')]"),
(By.XPATH, "//*[contains(@data-testid,'bucket')]//button"),
]
in_process_locators = [
(By.XPATH, "//*[contains(text(),'In-Process') or contains(text(),'In Process')]"),
(By.XPATH, "//button[contains(.,'In-Process') or contains(.,'In Process')]"),
(By.XPATH, "//li[contains(.,'In-Process') or contains(.,'In Process')]"),
]
try:
_click_any(driver, bucket_locators, timeout=WAIT_SHORT)
time.sleep(1)
_click_any(driver, in_process_locators, timeout=WAIT_SHORT)
time.sleep(1)
print("[INFO] Task marked In-Process")
except TimeoutException:
progress_locators = [
(By.XPATH, "//button[contains(@aria-label,'Progress') or contains(@aria-label,'progress')]"),
(By.XPATH, "//*[contains(@data-testid,'progress')]//button"),
]
try:
_click_any(driver, progress_locators, timeout=WAIT_SHORT)
_click_any(driver, in_process_locators, timeout=WAIT_SHORT)
print("[INFO] Task progress set to In-Process")
except TimeoutException:
_log.warning("Could not set In-Process — update selector if plan columns differ")
_IN_PROGRESS_OPTION_LOCATORS = [
(By.XPATH, "//*[@role='menuitem' and normalize-space()='In progress']"),
(By.XPATH, "//*[@role='option' and normalize-space()='In progress']"),
(By.XPATH, "//button[normalize-space()='In progress']"),
(By.XPATH, "//li[normalize-space()='In progress']"),
(By.XPATH, "//*[normalize-space()='In progress']"),
]
def _first_clickable_within(
parent,
locators: list[tuple[str, str]],
*,
timeout: float = 5.0,
):
"""Find first displayed/enabled element under a task card (relative locators)."""
end = time.time() + timeout
last_err = None
while time.time() < end:
for by, value in locators:
try:
for el in parent.find_elements(by, value):
if el.is_displayed() and el.is_enabled():
return el
except StaleElementReferenceException as exc:
last_err = exc
time.sleep(0.3)
raise TimeoutException(f"No clickable element within card (last: {last_err})")
def _click_in_progress_option(driver: WebDriver) -> None:
"""Pick In progress from the open menu only (not another task on the board)."""
menu_scoped = [
(By.XPATH, "//*[@role='menu']//*[@role='menuitem' and normalize-space()='In progress']"),
(By.XPATH, "//*[@role='listbox']//*[@role='option' and normalize-space()='In progress']"),
(By.XPATH, "//*[@role='menu']//*[normalize-space()='In progress']"),
]
end = time.time() + WAIT_SHORT
while time.time() < end:
for by, sel in menu_scoped + _IN_PROGRESS_OPTION_LOCATORS:
try:
for el in driver.find_elements(by, sel):
if el.is_displayed() and el.is_enabled():
_js_click(driver, el)
time.sleep(0.5)
return
except StaleElementReferenceException:
continue
time.sleep(0.3)
raise TimeoutException("In progress menu option not found")
def _hover_element(driver: WebDriver, element) -> None:
try:
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", element)
ActionChains(driver).move_to_element(element).perform()
time.sleep(0.4)
except Exception:
pass
def _task_card_unique_key(card) -> str:
"""Stable per-task key (Planner data-id / aria-label), not Python object id."""
try:
for node in card.find_elements(
By.XPATH,
"./ancestor-or-self::*[@data-dnd-role='card'][1]"
" | ./ancestor-or-self::div[contains(@class,'planner-draggable-task-card')][1]",
):
for attr in ("data-id", "id"):
val = (node.get_attribute(attr) or "").strip()
if val:
return f"id:{val}"
aria = (node.get_attribute("aria-label") or "").strip().lower()
if aria and "use tab key" not in aria:
return f"aria:{aria}"
except StaleElementReferenceException:
pass
snippet = _task_card_snippet(card).strip().lower()
return f"text:{snippet}" if snippet else f"obj:{id(card)}"
def _task_card_snippet(task_el) -> str:
"""Task title from card textbox (not bucket column accessibility text)."""
try:
if _is_planner_bucket_column(task_el):
for box in task_el.find_elements(By.XPATH, ".//*[@role='textbox']"):
line = ((box.text or "").strip().splitlines() or [""])[0].strip()
if line and "use tab key" not in line.lower():
return line[:80]
return ""
for box in task_el.find_elements(By.XPATH, ".//*[@role='textbox']"):
line = ((box.text or "").strip().splitlines() or [""])[0].strip()
if line:
return line[:80]
line = ((task_el.text or "").strip().splitlines() or [""])[0].strip()
if "use tab key" in line.lower():
return ""
return line[:80]
except StaleElementReferenceException:
return ""
def _task_card_matches_title(card, title: str) -> bool:
"""True when this card is the task we processed (e.g. '5.29.26' in To do)."""
want = (title or "").strip().lower()
if not want:
return False
try:
lines = [ln.strip().lower() for ln in (card.text or "").splitlines() if ln.strip()]
if lines and (lines[0] == want or want in lines[0]):
return True
for box in card.find_elements(By.XPATH, ".//*[@role='textbox']"):
box_text = (box.text or "").strip().lower()
if box_text == want or want in box_text:
return True
except StaleElementReferenceException:
return False
blob = ((card.text or "") + " " + (card.get_attribute("aria-label") or "")).lower()
return want in blob and "completed tasks" not in blob[:40]
def _refind_task_card_on_board(driver: WebDriver, title: str):
"""Re-locate the exact board card by task title after download (avoid stale/wrong card)."""
title = (title or "").strip()
if not title:
return None
_close_task_panel(driver)
_ensure_board_scan_context(driver)
lit = _xpath_literal(title[:60])
lit_contains = _xpath_literal(title[:40])
xpaths = [
f"//div[.//*[@role='textbox' and (normalize-space()={lit} or contains(normalize-space(), {lit_contains}))]]",
f"//div[.//button[@data-testid='attachment-indicator'] and contains(normalize-space(.), {lit_contains})]",
f"//*[.//label[@role='checkbox' and @aria-checked='false'] and contains(normalize-space(.), {lit_contains})]",
f"//button[contains(normalize-space(.), {lit_contains})]",
]
for xpath in xpaths:
for el in _find_in_best_board_context(driver, xpath):
try:
card = _ancestor_task_card(el)
if not card.is_displayed():
continue
if _is_in_completed_tasks_section(card):
continue
if _task_card_matches_title(card, title):
log_step("Refound task card on board", title=title[:60])
return card
except StaleElementReferenceException:
continue
return None
def _task_details_panel_open(driver: WebDriver) -> bool:
"""True when the task details side panel is visible (Status / Task details tab)."""
probes = [
(By.XPATH, "//*[normalize-space()='Task details']"),
(By.XPATH, "//*[normalize-space()='Status']"),
(By.XPATH, "//button[@aria-label='Close' or @aria-label='Close task']"),
]
for by, sel in probes:
try:
for el in driver.find_elements(by, sel):
if el.is_displayed():
return True
except Exception:
continue
return False
def _set_task_in_progress_from_board(driver: WebDriver, task_el, *, task_title: str = "") -> bool:
"""
Method 1: Mark In progress on THIS card only (To do → half-filled circle).
Clicks progress/status controls inside the card — never a global board search.
"""
log_step("Set In progress — method 1 (this board card)", title=(task_title or _task_card_snippet(task_el))[:60])
_close_task_panel(driver)
time.sleep(0.5)
title = (task_title or _task_card_snippet(task_el)).strip()
card = _refind_task_card_on_board(driver, title) if title else None
if card is None:
card = task_el
if _task_is_in_progress(card):
log_step("Task already In progress (skip)", title=title[:60])
return True
_hover_element(driver, card)
time.sleep(0.3)
# Empty complete checkbox / progress ring on this card only.
card_progress_locators = [
(By.XPATH, ".//label[@role='checkbox' and @aria-checked='false']"),
(By.XPATH, ".//*[@role='checkbox' and @aria-checked='false']"),
(By.XPATH, ".//button[contains(@aria-label,'Progress') or contains(@aria-label,'progress')]"),
(By.XPATH, ".//button[contains(@aria-label,'Not started')]"),
(By.XPATH, ".//button[contains(@aria-label,'status') or contains(@aria-label,'Status')]"),
]
for locators in (card_progress_locators,):
try:
btn = _first_clickable_within(card, locators, timeout=4.0)
_js_click(driver, btn)
time.sleep(0.5)
_click_in_progress_option(driver)
log_step("In progress set via this card", title=title[:60])
return True
except (TimeoutException, NoSuchElementException, StaleElementReferenceException):
continue
card_menu_locators = [
(By.XPATH, ".//button[contains(@aria-label,'More') or contains(@aria-label,'options') or contains(@aria-label,'Show more')]"),
(By.XPATH, ".//button[contains(@aria-label,'More task')]"),
]
progress_submenu_locators = [
(By.XPATH, ".//*[@role='menuitem' and (contains(.,'Progress') or contains(.,'Status'))]"),
]
try:
menu_btn = _first_clickable_within(card, card_menu_locators, timeout=4.0)
_js_click(driver, menu_btn)
time.sleep(0.5)
try:
submenu = _first_clickable_within(card, progress_submenu_locators, timeout=2.0)
_js_click(driver, submenu)
time.sleep(0.5)
except (TimeoutException, NoSuchElementException):
try:
for el in driver.find_elements(By.XPATH, "//*[@role='menu']//*[@role='menuitem']"):
if el.is_displayed() and "progress" in (el.text or "").lower():
_js_click(driver, el)
time.sleep(0.5)
break
except Exception:
pass
_click_in_progress_option(driver)
log_step("In progress set via this card menu", title=title[:60])
return True
except (TimeoutException, NoSuchElementException, StaleElementReferenceException):
return False
def _set_task_in_progress_from_task_details(
driver: WebDriver,
*,
expected_title: str = "",
) -> bool:
"""
Method 2: Task details tab -> Status (Not started) -> In progress.
Matches UiPath: Task details tab, button *Not started*, li In progress.
"""
log_step("Set In progress — task details → Status", title=expected_title[:60])
task_details_tab_locators = [
(By.XPATH, "//*[@role='tab' and normalize-space()='Task details']"),
(By.XPATH, "//*[@role='tab' and contains(normalize-space(),'Task details')]"),
(By.XPATH, "//button[normalize-space()='Task details']"),
(By.XPATH, "//*[self::button or self::div][normalize-space()='Task details']"),
]
try:
_click_any(driver, task_details_tab_locators, timeout=WAIT_SHORT)
time.sleep(PANEL_TAB_LOAD_DELAY)
except TimeoutException:
log_step("Task details tab not found — trying Status on current panel")
if expected_title:
try:
panel_text = " ".join(
el.text for el in driver.find_elements(By.XPATH, "//*[@role='textbox']")[:3] if el.is_displayed()
)
if expected_title.lower() not in panel_text.lower():
log_step(
"Task panel title mismatch (continuing)",
expected=expected_title[:40],
panel=panel_text[:80],
)
except Exception:
pass
if _task_details_shows_in_progress(driver):
log_step("Task already In progress in panel", title=expected_title[:60])
return False
status_dropdown_locators = [
(By.XPATH, ".//button[contains(@aria-label,'Not started')]"),
(By.XPATH, ".//button[contains(@aria-label,'In progress') or contains(@aria-label,'In-progress')]"),
(By.XPATH, ".//*[normalize-space()='Status']/following::button[contains(@aria-label,'Not started')][1]"),
(By.XPATH, ".//*[normalize-space()='Status']/following::button[1]"),
(By.XPATH, ".//button[contains(@aria-label,'status') or contains(@aria-label,'Status')]"),
(By.XPATH, ".//*[@role='combobox' and (contains(@aria-label,'Status') or contains(@class,'ComboBox'))]"),
]
in_progress_direct = [
(By.XPATH, "//li[normalize-space()='In progress']"),
(By.XPATH, "//*[@role='menuitem' and normalize-space()='In progress']"),
]
try:
clicked = False
for root in _task_details_panel_roots(driver) or [driver]:
try:
btn = _first_clickable_within(root, status_dropdown_locators, timeout=4.0)
_js_click(driver, btn)
clicked = True
break
except (TimeoutException, NoSuchElementException, StaleElementReferenceException):
continue
if not clicked:
log_step("Status dropdown not found in task panel", title=expected_title[:60])
return False
time.sleep(0.8)
try:
_click_in_progress_option(driver)
except TimeoutException:
_click_any(driver, in_progress_direct, timeout=WAIT_SHORT)
for attempt in range(10):
time.sleep(0.5 if attempt else PANEL_TAB_LOAD_DELAY)
if _task_details_shows_in_progress(driver):
log_step("In progress set via task details Status", title=expected_title[:60])
return True
log_step("In progress not confirmed in panel", title=expected_title[:60])
return False
except TimeoutException:
return False
def _task_details_panel_roots(driver: WebDriver) -> list:
"""Containers for the open task side panel — excludes board cards behind it."""
roots: list = []
for xpath in (
"//*[@role='dialog'][.//*[@role='textbox']]",
"//*[contains(@class,'TaskDetails') or contains(@class,'taskDetails')]",
"//div[.//button[contains(@aria-label,'Close task')] and .//*[@role='textbox']]",
):
try:
for el in driver.find_elements(By.XPATH, xpath):
if el.is_displayed():
roots.append(el)
except Exception:
continue
if roots:
return roots
for close in driver.find_elements(
By.XPATH,
"//button[@aria-label='Close' or @aria-label='Close task']",
):
if not close.is_displayed():
continue
try:
anc = close.find_element(
By.XPATH,
"./ancestor::*[.//*[@role='textbox']][1]",
)
if anc.is_displayed():
return [anc]
except NoSuchElementException:
continue
return []
def _panel_progress_status_label(root) -> str | None:
"""Read Status from buttons/combobox inside the task panel only."""
try:
for combo in root.find_elements(
By.XPATH,
".//*[@role='combobox' or contains(@class,'ComboBox')]",
):
if not combo.is_displayed():
continue
value = (combo.get_attribute("value") or combo.get_attribute("data-value") or "").strip().lower()
if value in ("notstarted", "not started", "0"):
return "not started"
if value in ("inprogress", "in progress", "in-progress", "1"):
return "in progress"
if value in ("completed", "2"):
return "completed"
text = (combo.text or combo.get_attribute("aria-label") or "").strip().lower()
if text == "not started" or text.startswith("not started"):
return "not started"
if text in ("in progress", "in-progress") or text.startswith("in progress"):
return "in progress"
if "completed" in text:
return "completed"
for btn in root.find_elements(
By.XPATH,
".//button[contains(@aria-label,'Not started') or contains(@aria-label,'In progress') "
"or contains(@aria-label,'In-progress') or contains(@aria-label,'Completed')]",
):
if not btn.is_displayed():
continue
aria = (btn.get_attribute("aria-label") or "").strip().lower()
if aria == "not started" or aria.startswith("not started"):
return "not started"
if aria in ("in progress", "in-progress") or aria.startswith("in progress"):
return "in progress"
if "completed" in aria:
return "completed"
btn_text = (btn.text or "").strip().lower()
if btn_text == "not started":
return "not started"
if btn_text in ("in progress", "in-progress"):
return "in progress"
if btn_text == "completed":
return "completed"
for label_el in root.find_elements(By.XPATH, ".//*[normalize-space()='Status']"):
if not label_el.is_displayed():
continue
try:
btn = label_el.find_element(
By.XPATH,
"following::button[contains(@aria-label,'started') or contains(@aria-label,'progress') or contains(@aria-label,'Completed')][1]",
)
if btn.is_displayed():
aria = (btn.get_attribute("aria-label") or "").strip().lower()
if "not started" in aria:
return "not started"
if "in progress" in aria or "in-progress" in aria:
return "in progress"
if "completed" in aria:
return "completed"
except NoSuchElementException:
continue
except StaleElementReferenceException:
return None
return None
def _task_details_progress_status(driver: WebDriver) -> str | None:
for root in _task_details_panel_roots(driver):
status = _panel_progress_status_label(root)
if status:
return status
return None
def _task_details_shows_not_started(driver: WebDriver) -> bool:
return _task_details_progress_status(driver) == "not started"
def _task_details_shows_in_progress(driver: WebDriver) -> bool:
"""True only when the open task panel Status is In progress (not board cards behind)."""
return _task_details_progress_status(driver) == "in progress"
def _mark_task_in_progress(driver: WebDriver, task_el, *, task_title: str = "") -> tuple[bool, bool]:
"""
Mark the downloaded task In progress via Task details → Status only.
Single attempt only — no panel re-open retry — to avoid the status ring
appearing to change twice in the UI.
"""
title = (task_title or _task_card_snippet(task_el)).strip()
log_step("Mark downloaded task In progress (task details only)", title=title[:60])
time.sleep(PANEL_TAB_LOAD_DELAY)
# Fast-path: already in progress (prior partial attempt or previous run).
if _task_details_shows_in_progress(driver):
log_step("Task already In progress in panel — skipping", title=title[:60])
_close_task_panel(driver)
return True, True
if _set_task_in_progress_from_task_details(driver, expected_title=title):
_close_task_panel(driver)
log_step("Task status changed via task details", title=title[:60])
return True, True
# _set_task_in_progress_from_task_details returns False for two reasons:
# (a) genuinely could not set, or (b) task was already In Progress.
# Check (b) before reporting failure.
if _task_details_shows_in_progress(driver):
log_step("Task already In progress in panel (confirmed after attempt)", title=title[:60])
_close_task_panel(driver)
return True, True
_close_task_panel(driver)
log_step("Could not set In progress via task details", title=title[:60])
_log.warning("Could not set In progress for task '%s'", title)
return False, False
def _set_task_in_progress(driver: WebDriver, task_el=None) -> None:
"""Backward-compatible wrapper; pass task_el for two-method flow."""
if task_el is not None:
_mark_task_in_progress(driver, task_el)
return
if _set_task_in_progress_from_task_details(driver):
log_step("Task progress set to In progress (details only)")
return
log_step("Could not set In progress (selector mismatch)")
_log.warning("Could not set task progress to In progress — pass task_el for full flow")
def save_failure_screenshot(
driver: WebDriver,
facility_name: str,
*,
failure_log_dir: Path,
) -> None:
"""Save PNG under Output/<date>/logs/failures/ (never under Original/Workable)."""
try:
failure_log_dir.mkdir(parents=True, exist_ok=True)
safe = _sanitize_folder_name(facility_name)[:40]
path = failure_log_dir / f"failure_{safe}_{int(time.time())}.png"
driver.save_screenshot(str(path))
_log.info("Saved failure screenshot -> %s", path)
except Exception as exc:
_log.warning("Could not save screenshot: %s", exc)
def save_board_screenshot(
driver: WebDriver,
facility_name: str,
*,
screenshots_dir: Path | None = None,
task_count: int = 0,
) -> None:
"""Save full-screen board-view screenshot before switching to Grid."""
try:
shots_dir = screenshots_dir or (
PROJECT_ROOT / "Output" / TODAY_ISO_DATE / "Screenshots"
)
shots_dir.mkdir(parents=True, exist_ok=True)
safe = _sanitize_folder_name(facility_name)
taken_at = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
path = shots_dir / f"{safe}_{taken_at}.png"
capture_browser_screenshot(driver, path)
log_step(
"Board screenshot saved",
facility=facility_name,
tasks=task_count,
path=str(path),
fullscreen="false",
view="board",
)
if task_count:
_log.info(
"Saved board screenshot -> %s (%d not-started tasks)",
path,
task_count,
)
else:
_log.info("Saved board screenshot -> %s (board view, before grid)", path)
except Exception as exc:
_log.warning("Could not save board screenshot: %s", exc)
def _close_task_panel(driver: WebDriver) -> None:
close_locators = [
(By.XPATH, "//button[@aria-label='Close' or @aria-label='Close task']"),
(By.XPATH, "//button[contains(@aria-label,'Close')]"),
(By.CSS_SELECTOR, "button[data-testid='close-button']"),
]
try:
_click_any(driver, close_locators, timeout=5)
time.sleep(1)
except TimeoutException:
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(0.5)
@dataclass
class FacilityRunResult:
downloaded: list[Path] = field(default_factory=list)
tasks_total: int = 0
tasks_with_attachments: int = 0 # not-started tasks that had attachment files
tasks_skipped_no_attachment: int = 0 # not-started tasks opened but had no files
tasks_with_downloads: int = 0 # tasks that saved at least one attachment
status_changed: int = 0 # tasks marked In progress (summary numerator)
status_via_task_details: int = 0 # tasks updated via Task details panel
def process_facility(
driver: WebDriver,
facility_name: str,
dest_folder: Path,
*,
screenshot_on_error: bool = False,
failure_log_dir: Path | None = None,
) -> FacilityRunResult:
"""
My plans -> Shared -> open facility plan (Grid view), process Not started
tasks from the Status column, download attachments, mark In progress.
"""
dest_folder = Path(dest_folder)
log_step("=== Facility workflow start ===", facility=facility_name, dest=str(dest_folder))
driver.switch_to.default_content()
_switch_to_planner_context(driver)
try:
open_my_plans_shared(driver)
_open_facility_from_shared_list(driver, facility_name)
_wait_for_board_loaded(driver)
save_board_screenshot(driver, facility_name)
_ensure_grid_view(driver)
_ensure_status_sort_descending(driver)
driver.switch_to.default_content()
time.sleep(2)
except Exception:
if screenshot_on_error and failure_log_dir is not None:
save_failure_screenshot(driver, facility_name, failure_log_dir=failure_log_dir)
raise
all_downloaded: list[Path] = []
tasks_with_downloads = 0
tasks_with_attachments = 0
tasks_skipped_no_attachment = 0
status_changed = 0
status_via_task_details = 0
if not _grid_probes_present(driver):
log_step("Grid not visible — waiting for grid to settle", facility=facility_name)
time.sleep(6)
task_entries = _scan_grid_not_started_titles(driver)
task_count = len(task_entries)
log_step(
"Scan tasks on grid",
task_count=task_count,
facility=facility_name,
)
if not task_count:
log_step("No not-started tasks on grid", facility=facility_name)
return FacilityRunResult()
download_dir_ready = False
processed_indices: set[int] = set()
for task_num, (grid_title, row_index) in enumerate(task_entries, start=1):
title_key = grid_title.strip().lower()
if row_index in processed_indices:
continue
_close_task_panel(driver)
task_title = ""
try:
# Locate by row index first (handles duplicate task names correctly),
# fall back to title search if the row has no aria-rowindex.
row = _find_grid_row_by_index(driver, row_index) or _find_grid_row_by_title(driver, grid_title)
if row is None:
log_step("Grid row not found", title=grid_title[:60], facility=facility_name)
processed_indices.add(row_index)
continue
row_status = _grid_row_status(row)
if row_status in ("in progress", "completed"):
log_step(
"Skipping task — grid status is not 'Not started'",
title=grid_title[:60],
status=row_status or "",
facility=facility_name,
)
processed_indices.add(row_index)
continue
log_step(
"Open task from grid",
index=f"{task_num}/{task_count}",
title=grid_title[:60],
facility=facility_name,
)
# Re-find row right before click — virtualized grid can shift rows.
row = _find_grid_row_by_index(driver, row_index) or _find_grid_row_by_title(driver, grid_title)
if row is None or not _open_grid_row_details(driver, row):
log_step("Grid task open failed", title=grid_title[:60], facility=facility_name)
continue
# Grid Status column already filtered to Not started — go straight
# to Attachments tab (no panel status gate).
task_title = _clean_grid_task_title(grid_title)
processed_indices.add(row_index)
log_step("Task opened from grid", title=task_title[:60])
time.sleep(PANEL_TAB_LOAD_DELAY)
_open_attachments_view(driver)
time.sleep(PANEL_TAB_LOAD_DELAY)
if _is_attachments_empty(driver):
log_step(
"No attachments — skipping",
title=task_title[:60],
facility=facility_name,
)
tasks_skipped_no_attachment += 1
_close_task_panel(driver)
time.sleep(1)
continue
tasks_with_attachments += 1
att_count = _attachment_tab_count(driver) or len(_discover_listed_attachment_names(driver))
log_step(
"Attachments found",
title=task_title[:60],
count=att_count,
facility=facility_name,
)
if not download_dir_ready:
_ensure_download_folder(dest_folder)
set_chrome_download_directory(driver, dest_folder, create_dir=False)
download_dir_ready = True
files, expected_files = _download_attachments_new_tab(driver, dest_folder)
if files:
log_step(
"Downloaded attachments",
count=len(files),
expected=expected_files,
names="; ".join(p.name for p in files[:5]),
facility=facility_name,
)
all_downloaded.extend(files)
tasks_with_downloads += 1
else:
log_step(
"No new files downloaded for task",
index=str(task_num),
facility=facility_name,
)
if _should_mark_task_in_progress(files, expected_files):
log_step(
"Set task In progress (all attachments downloaded)",
index=str(task_num),
facility=facility_name,
files=len(files),
expected=expected_files,
title=(task_title or "")[:60],
)
changed, via_details = _mark_task_in_progress(
driver,
row,
task_title=task_title or "",
)
if changed:
status_changed += 1
if via_details:
status_via_task_details += 1
elif files:
log_step(
"Partial download — status not changed",
index=str(task_num),
files=len(files),
expected=expected_files,
facility=facility_name,
)
else:
log_step(
"No files downloaded — status not changed",
index=str(task_num),
facility=facility_name,
)
_close_task_panel(driver)
time.sleep(1.5)
except Exception as exc:
log_step(
"Task failed",
index=str(task_num),
error=str(exc)[:200],
facility=facility_name,
)
_log.warning("Task %d failed: %s", task_num, exc)
_close_task_panel(driver)
processed_indices.add(row_index)
_remove_empty_directory(dest_folder)
log_step(
"=== Facility workflow done ===",
facility=facility_name,
files=len(all_downloaded),
tasks_with_downloads=str(tasks_with_downloads),
status_changed=f"{status_changed}/{tasks_with_downloads}",
via_task_details=str(status_via_task_details),
)
return FacilityRunResult(
downloaded=all_downloaded,
tasks_total=task_count,
tasks_with_attachments=tasks_with_attachments,
tasks_skipped_no_attachment=tasks_skipped_no_attachment,
tasks_with_downloads=tasks_with_downloads,
status_changed=status_changed,
status_via_task_details=status_via_task_details,
)