- 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
6450 lines
230 KiB
Python
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,
|
|
)
|