commit 863c92cad9428ef15be81fd9998af2aac413eef7 Author: 王性驊 Date: Fri Jul 17 14:55:36 2026 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f665fa7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# --- secrets / env (never bake into image) --- +.env +.env.* +!.env.example +**/.secret_key +**/settings.json +**/state.json +**/teams_browser_state.json + +# --- local runtime data --- +backend/data/ +pts-data/ +*.log +*.pid +server.log +server.pid +dates.txt + +# --- python / venv --- +.venv/ +venv/ +__pycache__/ +**/__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# --- VCS / IDE / OS --- +.git/ +.gitignore +.gitattributes +.DS_Store +**/.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# --- docs / host-only tooling (not needed in image) --- +README.md +Makefile +run.sh +scripts/ +docker-compose.yml +docker-compose.*.yml +*.md + +# --- local notes / extras --- +.claude/ +.grok/ +*.session.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af67e05 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# GitLab +GITLAB_URL=https://gitlab.supermicro.com +GITLAB_TOKEN=your_gitlab_personal_access_token +GITLAB_PROJECT_PATH=super-cloud/scc-flex +GITLAB_BOARD_ID=1126 +# Comma-separated labels for board bug column +GITLAB_BOARD_LABELS=Category::BugFix,Type::Bug +# 留空則自動選目前衝刺(最新 closed 版之後最小的 active,例如 a11) +GITLAB_MILESTONE_TITLE= + +# PTS +PTS_URL=https://tw-timesheet.supermicro.com/PTS +PTS_PROJECT_NAME=SuperCloud Composer +PTS_DEFAULT_TASK_NAME=Implement + +# Scheduler (24h, server local time) +AUTO_FILL_ENABLED=true +AUTO_FILL_HOUR=18 +AUTO_FILL_MINUTE=0 + +# xAI Grok LLM +XAI_API_KEY=your_xai_api_key +XAI_BASE_URL=https://api.x.ai +GROK_MODEL=grok-3-mini +USE_LLM=true + +# Microsoft Teams / Outlook calendar via web scrape (optional) +TEAMS_CALENDAR_ENABLED=false +TEAMS_CALENDAR_TIMEZONE=Asia/Taipei +TEAMS_MEETING_TASK_NAME=Meeting +# Prefer logging in via Web UI (passwords are encrypted on disk) +# TEAMS_USERNAME= +# TEAMS_PASSWORD= +# TEAMS_HEADLESS=false + +# Backend +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8765 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..168814b --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# secrets / env +.env +.env.* +!.env.example +**/.secret_key + +# runtime state (local + docker host mounts) +backend/data/state.json +backend/data/settings.json +backend/data/server.pid +backend/data/server.log +backend/data/teams_browser_state.json +pts-data/ +!pts-data/.gitkeep +!pts-data/README.txt + +# python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# OS / IDE +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# logs +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42f7763 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Playwright/Chromium system deps + virtual display (Xvfb) + noVNC for interactive MFA +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + fonts-liberation ca-certificates \ + xvfb x11vnc novnc websockify fluxbox \ + && rm -rf /var/lib/apt/lists/* + +# Install deps first (better layer cache). Context is filtered by .dockerignore. +COPY backend/requirements.txt backend/requirements.txt +RUN pip install --no-cache-dir -r backend/requirements.txt \ + && playwright install chromium + +# App only — no .env, no backend/data, no pts-data, no host secrets (see .dockerignore) +COPY backend/ backend/ +COPY frontend/ frontend/ +COPY extension/ extension/ +COPY docker/entrypoint.sh /entrypoint.sh +COPY .env.example .env.example + +# Ensure no accidental local data was copied (belt-and-suspenders) +RUN rm -rf /app/backend/data \ + && mkdir -p /app/backend/data \ + && chmod +x /entrypoint.sh + +ENV PTS_DATA_DIR=/data +ENV PTS_RELOAD=false +ENV PTS_IN_DOCKER=true +# Headed Chromium on Xvfb for MFA; login API still requests headless=false +ENV TEAMS_HEADLESS=false +ENV DISPLAY=:99 +ENV TEAMS_VNC_PORT=6080 +ENV PYTHONUNBUFFERED=1 +# Playwright/Chromium in container +ENV PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright + +EXPOSE 8765 6080 + +WORKDIR /app/backend + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df08e14 --- /dev/null +++ b/Makefile @@ -0,0 +1,118 @@ +PYTHON := .venv/bin/python +PIP := .venv/bin/pip +TODAY := $(shell date +%Y-%m-%d) + +.PHONY: help setup init status preview preview-dates dry-run dry-run-dates fill fill-range fill-dates teams-test start stop restart server-status start-fg docker-build docker-up docker-down docker-logs docker-restart docker-start + +help: + @echo "吉八小 · 集滿八小時" + @echo "" + @echo " make setup 建立 venv + 安裝依賴 + 建立 .env" + @echo " make status 檢查設定 / Session / 任務數" + @echo " make preview 用 Grok 預覽今天的填寫計畫" + @echo " make dry-run 試跑今天(不送出 PTS)" + @echo " make fill 一鍵填寫今天" + @echo " make fill-range START=... END=... 補寫日期區間" + @echo " make fill-dates 依 dates.txt 補寫指定日期" + @echo " make preview-dates 預覽 dates.txt 的分配" + @echo " make teams-test [START=] [END=] 只測 Teams 抓會議(不寫 PTS)" + @echo " make start 背景啟動 Web UI + API" + @echo " make stop 關閉伺服器" + @echo " make restart 重啟伺服器" + @echo " make server-status 查看伺服器是否在跑" + @echo " make start-fg 前景啟動(開發用,Ctrl+C 結束)" + @echo " make docker-start 一鍵:關本地 + 建置 + 啟動 Docker" + @echo " make docker-build 建置 Docker 映像" + @echo " make docker-up 啟動 Docker(無主機掛載,/data 為 tmpfs,關容器即消失)" + @echo " make docker-down 停止 Docker" + @echo " make docker-logs 查看 Docker log" + @echo "" + @echo "第一次請先:" + @echo " 1) 編輯 .env 填入 GITLAB_TOKEN / XAI_API_KEY" + @echo " 2) make start → 網頁輸入 PTS(與選用 Teams)帳密登入" + @echo " 3) make fill" + +setup: init + python3 -m venv .venv + $(PIP) install -r backend/requirements.txt + $(PYTHON) -m playwright install chromium + @echo "Setup done. Edit .env then open http://localhost:8765 to login with PTS / Teams accounts." + +init: + @test -f .env || cp .env.example .env + +status: + cd backend && ../$(PYTHON) cli.py status + +preview: + cd backend && ../$(PYTHON) cli.py preview --start $(TODAY) --end $(TODAY) + +dry-run: + cd backend && ../$(PYTHON) cli.py fill-today --dry-run + +fill: + cd backend && ../$(PYTHON) cli.py fill-today + +fill-range: + @test -n "$(START)" && test -n "$(END)" || (echo "Usage: make fill-range START=YYYY-MM-DD END=YYYY-MM-DD" && exit 1) + cd backend && ../$(PYTHON) cli.py fill-range --start $(START) --end $(END) + +preview-dates: + cd backend && ../$(PYTHON) cli.py preview-dates --file ../dates.txt + +dry-run-dates: + cd backend && ../$(PYTHON) cli.py fill-dates --file ../dates.txt --dry-run + +fill-dates: + cd backend && ../$(PYTHON) cli.py fill-dates --file ../dates.txt + +teams-test: + cd backend && ../$(PYTHON) cli.py teams-test $(if $(START),--start $(START),) $(if $(END),--end $(END),) $(if $(INCLUDE_HOLIDAYS),--include-holidays,) + +start: + @chmod +x scripts/server.sh + @./scripts/server.sh start + +stop: + @chmod +x scripts/server.sh + @./scripts/server.sh stop + +restart: + @chmod +x scripts/server.sh + @./scripts/server.sh restart + +server-status: + @chmod +x scripts/server.sh + @./scripts/server.sh status + +start-fg: + cd backend && ../$(PYTHON) main.py + +docker-start: + @chmod +x scripts/docker-start.sh + @./scripts/docker-start.sh + +docker-build: + docker compose build --pull + +docker-up: + @echo "Ephemeral mode: no host data mount (tmpfs /data only)" + docker compose up -d --build --force-recreate --remove-orphans + @echo "Web UI: http://localhost:$${BACKEND_PORT:-8765}" + @echo "資料僅在容器內,docker compose down 後即消失" + +docker-down: + docker compose down --remove-orphans + +docker-logs: + docker compose logs -f + +docker-restart: + docker compose up -d --force-recreate --build --remove-orphans + +# Stop local Python server + Docker container +stop-all: + @chmod +x scripts/server.sh + @./scripts/server.sh stop 2>/dev/null || true + @docker compose down --remove-orphans 2>/dev/null || true + @echo "All local + Docker services stopped" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e71f8c --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# 吉八小 + +**集滿八小時** — 自動從 GitLab 任務看板抓取工作項目,用 **Grok LLM** 分析後填入 [PTS 工時系統](https://tw-timesheet.supermicro.com/PTS/)。可選併入 **Teams / Outlook 網頁行事曆**會議時數。 + +## 功能 + +- 網頁輸入 **PTS 帳密** → 直接打 PTS Login API(Windows/NTLM;可選 Forms) +- 可選:網頁輸入 **Microsoft 帳密** → 優先 Graph 帳密 API;MFA 時改瀏覽器登入,之後仍打行事曆 API +- 從 GitLab 抓取任務(看板 Bug + 目前衝刺 milestone) +- **Grok** 產生專業英文 Description 與時數分配 +- 每日最多 **8 小時**(0.5h 單位) +- Chrome Extension **備援**同步 PTS Token +- **CompBase 刷卡補填**(獨立分頁 `/compbase`):NTLM 登入出勤系統 → 掃「應刷未刷」→ 一鍵補刷退 + +## 架構 + +``` +Web UI 帳密 → PTS Windows/NTLM Login → Backend +Web UI 帳密 → Playwright / Outlook 爬蟲 → 會議時數 +GitLab API → 任務 ↗ +Grok API → 分析填寫計畫 ↗ +(備援)Chrome Extension → PTS Token +``` + +## 快速開始 + +### 1. 安裝 + +```bash +cd pts +make setup +``` + +會安裝 Python 依賴與 Playwright Chromium。 + +### 2. 編輯 `.env` + +```env +GITLAB_TOKEN=你的_gitlab_token +XAI_API_KEY=你的_grok_api_key +``` + +其餘設定(PTS / Teams 帳密、專案名等)建議在 Web UI 操作;密碼會以 Fernet 加密寫入本機 `settings.json`。 + +### 3. 啟動並登入 + +**本機 Python:** + +```bash +make start +``` + +**Docker(不存本機資料,`/data` 用 tmpfs,關容器即消失):** + +```bash +make docker-start +# 或: make docker-up +``` + +可選:在專案根目錄 `.env` 放 `GITLAB_TOKEN` / `XAI_API_KEY` 等,compose 會注入容器環境變數(仍不會掛載資料目錄)。 + +開啟 http://localhost:8765 + +停止 Docker:`make docker-down` + +1. **PTS**:輸入帳號(建議 `DOMAIN\user`)與密碼 → **登入 PTS** +2. **Teams**(選用):勾選啟用 → 輸入 Microsoft 帳密 → **登入並抓行事曆** + - 有 MFA 時會開瀏覽器視窗,請完成驗證 +3. **進階設定**:GitLab Token / Grok / 專案 / 排程 + +### 4. 一鍵填寫 + +```bash +make preview # 預覽 +make dry-run # 試跑 +make fill # 正式填今天 +``` + +## Make 指令 + +| 指令 | 說明 | +|------|------| +| `make setup` | venv、依賴、Playwright Chromium | +| `make status` | 檢查設定與連線 | +| `make preview` / `make fill` | 預覽 / 填寫今天 | +| `make fill-range START=... END=...` | 補寫區間 | +| `make start` | 啟動 Web UI(http://localhost:8765) | + +## 帳密與安全 + +- 密碼以 **Fernet** 加密存在本機資料目錄;金鑰為 `data/.secret_key`(或 Docker 的 `/data/.secret_key`) +- **僅供個人本機使用**,勿提交 settings / secret key 到 git +- PTS Token 過期時:先 Refresh,失敗則用儲存帳密重登 +- Teams session 存在 `teams_browser_state.json`;過期會嘗試重登(若觸發 MFA 需再互動一次) + +## 備援:Chrome Extension + +若公司網路僅允許瀏覽器 Windows SSO、帳密 API 不可用: + +1. 下載 UI 底部「備援」區塊的 Extension +2. 登入 PTS 網頁後同步 Token 到後端 + +## 常見問題 + +**PTS 登入失敗** +→ 確認在公司網路/VPN;帳號試 `DOMAIN\user`;或改用 Extension 備援。 + +**Teams / Docker 如何完成 MFA** +→ Docker 內建 **Xvfb + noVNC**:設定頁按「登入」會跳出 MFA 面板 + - **互動畫面**(http://主機:6080):可點擊、輸入,跟真的 Chrome 一樣 + - **即時截圖**:約每 1.5 秒更新,方便對 Authenticator 數字 +→ 登入流程中瀏覽器會**保持開啟最多約 10 分鐘**,完成 MFA 前不會關掉。 +→ 需映射埠 `6080`(`docker-compose` 已設定)。若 iframe 空白,用「新分頁開啟」。 + +**Missing PTS session** +→ 在 Web UI 重新登入 PTS,或用 Extension 同步。 + +**Project not found** +→ 進階設定中 `Project Name` 需與 PTS 下拉一致。 + +## 專案結構 + +``` +pts/ +├── backend/ +│ ├── main.py +│ └── services/ +│ ├── pts_client.py # PTS API + 帳密登入 +│ ├── teams_calendar_scraper.py # Outlook 網頁爬蟲 +│ ├── secret_box.py # 密碼加密 +│ ├── gitlab_client.py +│ ├── grok_client.py +│ └── planner.py +├── extension/ # 備援憑證同步 +├── frontend/ # Web UI +└── Makefile +``` diff --git a/backend/cli.py b/backend/cli.py new file mode 100644 index 0000000..ab91f3d --- /dev/null +++ b/backend/cli.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import date +from pathlib import Path + +from services.date_parser import parse_dates_file, parse_dates_text +from services.fill_service import FillService + + +def _print_json(data: object) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def _resolve_dates(args: argparse.Namespace) -> list[date]: + if args.file: + return parse_dates_file(args.file) + if args.dates: + return parse_dates_text(args.dates) + raise ValueError("Provide --file or --dates") + + +async def cmd_status(_: argparse.Namespace) -> int: + service = FillService() + _print_json(await service.get_status()) + return 0 + + +async def cmd_teams_test(args: argparse.Namespace) -> int: + """Standalone Teams calendar fetch test (no PTS / GitLab write).""" + from services.teams_calendar_scraper import TeamsCalendarScraper + + start = date.fromisoformat(args.start) if args.start else date.today() + end = date.fromisoformat(args.end) if args.end else start + scraper = TeamsCalendarScraper() + result = await scraper.list_meetings( + start, + end, + skip_holidays=not args.include_holidays, + debug=True, + ) + _print_json(result) + if result.get("error"): + return 1 + if (result.get("total_count") or 0) == 0: + print( + "\nHINT: 0 meetings. Login Teams via Web UI Settings (MFA), then retry.", + file=sys.stderr, + ) + return 2 + return 0 + + +async def cmd_preview(args: argparse.Namespace) -> int: + service = FillService() + start = date.fromisoformat(args.start) + end = date.fromisoformat(args.end) + _print_json(await service.preview_plan(start, end, use_llm=not args.no_llm)) + return 0 + + +async def cmd_preview_dates(args: argparse.Namespace) -> int: + service = FillService() + days = _resolve_dates(args) + _print_json(await service.preview_plan_for_days(days, use_llm=not args.no_llm)) + return 0 + + +async def cmd_fill_today(args: argparse.Namespace) -> int: + service = FillService() + try: + service.ensure_ready() + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + result = await service.fill_today(dry_run=args.dry_run, use_llm=not args.no_llm) + _print_json(result) + return 0 if result.get("ok") else 1 + + +async def cmd_fill_range(args: argparse.Namespace) -> int: + service = FillService() + try: + service.ensure_ready() + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + start = date.fromisoformat(args.start) + end = date.fromisoformat(args.end) + result = await service.fill_dates( + start, + end, + dry_run=args.dry_run, + skip_existing=not args.force, + use_llm=not args.no_llm, + ) + _print_json(result) + return 0 if result.get("ok") else 1 + + +async def cmd_fill_dates(args: argparse.Namespace) -> int: + service = FillService() + try: + service.ensure_ready() + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + days = _resolve_dates(args) + result = await service.fill_date_list( + days, + dry_run=args.dry_run, + skip_existing=not args.force, + use_llm=not args.no_llm, + ) + _print_json(result) + return 0 if result.get("ok") else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="PTS auto-fill CLI") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("status", help="Show system status") + + preview = sub.add_parser("preview", help="Preview fill plan for date range") + preview.add_argument("--start", required=True, help="YYYY-MM-DD") + preview.add_argument("--end", required=True, help="YYYY-MM-DD") + preview.add_argument("--no-llm", action="store_true") + + preview_dates = sub.add_parser("preview-dates", help="Preview fill plan for date list") + preview_dates.add_argument("--file", help="Path to dates.txt") + preview_dates.add_argument("--dates", help="Comma/newline separated dates") + preview_dates.add_argument("--no-llm", action="store_true") + + fill_today = sub.add_parser("fill-today", help="Fill today's PTS entries") + fill_today.add_argument("--dry-run", action="store_true") + fill_today.add_argument("--no-llm", action="store_true") + + fill_range = sub.add_parser("fill-range", help="Fill PTS entries for date range") + fill_range.add_argument("--start", required=True, help="YYYY-MM-DD") + fill_range.add_argument("--end", required=True, help="YYYY-MM-DD") + fill_range.add_argument("--dry-run", action="store_true") + fill_range.add_argument("--force", action="store_true") + fill_range.add_argument("--no-llm", action="store_true") + + fill_dates = sub.add_parser("fill-dates", help="Fill PTS entries for explicit date list") + fill_dates.add_argument("--file", help="Path to dates.txt") + fill_dates.add_argument("--dates", help="Comma/newline separated dates") + fill_dates.add_argument("--dry-run", action="store_true") + fill_dates.add_argument("--force", action="store_true") + fill_dates.add_argument("--no-llm", action="store_true") + + teams_test = sub.add_parser( + "teams-test", + help="Standalone test: fetch Teams/Outlook meetings only (no PTS write)", + ) + teams_test.add_argument("--start", help="YYYY-MM-DD (default: today)") + teams_test.add_argument("--end", help="YYYY-MM-DD (default: start)") + teams_test.add_argument( + "--include-holidays", + action="store_true", + help="Do not skip weekends / TW holidays", + ) + + return parser + + +async def main() -> int: + parser = build_parser() + args = parser.parse_args() + + handlers = { + "status": cmd_status, + "preview": cmd_preview, + "preview-dates": cmd_preview_dates, + "fill-today": cmd_fill_today, + "fill-range": cmd_fill_range, + "fill-dates": cmd_fill_dates, + "teams-test": cmd_teams_test, + } + + try: + return await handlers[args.command](args) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..6a208aa --- /dev/null +++ b/backend/config.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from paths import DATA_DIR, EXTENSION_DIR, ROOT_DIR, get_dates_file_path, get_env_files +from services.settings_store import load_overrides, save_overrides + +CONFIGURABLE_FIELDS = [ + "gitlab_url", + "gitlab_token", + "gitlab_project_path", + "gitlab_board_id", + "gitlab_board_labels", + "gitlab_milestone_title", + "pts_url", + "pts_project_name", + "pts_default_task_name", + "auto_fill_enabled", + "auto_fill_hour", + "auto_fill_minute", + "max_hours_per_day", + "hour_step", + "xai_api_key", + "xai_base_url", + "grok_model", + "use_llm", + "use_description_variation", + "pts_username", + "pts_password", + "teams_username", + "teams_password", + "teams_calendar_enabled", + "teams_calendar_timezone", + "teams_meeting_task_name", + "teams_headless", + "teams_meeting_exclude_keywords", + # CompBase attendance punch (independent of PTS) + "compbase_list_url", + "compbase_fill_url", + "compbase_username", + "compbase_password", + "compbase_default_days", + "compbase_out_time_mode", + # legacy Graph fields kept for backward-compatible settings files + "azure_client_id", + "azure_tenant_id", +] + + +class EnvSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=get_env_files() or None, + env_file_encoding="utf-8", + extra="ignore", + ) + + gitlab_url: str = "https://gitlab.supermicro.com" + gitlab_token: str = "" + gitlab_project_path: str = "super-cloud/scc-flex" + gitlab_board_id: int = 1126 + gitlab_board_labels: str = "Category::BugFix,Type::Bug" + gitlab_milestone_title: str = "" + + pts_url: str = "https://tw-timesheet.supermicro.com/PTS" + pts_project_name: str = "SuperCloud Composer" + pts_default_task_name: str = "Implement" + pts_username: str = "" + pts_password: str = "" + + auto_fill_enabled: bool = True + auto_fill_hour: int = 18 + auto_fill_minute: int = 0 + + backend_host: str = "0.0.0.0" + backend_port: int = 8765 + + max_hours_per_day: float = 8.0 + hour_step: float = 0.5 + + xai_api_key: str = "" + xai_base_url: str = "https://api.x.ai" + grok_model: str = "grok-3-mini" + use_llm: bool = True + use_description_variation: bool = True + + teams_calendar_enabled: bool = False + teams_username: str = "" + teams_password: str = "" + teams_calendar_timezone: str = "Asia/Taipei" + teams_meeting_task_name: str = "Meeting" + teams_headless: bool = False + teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退" + azure_client_id: str = "" + azure_tenant_id: str = "organizations" + + compbase_list_url: str = "http://tw-compbase.supermicro.com:6699" + compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671" + compbase_username: str = "" + compbase_password: str = "" + compbase_default_days: int = 14 + compbase_out_time_mode: str = "expected" + + +class AppSettings(BaseModel): + gitlab_url: str = "https://gitlab.supermicro.com" + gitlab_token: str = "" + gitlab_project_path: str = "super-cloud/scc-flex" + gitlab_board_id: int = 1126 + gitlab_board_labels: str = "Category::BugFix,Type::Bug" + gitlab_milestone_title: str = "" + + pts_url: str = "https://tw-timesheet.supermicro.com/PTS" + pts_project_name: str = "SuperCloud Composer" + pts_default_task_name: str = "Implement" + pts_username: str = "" + pts_password: str = "" + + auto_fill_enabled: bool = True + auto_fill_hour: int = 18 + auto_fill_minute: int = 0 + + backend_host: str = "0.0.0.0" + backend_port: int = 8765 + + max_hours_per_day: float = 8.0 + hour_step: float = 0.5 + + xai_api_key: str = "" + xai_base_url: str = "https://api.x.ai" + grok_model: str = "grok-3-mini" + use_llm: bool = True + use_description_variation: bool = True + + teams_calendar_enabled: bool = False + teams_username: str = "" + teams_password: str = "" + teams_calendar_timezone: str = "Asia/Taipei" + teams_meeting_task_name: str = "Meeting" + teams_headless: bool = False + teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退" + azure_client_id: str = "" + azure_tenant_id: str = "organizations" + + compbase_list_url: str = "http://tw-compbase.supermicro.com:6699" + compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671" + compbase_username: str = "" + compbase_password: str = "" + compbase_default_days: int = 14 + compbase_out_time_mode: str = "expected" + + @property + def teams_meeting_exclude_keyword_list(self) -> list[str]: + return [part.strip() for part in self.teams_meeting_exclude_keywords.split(",") if part.strip()] + + @property + def gitlab_board_label_list(self) -> list[str]: + return [part.strip() for part in self.gitlab_board_labels.split(",") if part.strip()] + + +_env = EnvSettings() + + +def build_settings() -> AppSettings: + data = _env.model_dump() + overrides = load_overrides() + for key in CONFIGURABLE_FIELDS: + if key in overrides and overrides[key] is not None: + data[key] = overrides[key] + return AppSettings(**data) + + +def get_settings_dict() -> dict[str, Any]: + return build_settings().model_dump() + + +def update_settings(updates: dict[str, Any]) -> AppSettings: + current = load_overrides() + merged = {**current} + for key in CONFIGURABLE_FIELDS: + if key not in updates: + continue + value = updates[key] + if value is not None: + merged[key] = value + save_overrides(merged) + return reload_settings() + + +def reload_settings() -> AppSettings: + global _runtime + _runtime = build_settings() + return _runtime + + +_runtime = build_settings() + + +class SettingsProxy: + def __getattr__(self, name: str) -> Any: + return getattr(_runtime, name) + + def __repr__(self) -> str: + return f"SettingsProxy({_runtime!r})" + + +settings = SettingsProxy() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..03473e3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from datetime import date +from pathlib import Path +from typing import Any, Optional + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from config import reload_settings, settings +from services.compbase_client import CompBaseClient +from services.date_parser import parse_dates_text +from services.extension_packager import build_extension_zip +from services.fill_service import FillService +from services.pts_client import PTSClient +from services.scheduler import start_scheduler, stop_scheduler +from services.secret_box import encrypt_secret +from services.settings_api import DatesFilePayload, SettingsUpdatePayload +from services.settings_service import SettingsService +from services.settings_store import load_overrides, save_overrides +from services.state_store import ( + clear_pts_session, + get_pts_session, + get_teams_browser_meta, + set_pts_session, +) +from services.teams_calendar_scraper import ( + MFA_PREVIEW_FILE, + TeamsCalendarScraper, + browser_view_info, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + from services.pts_client import normalize_pts_base_url + + logger.info("PTS API base: %s", normalize_pts_base_url(settings.pts_url)) + start_scheduler() + yield + stop_scheduler() + + +app = FastAPI(title="吉八小", version="1.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +if FRONTEND_DIR.exists(): + app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") + + +class SessionPayload(BaseModel): + accessToken: str + refreshToken: Optional[str] = None + groupCode: Optional[str] = None + raw: Optional[dict[str, Any]] = None + + +class PtsLoginPayload(BaseModel): + username: str + password: str + remember: bool = True + group_code: Optional[str] = None + + +class TeamsLoginPayload(BaseModel): + username: str + password: str + remember: bool = True + headless: Optional[bool] = None + + +class FillRangePayload(BaseModel): + start_date: date + end_date: date + dry_run: bool = False + skip_existing: bool = True + + +class FillTodayPayload(BaseModel): + dry_run: bool = False + + +class DatesTextPayload(BaseModel): + dates_text: str + dry_run: bool = False + skip_existing: bool = True + + +class CompbaseScanPayload(BaseModel): + days: int = Field(default=14, ge=1, le=62) + + +class CompbaseFillPayload(BaseModel): + days: int = Field(default=14, ge=1, le=62) + dry_run: bool = False + out_time_mode: str = "expected" + + +@app.get("/api/extension/download") +async def download_extension(): + try: + payload = build_extension_zip() + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return Response( + content=payload, + media_type="application/zip", + headers={ + "Content-Disposition": 'attachment; filename="pts-session-sync.zip"', + }, + ) + + +@app.get("/") +async def index(): + index_file = FRONTEND_DIR / "index.html" + if index_file.exists(): + return FileResponse(index_file) + return {"message": "PTS backend running. Frontend not found."} + + +@app.get("/settings") +async def settings_page(): + settings_file = FRONTEND_DIR / "settings.html" + if settings_file.exists(): + return FileResponse(settings_file) + raise HTTPException(status_code=404, detail="Settings page not found") + + +@app.get("/compbase") +async def compbase_page(): + page = FRONTEND_DIR / "compbase.html" + if page.exists(): + return FileResponse(page) + raise HTTPException(status_code=404, detail="CompBase page not found") + + +@app.post("/api/session") +async def save_session(payload: SessionPayload): + session_data = payload.raw or { + "accessToken": payload.accessToken, + "refreshToken": payload.refreshToken, + "groupCode": payload.groupCode, + } + set_pts_session(session_data) + return {"ok": True, "message": "PTS session saved"} + + +@app.get("/api/session") +async def get_session_status(): + session = get_pts_session() + return { + "has_session": bool(session and session.get("accessToken")), + "group_code": (session or {}).get("groupCode"), + } + + +@app.get("/api/gitlab/tasks") +async def list_gitlab_tasks(): + service = FillService() + try: + return {"tasks": await service.fetch_gitlab_tasks()} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/gitlab/sources") +async def list_gitlab_sources(): + from services.gitlab_client import GitLabClient + + try: + return await GitLabClient().get_task_sources() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/preview") +async def preview_plan(start_date: date, end_date: date): + service = FillService() + try: + return await service.preview_plan(start_date, end_date) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/fill/range") +async def fill_range(payload: FillRangePayload): + service = FillService() + try: + return await service.fill_dates( + payload.start_date, + payload.end_date, + skip_existing=payload.skip_existing, + dry_run=payload.dry_run, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/settings") +async def get_settings(): + service = SettingsService() + return service.get_public_settings() + + +@app.put("/api/settings") +async def update_settings(payload: SettingsUpdatePayload): + service = SettingsService() + try: + return service.save_settings(payload.to_update_dict()) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/dates-file") +async def get_dates_file(): + service = SettingsService() + return {"content": service.get_dates_file()} + + +@app.put("/api/dates-file") +async def save_dates_file(payload: DatesFilePayload): + service = SettingsService() + service.save_dates_file(payload.content) + return {"ok": True, "message": "dates.txt saved"} + + +@app.post("/api/preview/dates") +async def preview_dates(payload: DatesTextPayload): + service = FillService() + days = parse_dates_text(payload.dates_text) + try: + return await service.preview_plan_for_days(days) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/fill/dates") +async def fill_dates(payload: DatesTextPayload): + service = FillService() + days = parse_dates_text(payload.dates_text) + try: + return await service.fill_date_list( + days, + dry_run=payload.dry_run, + skip_existing=payload.skip_existing, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/fill/today") +async def fill_today(payload: FillTodayPayload = FillTodayPayload()): + service = FillService() + try: + return await service.fill_today(dry_run=payload.dry_run) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/pts/login") +async def pts_login(payload: PtsLoginPayload): + try: + if payload.remember: + overrides = load_overrides() + overrides["pts_username"] = payload.username.strip() + overrides["pts_password"] = encrypt_secret(payload.password) + save_overrides(overrides) + reload_settings() + result = await PTSClient().login( + payload.username, + payload.password, + group_code=payload.group_code, + ) + return result + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/pts/logout") +async def pts_logout(): + clear_pts_session() + return {"ok": True, "message": "PTS session cleared"} + + +@app.get("/api/teams/status") +async def teams_status(): + status = TeamsCalendarScraper().status() + status["browser_view"] = browser_view_info() + return status + + +@app.get("/api/teams/browser-view") +async def teams_browser_view(): + """Docker noVNC / local window info for interactive MFA.""" + info = browser_view_info() + meta = get_teams_browser_meta() or {} + info["login_message"] = meta.get("message") + info["mfa_page_url"] = meta.get("mfa_page_url") + info["mfa_preview_label"] = meta.get("mfa_preview_label") + return info + + +@app.get("/api/teams/mfa-preview") +async def teams_mfa_preview(): + """Latest screenshot of the login/MFA Chromium page (refreshed while waiting).""" + if not MFA_PREVIEW_FILE.exists() or MFA_PREVIEW_FILE.stat().st_size < 50: + raise HTTPException(status_code=404, detail="尚無 MFA 預覽截圖(請先按登入)") + return FileResponse( + path=str(MFA_PREVIEW_FILE), + media_type="image/png", + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", + "Pragma": "no-cache", + }, + ) + + +@app.post("/api/teams/login") +async def teams_login(payload: TeamsLoginPayload): + try: + if payload.remember: + overrides = load_overrides() + overrides["teams_username"] = payload.username.strip() + overrides["teams_password"] = encrypt_secret(payload.password) + overrides["teams_calendar_enabled"] = True + save_overrides(overrides) + reload_settings() + # Default: show browser for MFA (Docker Xvfb + noVNC, or local window) + headless = payload.headless if payload.headless is not None else False + result = await TeamsCalendarScraper().login( + payload.username, + payload.password, + headless=headless, + ) + result["browser_view"] = browser_view_info() + return result + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/teams/logout") +async def teams_logout(): + await TeamsCalendarScraper().logout() + return {"ok": True, "message": "Teams calendar session cleared"} + + +@app.get("/api/teams/meetings") +async def teams_meetings( + start_date: Optional[date] = None, + end_date: Optional[date] = None, + skip_holidays: bool = True, + debug: bool = False, +): + """List Teams/Outlook meetings for the same date range as PTS fill. + + By default skips weekends and Taiwan public holidays (same as fill). + Pass debug=1 for connection diagnostics (standalone Teams test UI/CLI). + """ + today = date.today() + start = start_date or today + end = end_date or start + try: + return await TeamsCalendarScraper().list_meetings( + start, + end, + skip_holidays=skip_holidays, + debug=debug, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/status") +async def status(): + service = FillService() + return await service.get_status() + + +@app.get("/api/compbase/status") +async def compbase_status(): + try: + return await CompBaseClient().status() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/compbase/scan") +async def compbase_scan(payload: CompbaseScanPayload): + try: + return await CompBaseClient().list_missing(payload.days) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/compbase/fill") +async def compbase_fill(payload: CompbaseFillPayload): + mode = (payload.out_time_mode or "expected").strip().lower() + if mode not in {"expected", "latest_option"}: + raise HTTPException( + status_code=400, + detail="out_time_mode must be 'expected' or 'latest_option'", + ) + try: + return await CompBaseClient().fill_recent( + payload.days, + dry_run=payload.dry_run, + out_time_mode=mode, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.get("/api/config") +async def get_config(): + return SettingsService().get_public_settings() + + +if __name__ == "__main__": + import os + + import uvicorn + + reload = os.environ.get("PTS_RELOAD", "true").lower() in ("1", "true", "yes") + uvicorn.run( + "main:app", + host=settings.backend_host, + port=settings.backend_port, + reload=reload, + ) \ No newline at end of file diff --git a/backend/paths.py b/backend/paths.py new file mode 100644 index 0000000..87313bc --- /dev/null +++ b/backend/paths.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +_PTS_DATA_DIR = os.environ.get("PTS_DATA_DIR", "").strip() +DATA_DIR = Path(_PTS_DATA_DIR) if _PTS_DATA_DIR else Path(__file__).resolve().parent / "data" +DATA_DIR.mkdir(parents=True, exist_ok=True) +EXTENSION_DIR = ROOT_DIR / "extension" + + +def get_env_files() -> tuple[str, ...]: + candidates = [DATA_DIR / ".env", ROOT_DIR / ".env"] + return tuple(str(path) for path in candidates if path.exists()) + + +def get_dates_file_path() -> Path: + if _PTS_DATA_DIR: + return DATA_DIR / "dates.txt" + return ROOT_DIR / "dates.txt" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..adfaaf1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +httpx>=0.27.0 +httpx-ntlm>=1.4.0 +apscheduler>=3.10.4 +holidays>=0.57 +python-dotenv>=1.0.1 +pydantic>=2.9.0 +pydantic-settings>=2.6.0 +cryptography>=43.0.0 +playwright>=1.49.0 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/calendar_models.py b/backend/services/calendar_models.py new file mode 100644 index 0000000..3f6c291 --- /dev/null +++ b/backend/services/calendar_models.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class CalendarMeeting: + subject: str + start: datetime + end: datetime + hours: float + is_online: bool + + +def format_meeting_description(meeting: CalendarMeeting) -> str: + label = "Teams meeting" if meeting.is_online else "Meeting" + return f"Attended {label}: {meeting.subject}." diff --git a/backend/services/compbase_client.py b/backend/services/compbase_client.py new file mode 100644 index 0000000..3d55457 --- /dev/null +++ b/backend/services/compbase_client.py @@ -0,0 +1,550 @@ +from __future__ import annotations + +import logging +import re +from dataclasses import asdict, dataclass +from datetime import date, timedelta +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx + +from config import settings +from services.secret_box import decrypt_secret + +logger = logging.getLogger(__name__) + +DEFAULT_LIST_URL = "http://tw-compbase.supermicro.com:6699" +DEFAULT_FILL_URL = "http://tw-compbase.supermicro.com:6671" + +_HIDDEN_FIELDS = ( + "__VIEWSTATE", + "__VIEWSTATEGENERATOR", + "__EVENTVALIDATION", + "__VIEWSTATEENCRYPTED", + "__EVENTTARGET", + "__EVENTARGUMENT", + "__LASTFOCUS", +) + + +@dataclass +class AbnormalRow: + date: date + rid: str | None + exp_in: str + exp_out: str + actual_in: str + actual_out: str + status: str # missing_out | missing_in | other + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["date"] = self.date.isoformat() + return data + + +class CompBaseClient: + def __init__( + self, + *, + list_url: str | None = None, + fill_url: str | None = None, + ) -> None: + self.list_url = (list_url or getattr(settings, "compbase_list_url", "") or DEFAULT_LIST_URL).rstrip("/") + self.fill_url = (fill_url or getattr(settings, "compbase_fill_url", "") or DEFAULT_FILL_URL).rstrip("/") + + def _credentials(self) -> tuple[str, str]: + username = (getattr(settings, "compbase_username", "") or "").strip() + encrypted = getattr(settings, "compbase_password", "") or "" + if not username or not encrypted: + username = (getattr(settings, "pts_username", "") or "").strip() + encrypted = getattr(settings, "pts_password", "") or "" + if not username or not encrypted: + raise ValueError( + "Missing CompBase credentials. Set CompBase or PTS username/password in Settings." + ) + try: + password = decrypt_secret(encrypted) + except ValueError as exc: + raise ValueError("Stored password could not be decrypted. Re-enter password in Settings.") from exc + if not password: + raise ValueError("CompBase password is empty. Re-enter password in Settings.") + return username, password + + def _auth(self): + try: + from httpx_ntlm import HttpNtlmAuth + except ImportError as exc: + raise ValueError("httpx-ntlm is not installed. Run: pip install httpx-ntlm") from exc + username, password = self._credentials() + return HttpNtlmAuth(username, password) + + def _make_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + auth=self._auth(), + timeout=60.0, + follow_redirects=True, + ) + + @staticmethod + def _extract_hidden(html: str) -> dict[str, str]: + out: dict[str, str] = {} + for name in _HIDDEN_FIELDS: + m = re.search( + rf']+name=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']', + html, + re.I, + ) + if not m: + m = re.search( + rf']+id=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']', + html, + re.I, + ) + if m: + out[name] = m.group(1) + return out + + @staticmethod + def _extract_selected_selects(html: str) -> dict[str, str]: + out: dict[str, str] = {} + for m in re.finditer( + r']*name=["\']([^"\']+)["\'][^>]*>([\s\S]*?)', + html, + re.I, + ): + name, body = m.group(1), m.group(2) + selected = re.search( + r']*selected[^>]*(?:value=["\']([^"\']*)["\'])?[^>]*>([^<]*)', + body, + re.I, + ) + if selected: + val = selected.group(1) + out[name] = val if val is not None else selected.group(2).strip() + continue + first = re.search(r']*value=["\']([^"\']*)["\']', body, re.I) + if first: + out[name] = first.group(1) + return out + + @staticmethod + def _extract_checked_radios(html: str) -> dict[str, str]: + out: dict[str, str] = {} + for m in re.finditer(r"]+)/?>", html, re.I): + attrs = m.group(1) + typ = re.search(r'type=["\']([^"\']+)["\']', attrs, re.I) + if not typ or typ.group(1).lower() != "radio": + continue + if not re.search(r"\bchecked\b", attrs, re.I): + continue + nm = re.search(r'name=["\']([^"\']+)["\']', attrs, re.I) + val = re.search(r'value=["\']([^"\']*)["\']', attrs, re.I) + if nm: + out[nm.group(1)] = val.group(1) if val else "on" + return out + + @staticmethod + def _parse_us_date(text: str) -> date | None: + text = text.strip() + m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", text) + if not m: + return None + month, day, year = int(m.group(1)), int(m.group(2)), int(m.group(3)) + try: + return date(year, month, day) + except ValueError: + return None + + @staticmethod + def _rid_from_href(href: str) -> str | None: + if not href: + return None + qs = parse_qs(urlparse(href).query) + rid_list = qs.get("RID") or qs.get("rid") + if rid_list and rid_list[0]: + return str(rid_list[0]).strip() + m = re.search(r"RID=(\d+)", href, re.I) + return m.group(1) if m else None + + @staticmethod + def _cell_text(cell_html: str) -> str: + text = re.sub(r"<[^>]+>", " ", cell_html) + text = re.sub(r" ", " ", text, flags=re.I) + return re.sub(r"\s+", " ", text).strip() + + def _parse_table2(self, html: str) -> list[AbnormalRow]: + m = re.search(r']*id=["\']Table2["\'][^>]*>([\s\S]*?)', html, re.I) + if not m: + return [] + body = m.group(1) + rows: list[AbnormalRow] = [] + for tr in re.finditer(r"]*>([\s\S]*?)", body, re.I): + cells = re.findall(r"]*>([\s\S]*?)", tr.group(1), re.I) + if len(cells) < 5: + continue + day = self._parse_us_date(self._cell_text(cells[0])) + if day is None: + continue + # Layout: 日期 | 應上班 | 應下班 | 實上班 | 實下班 | [異常上班] | [異常下班] + exp_in = self._cell_text(cells[1]) if len(cells) > 1 else "" + exp_out = self._cell_text(cells[2]) if len(cells) > 2 else "" + actual_in = self._cell_text(cells[3]) if len(cells) > 3 else "" + actual_out = self._cell_text(cells[4]) if len(cells) > 4 else "" + abn_in_html = cells[5] if len(cells) > 5 else "" + abn_out_html = cells[6] if len(cells) > 6 else "" + + rid: str | None = None + status = "ok" + href_m = re.search(r'href=["\']([^"\']+)["\']', abn_out_html, re.I) + if href_m: + rid = self._rid_from_href(href_m.group(1)) + if "應刷未刷" in abn_out_html or rid: + status = "missing_out" + elif "應刷未刷" in abn_in_html: + status = "missing_in" + elif not actual_in and not actual_out: + status = "other" + + if status == "ok": + continue + rows.append( + AbnormalRow( + date=day, + rid=rid, + exp_in=exp_in, + exp_out=exp_out, + actual_in=actual_in, + actual_out=actual_out, + status=status, + ) + ) + return rows + + async def fetch_abnormal_rows(self, year: int, month: int) -> list[AbnormalRow]: + async with self._make_client() as client: + home = await client.get(f"{self.list_url}/") + if home.status_code in {401, 403}: + raise ValueError("CompBase NTLM login failed (401/403). Check username/password.") + home.raise_for_status() + html = home.text + + data: dict[str, str] = {} + data.update(self._extract_hidden(html)) + data.update(self._extract_selected_selects(html)) + data.update(self._extract_checked_radios(html)) + data["ddlYear"] = str(year) + data["ddlMonth"] = str(month) + if "subYear" in data: + data["subYear"] = str(year) + if "subMonth" in data: + data["subMonth"] = str(month) + # Image button click — do not send btnSet + data.pop("btnSet", None) + data.pop("Button1", None) + data["ImageCalendar.x"] = "10" + data["ImageCalendar.y"] = "10" + + resp = await client.post(f"{self.list_url}/", data=data) + if resp.status_code in {401, 403}: + raise ValueError("CompBase NTLM login failed on calendar post.") + resp.raise_for_status() + rows = self._parse_table2(resp.text) + logger.info( + "CompBase calendar %04d-%02d: %d abnormal row(s)", + year, + month, + len(rows), + ) + return rows + + async def list_missing(self, days: int = 14) -> dict[str, Any]: + if days < 1: + raise ValueError("days must be >= 1") + today = date.today() + start = today - timedelta(days=days - 1) + months: list[tuple[int, int]] = [] + cursor = date(start.year, start.month, 1) + end_month = date(today.year, today.month, 1) + while cursor <= end_month: + months.append((cursor.year, cursor.month)) + if cursor.month == 12: + cursor = date(cursor.year + 1, 1, 1) + else: + cursor = date(cursor.year, cursor.month + 1, 1) + + all_rows: list[AbnormalRow] = [] + seen: set[tuple[date, str | None]] = set() + for year, month in months: + for row in await self.fetch_abnormal_rows(year, month): + key = (row.date, row.rid) + if key in seen: + continue + seen.add(key) + all_rows.append(row) + + in_range = [r for r in all_rows if start <= r.date <= today] + fillable = [r for r in in_range if r.rid and r.status == "missing_out"] + skipped = [r for r in in_range if not (r.rid and r.status == "missing_out")] + + return { + "ok": True, + "start": start.isoformat(), + "end": today.isoformat(), + "days": days, + "fillable": [r.to_dict() for r in sorted(fillable, key=lambda x: x.date)], + "skipped": [r.to_dict() for r in sorted(skipped, key=lambda x: x.date)], + "fillable_count": len(fillable), + "skipped_count": len(skipped), + } + + @staticmethod + def _span_text(html: str, element_id: str) -> str: + m = re.search( + rf']+id=["\']{re.escape(element_id)}["\'][^>]*>([\s\S]*?)', + html, + re.I, + ) + if not m: + return "" + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip() + + @staticmethod + def _select_options(html: str, select_name: str) -> list[str]: + m = re.search( + rf']*name=["\']{re.escape(select_name)}["\'][^>]*>([\s\S]*?)', + html, + re.I, + ) + if not m: + return [] + opts: list[str] = [] + for om in re.finditer(r"]*>([\s\S]*?)", m.group(1), re.I): + val_m = re.search(r'value=["\']([^"\']*)["\']', om.group(0), re.I) + text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", om.group(1))).strip() + opts.append(val_m.group(1) if val_m else text) + return opts + + @staticmethod + def _input_value(html: str, name: str) -> str: + m = re.search( + rf']+name=["\']{re.escape(name)}["\'][^>]*>', + html, + re.I, + ) + if not m: + return "" + val = re.search(r'value=["\']([^"\']*)["\']', m.group(0), re.I) + return val.group(1) if val else "" + + @staticmethod + def _normalize_clock(value: str) -> str: + """Normalize '5:53:00 PM' / '5:53 PM' → '5:53PM' for comparison.""" + text = re.sub(r"\s+", " ", (value or "").strip()).upper() + # drop seconds if present: H:MM:SS AM/PM → H:MM AM/PM + text = re.sub( + r"\b(\d{1,2}:\d{2}):\d{2}\s*(AM|PM)\b", + r"\1 \2", + text, + ) + return re.sub(r"[^0-9APM:]", "", text) + + def _choose_out_time( + self, + options: list[str], + exp_out: str, + *, + out_time_mode: str, + ) -> str: + usable = [ + o + for o in options + if o and o not in {"------", "自行輸入"} and "自行" not in o + ] + if not usable: + raise ValueError(f"No usable out-time options: {options}") + + if out_time_mode == "latest_option": + return usable[-1] + + exp_key = self._normalize_clock(exp_out) + for opt in usable: + if self._normalize_clock(opt) == exp_key and exp_key: + return opt + return usable[0] + + async def fill_rid( + self, + rid: str, + *, + out_time_mode: str = "expected", + dry_run: bool = False, + row_date: date | None = None, + ) -> dict[str, Any]: + rid = str(rid).strip() + if not rid: + raise ValueError("RID is required") + + async with self._make_client() as client: + url = f"{self.fill_url}/?RID={rid}" + page = await client.get(url) + if page.status_code in {401, 403}: + raise ValueError("CompBase fill page NTLM login failed.") + page.raise_for_status() + html = page.text + + exp_out = self._span_text(html, "ExpOut") + in_time = self._input_value(html, "InTime") or self._span_text(html, "ExpIn") + options = self._select_options(html, "preSetOutTimeList") + chosen = self._choose_out_time(options, exp_out, out_time_mode=out_time_mode) + label_date = self._span_text(html, "Label3") or (row_date.isoformat() if row_date else "") + + result: dict[str, Any] = { + "rid": rid, + "date": label_date, + "exp_out": exp_out, + "in_time": in_time, + "chosen_out_time": chosen, + "options": options, + "dry_run": dry_run, + } + + if dry_run: + result["ok"] = True + result["message"] = "dry_run" + return result + + data: dict[str, str] = {} + data.update(self._extract_hidden(html)) + data["RID"] = self._input_value(html, "RID") or rid + data["InTime"] = in_time + data["preSetOutTimeList"] = chosen + data["Button1"] = "送出" + + resp = await client.post(url, data=data) + resp.raise_for_status() + msg = self._span_text(resp.text, "Msg") + # re-parse msg with colors if empty + if not msg: + mm = re.search( + r'id=["\']Msg["\'][^>]*>([\s\S]*?)', + resp.text, + re.I, + ) + if mm: + msg = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", mm.group(1))).strip() + + lower = msg.lower() + failed = any( + token in msg + for token in ("失敗", "錯誤", "error", "invalid", "無法", "拒絕") + ) + result["message"] = msg or "submitted" + result["ok"] = not failed + if failed: + result["error"] = msg + logger.info( + "CompBase fill RID=%s date=%s out=%s ok=%s msg=%s", + rid, + label_date, + chosen, + result["ok"], + msg, + ) + return result + + async def fill_recent( + self, + days: int = 14, + *, + dry_run: bool = False, + out_time_mode: str = "expected", + ) -> dict[str, Any]: + scan = await self.list_missing(days) + filled: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + skipped_rows = list(scan.get("skipped") or []) + + for item in scan.get("fillable") or []: + rid = item.get("rid") + if not rid: + skipped_rows.append({**item, "reason": "no_rid"}) + continue + try: + day = date.fromisoformat(item["date"]) if item.get("date") else None + result = await self.fill_rid( + str(rid), + out_time_mode=out_time_mode, + dry_run=dry_run, + row_date=day, + ) + if result.get("ok"): + filled.append({**item, **result}) + else: + errors.append({**item, **result}) + except Exception as exc: # noqa: BLE001 + logger.exception("CompBase fill failed for RID=%s", rid) + errors.append({**item, "ok": False, "error": str(exc)}) + + # annotate skipped without rid + for row in skipped_rows: + row.setdefault("reason", "no_rid" if not row.get("rid") else row.get("status", "skipped")) + + return { + "ok": len(errors) == 0, + "dry_run": dry_run, + "days": days, + "out_time_mode": out_time_mode, + "start": scan.get("start"), + "end": scan.get("end"), + "fillable_count": scan.get("fillable_count"), + "filled": filled, + "skipped": skipped_rows, + "errors": errors, + "filled_count": len(filled), + "skipped_count": len(skipped_rows), + "error_count": len(errors), + } + + async def status(self) -> dict[str, Any]: + username = (getattr(settings, "compbase_username", "") or "").strip() + source = "compbase" + if not username: + username = (getattr(settings, "pts_username", "") or "").strip() + source = "pts" if username else "none" + has_password = bool( + (getattr(settings, "compbase_password", "") or "") + or (getattr(settings, "pts_password", "") or "") + ) + result: dict[str, Any] = { + "list_url": self.list_url, + "fill_url": self.fill_url, + "username": username or None, + "credential_source": source, + "has_credentials": bool(username and has_password), + "default_days": int(getattr(settings, "compbase_default_days", 14) or 14), + "out_time_mode": getattr(settings, "compbase_out_time_mode", "expected") or "expected", + "connected": False, + } + if not result["has_credentials"]: + result["error"] = "No credentials configured" + return result + try: + async with self._make_client() as client: + resp = await client.get(f"{self.list_url}/") + result["connected"] = resp.status_code == 200 + result["http_status"] = resp.status_code + if resp.status_code == 200: + # pull employee name if present + m = re.search(r'id=["\']CName["\'][^>]*>([^<]+)', resp.text) + if m: + result["display_name"] = m.group(1).strip() + m = re.search(r'id=["\']EmpID["\'][^>]*>([^<]+)', resp.text) + if m: + result["emp_id"] = m.group(1).strip() + else: + result["error"] = f"HTTP {resp.status_code}" + except Exception as exc: # noqa: BLE001 + result["error"] = str(exc) + return result diff --git a/backend/services/date_parser.py b/backend/services/date_parser.py new file mode 100644 index 0000000..3625f69 --- /dev/null +++ b/backend/services/date_parser.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import re +from datetime import date +from pathlib import Path + + +def parse_dates_text(text: str) -> list[date]: + dates: list[date] = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + for token in re.split(r"[\s,;]+", line): + token = token.strip() + if token: + dates.append(date.fromisoformat(token)) + return sorted(set(dates)) + + +def parse_dates_file(path: str | Path) -> list[date]: + content = Path(path).read_text(encoding="utf-8") + dates = parse_dates_text(content) + if not dates: + raise ValueError(f"No dates found in {path}") + return dates \ No newline at end of file diff --git a/backend/services/description_formatter.py b/backend/services/description_formatter.py new file mode 100644 index 0000000..5eaebe5 --- /dev/null +++ b/backend/services/description_formatter.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from services.gitlab_client import GitLabTask + + +def format_professional_description(task: GitLabTask) -> str: + if task.source == "board_bug": + lead = "Investigated and resolved defect" + elif task.source == "milestone": + lead = "Delivered milestone work item" + else: + lead = "Completed engineering task" + + return f"{lead} ({task.reference}): {task.title}" \ No newline at end of file diff --git a/backend/services/distributor.py b/backend/services/distributor.py new file mode 100644 index 0000000..fdfd6ab --- /dev/null +++ b/backend/services/distributor.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from datetime import date + +from config import settings +from services.gitlab_client import GitLabTask +from services.holidays_util import iter_workdays + + +@dataclass +class DayEntry: + date: str + description: str + take_hours: float + gitlab_task_id: int | None = None + pts_task_name: str | None = None + + +def _to_units(hours: float, step: float) -> int: + return int(round(hours / step)) + + +def _from_units(units: int, step: float) -> float: + return round(units * step, 1) + + +def distribute_hours(num_tasks: int, max_hours: float, step: float) -> list[float]: + if num_tasks <= 0: + return [] + if num_tasks == 1: + return [max_hours] + + max_units = _to_units(max_hours, step) + min_units = 1 + if max_units < num_tasks * min_units: + return [_from_units(min_units, step)] * num_tasks + + remaining = max_units - num_tasks * min_units + weights = [random.random() for _ in range(num_tasks)] + total_weight = sum(weights) or 1 + extra = [int(remaining * w / total_weight) for w in weights] + leftover = remaining - sum(extra) + + idx = 0 + while leftover > 0: + extra[idx % num_tasks] += 1 + leftover -= 1 + idx += 1 + + return [_from_units(min_units + extra[i], step) for i in range(num_tasks)] + + +def build_daily_plan( + tasks: list[GitLabTask], + workdays: list[date], + *, + max_hours: float | None = None, + hour_step: float | None = None, + tasks_per_day: int | None = None, +) -> dict[str, list[DayEntry]]: + max_hours = max_hours or settings.max_hours_per_day + hour_step = hour_step or settings.hour_step + + if not workdays: + return {} + + if not tasks: + return {d.isoformat(): [] for d in workdays} + + per_day = tasks_per_day or min(3, max(1, len(tasks))) + plan: dict[str, list[DayEntry]] = {} + task_index = 0 + + for workday in workdays: + day_key = workday.isoformat() + day_tasks: list[GitLabTask] = [] + for _ in range(per_day): + day_tasks.append(tasks[task_index % len(tasks)]) + task_index += 1 + + hours = distribute_hours(len(day_tasks), max_hours, hour_step) + plan[day_key] = [ + DayEntry( + date=day_key, + description=task.to_description(), + take_hours=hours[i], + gitlab_task_id=task.id, + ) + for i, task in enumerate(day_tasks) + ] + + return plan + + +def build_plan_for_dates( + tasks: list[GitLabTask], + start: date, + end: date, +) -> dict[str, list[DayEntry]]: + workdays = iter_workdays(start, end) + return build_daily_plan(tasks, workdays) + + +def build_plan_for_day_list( + tasks: list[GitLabTask], + days: list[date], +) -> dict[str, list[DayEntry]]: + sorted_days = sorted(set(days)) + return build_daily_plan(tasks, sorted_days) + + +def entries_from_last_state( + last_entries: list[dict], + workdays: list[date], +) -> dict[str, list[DayEntry]]: + if not last_entries or not workdays: + return {} + + templates = [ + DayEntry( + date="", + description=e.get("description", ""), + take_hours=float(e.get("take_hours", 2)), + gitlab_task_id=e.get("gitlab_task_id"), + ) + for e in last_entries + if e.get("description") + ] + if not templates: + return {} + + plan: dict[str, list[DayEntry]] = {} + for workday in workdays: + day_key = workday.isoformat() + hours = distribute_hours(len(templates), settings.max_hours_per_day, settings.hour_step) + plan[day_key] = [ + DayEntry( + date=day_key, + description=templates[i].description, + take_hours=hours[i], + gitlab_task_id=templates[i].gitlab_task_id, + ) + for i in range(len(templates)) + ] + return plan \ No newline at end of file diff --git a/backend/services/extension_packager.py b/backend/services/extension_packager.py new file mode 100644 index 0000000..561e036 --- /dev/null +++ b/backend/services/extension_packager.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +from paths import EXTENSION_DIR + +ZIP_FOLDER_NAME = "pts-session-sync" + + +def build_extension_zip() -> bytes: + if not EXTENSION_DIR.is_dir(): + raise FileNotFoundError(f"Extension directory not found: {EXTENSION_DIR}") + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(EXTENSION_DIR.rglob("*")): + if not path.is_file(): + continue + if path.name.startswith("."): + continue + arcname = Path(ZIP_FOLDER_NAME) / path.relative_to(EXTENSION_DIR) + archive.write(path, arcname.as_posix()) + + return buffer.getvalue() \ No newline at end of file diff --git a/backend/services/fill_service.py b/backend/services/fill_service.py new file mode 100644 index 0000000..797d8cc --- /dev/null +++ b/backend/services/fill_service.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from config import settings +from services.distributor import DayEntry +from services.gitlab_client import GitLabClient +from services.holidays_util import is_workday +from services.planner import Planner +from services.pts_client import PTSClient +from services.state_store import ( + get_last_entries, + get_pts_session, + load_state, + mark_dates_filled, + set_last_entries, +) +from services.teams_calendar_scraper import TeamsCalendarScraper + + +class FillService: + def __init__(self) -> None: + self.gitlab = GitLabClient() + self.pts = PTSClient() + self.planner = Planner() + + async def fetch_gitlab_tasks(self) -> list[dict[str, Any]]: + tasks = await self.gitlab.get_assigned_tasks() + return [ + { + "id": t.id, + "iid": t.iid, + "title": t.title, + "reference": t.reference, + "web_url": t.web_url, + "source": t.source, + "labels": t.labels or [], + "description": t.to_description(), + } + for t in tasks + ] + + async def preview_plan( + self, + start: date, + end: date, + *, + use_last_if_empty: bool = True, + use_llm: bool | None = None, + ) -> dict[str, Any]: + result = await self.planner.build_plan( + start, + end, + use_llm=use_llm, + use_last_if_empty=use_last_if_empty, + ) + payload = {k: v for k, v in result.items() if k != "_plan_entries"} + return payload + + async def preview_plan_for_days( + self, + days: list[date], + *, + use_last_if_empty: bool = True, + use_llm: bool | None = None, + ) -> dict[str, Any]: + result = await self.planner.build_plan_for_days( + days, + use_llm=use_llm, + use_last_if_empty=use_last_if_empty, + ) + return {k: v for k, v in result.items() if k != "_plan_entries"} + + async def fill_dates( + self, + start: date, + end: date, + *, + skip_existing: bool = True, + dry_run: bool = False, + use_llm: bool | None = None, + ) -> dict[str, Any]: + planned = await self.planner.build_plan(start, end, use_llm=use_llm) + return await self._execute_plan( + planned, + skip_existing=skip_existing, + dry_run=dry_run, + ) + + async def fill_date_list( + self, + days: list[date], + *, + skip_existing: bool = True, + dry_run: bool = False, + use_llm: bool | None = None, + ) -> dict[str, Any]: + planned = await self.planner.build_plan_for_days(days, use_llm=use_llm) + result = await self._execute_plan( + planned, + skip_existing=skip_existing, + dry_run=dry_run, + ) + result["requested_dates"] = [d.isoformat() for d in sorted(set(days))] + return result + + async def _execute_plan( + self, + planned: dict[str, Any], + *, + skip_existing: bool, + dry_run: bool, + ) -> dict[str, Any]: + plan: dict[str, list[DayEntry]] = planned["_plan_entries"] + source = planned["source"] + + if not any(plan.values()): + return { + "ok": False, + "message": "No GitLab tasks and no previous entries to reuse.", + "source": source, + "analysis": planned.get("analysis", {}), + "created": [], + "skipped": [], + } + + project_code = await self.pts.resolve_project_code() + default_task_id = await self.pts.resolve_task_id() + task_id_cache: dict[str, int] = {} + + created: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + for day, entries in sorted(plan.items()): + if not entries: + continue + + if skip_existing: + existing = await self.pts.search_reports(day, day, project_code=project_code) + existing_hours = sum(float(r.get("takeHours") or 0) for r in existing) + if existing_hours >= settings.max_hours_per_day - 0.01: + skipped.append({"date": day, "reason": "already_filled", "hours": existing_hours}) + continue + + for entry in entries: + task_name = entry.pts_task_name or settings.pts_default_task_name + if task_name not in task_id_cache: + task_id_cache[task_name] = await self.pts.resolve_task_id(task_name) + task_id = task_id_cache[task_name] + + payload = { + "projectCode": project_code, + "taskId": task_id, + "description": entry.description, + "date": day.isoformat() if hasattr(day, "isoformat") else str(day), + "takeHours": entry.take_hours, + "licenseIds": [], + "pts_task_name": task_name, + } + + if dry_run: + created.append({"date": day, **payload, "dry_run": True}) + continue + + try: + report_id = await self.pts.create_report(payload) + created.append({"date": day, "id": report_id, **payload}) + except Exception as exc: # noqa: BLE001 + errors.append({"date": day, "description": entry.description, "error": str(exc)}) + + if created and not dry_run: + mark_dates_filled(list({c["date"] for c in created})) + set_last_entries( + [ + { + "description": e.description, + "take_hours": e.take_hours, + "gitlab_task_id": e.gitlab_task_id, + } + for entries in plan.values() + for e in entries + ][:10] + ) + + # Include human-readable plan for the web UI (same shape as preview) + plan_public: dict[str, list[dict[str, Any]]] = {} + for day, entries in plan.items(): + day_key = day.isoformat() if hasattr(day, "isoformat") else str(day) + plan_public[day_key] = [ + { + "description": e.description, + "take_hours": e.take_hours, + "pts_task_name": e.pts_task_name, + "gitlab_task_id": e.gitlab_task_id, + } + for e in entries + ] + + return { + "ok": len(errors) == 0, + "source": source, + "analysis": planned.get("analysis", {}), + "project_code": project_code, + "task_id": default_task_id, + "created": created, + "skipped": skipped, + "errors": errors, + "dry_run": dry_run, + "workdays": planned.get("workdays", list(plan_public.keys())), + "gitlab_task_count": planned.get("gitlab_task_count"), + "plan": plan_public, + } + + async def fill_today(self, *, dry_run: bool = False, use_llm: bool | None = None) -> dict[str, Any]: + today = date.today() + if not is_workday(today): + return { + "ok": True, + "message": f"{today.isoformat()} is not a workday (weekend/holiday). Skipped.", + "created": [], + "skipped": [{"date": today.isoformat(), "reason": "holiday_or_weekend"}], + } + return await self.fill_dates(today, today, dry_run=dry_run, use_llm=use_llm) + + async def get_status(self) -> dict[str, Any]: + state = load_state() + session = get_pts_session() + has_session = bool(session and session.get("accessToken")) + + status: dict[str, Any] = { + "has_pts_session": has_session, + "pts_session_updated_at": state.get("pts_session_updated_at"), + "last_entries_count": len(get_last_entries()), + "gitlab_configured": bool(settings.gitlab_token), + "llm_configured": bool(settings.xai_api_key), + "use_llm": settings.use_llm, + "use_description_variation": settings.use_description_variation, + "grok_model": settings.grok_model, + "auto_fill_enabled": settings.auto_fill_enabled, + "auto_fill_time": f"{settings.auto_fill_hour:02d}:{settings.auto_fill_minute:02d}", + "teams_calendar_enabled": settings.teams_calendar_enabled, + "pts_username": settings.pts_username or None, + "teams_calendar": TeamsCalendarScraper().status(), + # backward-compatible alias for old UI fields + "graph_calendar": TeamsCalendarScraper().status(), + } + + if has_session: + try: + pts_info = await self.pts.verify_connection() + status.update(pts_info) + status["pts_projects"] = pts_info["project_count"] + status["pts_task_types"] = pts_info["task_type_count"] + except Exception as exc: # noqa: BLE001 + status["pts_error"] = str(exc) + + try: + gitlab_tasks = await self.fetch_gitlab_tasks() + status["gitlab_open_tasks"] = len(gitlab_tasks) + except Exception as exc: # noqa: BLE001 + status["gitlab_error"] = str(exc) + + return status + + def ensure_ready(self) -> None: + if not settings.gitlab_token: + raise ValueError("Missing GITLAB_TOKEN in .env") + session = get_pts_session() + if not session or not session.get("accessToken"): + raise ValueError( + "Missing PTS session. Login with PTS account/password on the web UI (or sync via Chrome extension as fallback)." + ) \ No newline at end of file diff --git a/backend/services/gitlab_client.py b/backend/services/gitlab_client.py new file mode 100644 index 0000000..b9ecf03 --- /dev/null +++ b/backend/services/gitlab_client.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Optional +from urllib.parse import quote + +import httpx + +from config import settings + + +@dataclass +class GitLabTask: + id: int + iid: int + title: str + reference: str + web_url: str + project_path: str + source: str = "unknown" + labels: list[str] | None = None + + def to_description(self) -> str: + from services.description_formatter import format_professional_description + + return format_professional_description(self) + + +class GitLabClient: + def __init__(self) -> None: + self.base_url = settings.gitlab_url.rstrip("/") + self.token = (settings.gitlab_token or "").strip().strip("\"'") + self._username: Optional[str] = None + + def _headers(self) -> dict[str, str]: + return {"PRIVATE-TOKEN": self.token} + + def _auth_error_message(self, response: httpx.Response) -> str: + detail = "" + try: + payload = response.json() + detail = ( + payload.get("error_description") + or payload.get("message") + or payload.get("error") + or "" + ) + except Exception: # noqa: BLE001 + detail = (response.text or "")[:200] + detail = str(detail).strip() + lower = detail.lower() + if response.status_code == 401: + if "revok" in lower: + return ( + "GitLab token 已撤銷或失效。請到 GitLab → Preferences → Access Tokens " + "重新建立 Personal Access Token(勾選 api),再到設定頁貼上新 token。" + ) + return ( + "GitLab token 無效(401)。請確認已貼上新的 Personal Access Token," + f"且 URL 為 {self.base_url}。{(' 詳情: ' + detail) if detail else ''}" + ) + if response.status_code == 403: + return f"GitLab token 權限不足(403)。請勾選 api scope。{(' 詳情: ' + detail) if detail else ''}" + return f"GitLab API error {response.status_code}: {detail or response.reason_phrase}" + + async def _get_current_username(self) -> str: + if self._username: + return self._username + async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: + response = await client.get(f"{self.base_url}/api/v4/user") + if response.status_code in {401, 403}: + raise ValueError(self._auth_error_message(response)) + response.raise_for_status() + self._username = response.json()["username"] + return self._username + + def _issue_to_task(self, issue: dict[str, Any], source: str) -> GitLabTask: + refs = issue.get("references") or {} + return GitLabTask( + id=issue["id"], + iid=issue["iid"], + title=issue["title"], + reference=refs.get("full") or f"#{issue['iid']}", + web_url=issue.get("web_url", ""), + project_path=refs.get("relative", ""), + source=source, + labels=issue.get("labels") or [], + ) + + async def _fetch_project_issues( + self, + project_path: str, + *, + source: str, + extra_params: Optional[dict[str, Any]] = None, + ) -> list[GitLabTask]: + encoded = quote(project_path, safe="") + params: dict[str, Any] = { + "state": "opened", + "per_page": 100, + "order_by": "updated_at", + "sort": "desc", + } + if extra_params: + params.update(extra_params) + + async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: + response = await client.get( + f"{self.base_url}/api/v4/projects/{encoded}/issues", + params=params, + ) + response.raise_for_status() + issues = response.json() + + return [self._issue_to_task(issue, source) for issue in issues] + + async def get_board_bug_tasks(self) -> list[GitLabTask]: + username = await self._get_current_username() + labels = ",".join(settings.gitlab_board_label_list) + return await self._fetch_project_issues( + settings.gitlab_project_path, + source="board_bug", + extra_params={ + "assignee_username": username, + "labels": labels, + }, + ) + + async def _pick_current_milestone( + self, + active_milestones: list[dict[str, Any]], + closed_milestones: list[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if settings.gitlab_milestone_title: + for milestone in active_milestones: + if milestone.get("title") == settings.gitlab_milestone_title: + return milestone + return {"title": settings.gitlab_milestone_title, "state": "configured"} + + version_re = re.compile(r"^SCC_v", re.IGNORECASE) + active_versions = [ + milestone + for milestone in active_milestones + if milestone.get("state") == "active" + and "backlog" not in milestone.get("title", "").lower() + and version_re.match(milestone.get("title", "")) + ] + if not active_versions: + return None + + closed_versions = [ + milestone + for milestone in closed_milestones + if version_re.match(milestone.get("title", "")) + ] + latest_closed_version = 0 + if closed_versions: + latest_closed_version = max( + self._milestone_version_key(milestone.get("title", "")) + for milestone in closed_versions + ) + + # 目前衝刺 = 最新已關閉版本之後、版本號最小的 active milestone + # 例:a10 已關閉 → 選 a11(不是規劃中的 a12) + successors = [ + milestone + for milestone in active_versions + if self._milestone_version_key(milestone.get("title", "")) > latest_closed_version + ] + if successors: + successors.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", ""))) + return successors[0] + + active_versions.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", ""))) + return active_versions[0] + + def _milestone_version_key(self, title: str) -> int: + match = re.search(r"a(\d+)$", title, re.IGNORECASE) + return int(match.group(1)) if match else 0 + + async def get_milestone_tasks(self) -> tuple[Optional[str], list[GitLabTask]]: + encoded = quote(settings.gitlab_project_path, safe="") + username = await self._get_current_username() + + async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: + active_resp = await client.get( + f"{self.base_url}/api/v4/projects/{encoded}/milestones", + params={"state": "active", "per_page": 50}, + ) + active_resp.raise_for_status() + active_milestones = active_resp.json() + + closed_resp = await client.get( + f"{self.base_url}/api/v4/projects/{encoded}/milestones", + params={"state": "closed", "per_page": 20, "order_by": "updated_at", "sort": "desc"}, + ) + closed_resp.raise_for_status() + closed_milestones = closed_resp.json() + + milestone = await self._pick_current_milestone(active_milestones, closed_milestones) + if not milestone: + return None, [] + + title = milestone["title"] + tasks = await self._fetch_project_issues( + settings.gitlab_project_path, + source="milestone", + extra_params={ + "assignee_username": username, + "milestone": title, + }, + ) + return title, tasks + + async def get_assigned_tasks(self) -> list[GitLabTask]: + if not self.token: + raise ValueError("GITLAB_TOKEN is not configured") + + board_tasks = await self.get_board_bug_tasks() + milestone_title, milestone_tasks = await self.get_milestone_tasks() + + merged: dict[int, GitLabTask] = {} + for task in board_tasks: + merged[task.id] = task + for task in milestone_tasks: + merged.setdefault(task.id, task) + + tasks = list(merged.values()) + tasks.sort(key=lambda t: (0 if t.source == "board_bug" else 1, t.reference)) + return tasks + + async def get_task_sources(self) -> dict[str, Any]: + board_tasks = await self.get_board_bug_tasks() + milestone_title, milestone_tasks = await self.get_milestone_tasks() + merged = await self.get_assigned_tasks() + return { + "board": { + "project": settings.gitlab_project_path, + "board_id": settings.gitlab_board_id, + "labels": settings.gitlab_board_label_list, + "count": len(board_tasks), + "tasks": [t.__dict__ for t in board_tasks], + }, + "milestone": { + "title": milestone_title, + "count": len(milestone_tasks), + "tasks": [t.__dict__ for t in milestone_tasks], + }, + "merged_count": len(merged), + "tasks": [t.__dict__ for t in merged], + } \ No newline at end of file diff --git a/backend/services/grok_client.py b/backend/services/grok_client.py new file mode 100644 index 0000000..1a23470 --- /dev/null +++ b/backend/services/grok_client.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import json +import logging +import re +from datetime import date +from typing import Any + +import httpx + +from config import settings +from services.description_formatter import format_professional_description +from services.distributor import DayEntry +from services.gitlab_client import GitLabTask + +logger = logging.getLogger(__name__) + +DEFAULT_PTS_TASK_TYPES = [ + "Implement", + "Meeting", + "Test", + "Survey", + "Bug Fix", + "Support", + "Trouble Shooting", + "Document", + "Design", + "Code Review", + "Training", + "Project Management", + "Manager Task", + "POC", + "Interview", + "Investigating", + "Misc", +] + + +class GrokClient: + def __init__(self) -> None: + self.api_key = settings.xai_api_key + self.base_url = settings.xai_base_url.rstrip("/") + self.model = settings.grok_model + + @property + def enabled(self) -> bool: + return bool(self.api_key) + + async def plan_pts_entries( + self, + tasks: list[GitLabTask], + workdays: list[date], + *, + pts_project_name: str, + pts_task_name: str, + max_hours_per_day: float, + hour_step: float, + ) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]: + if not self.enabled: + raise ValueError("XAI_API_KEY is not configured") + + if not tasks: + raise ValueError("No GitLab tasks to analyze") + if not workdays: + raise ValueError("No workdays in selected range") + + prompt = self._build_prompt( + tasks, + workdays, + pts_project_name=pts_project_name, + pts_task_name=pts_task_name, + max_hours_per_day=max_hours_per_day, + hour_step=hour_step, + ) + + raw = await self._chat(prompt) + parsed = self._parse_json(raw) + plan = self._to_plan(parsed, tasks, workdays, hour_step, max_hours_per_day) + analysis = { + "model": self.model, + "summary": parsed.get("summary", ""), + "notes": parsed.get("notes", ""), + "days": parsed.get("days", []), + } + return plan, analysis + + def _build_prompt( + self, + tasks: list[GitLabTask], + workdays: list[date], + *, + pts_project_name: str, + pts_task_name: str, + max_hours_per_day: float, + hour_step: float, + ) -> str: + task_payload = [ + { + "id": task.id, + "reference": task.reference, + "title": task.title, + "source": task.source, + "labels": task.labels or [], + } + for task in tasks + ] + days_payload = [d.isoformat() for d in workdays] + + return f"""You are a senior software engineer preparing professional PTS timesheet entries for Supermicro. + +Plan daily work logs based on the GitLab tasks below. + +## PTS constraints +- Project (fixed): {pts_project_name} +- Task type (fixed): {pts_task_name} +- Plan only: description + take_hours +- Max {max_hours_per_day} hours per day +- Hours must be multiples of {hour_step} +- 1 to 3 entries per day +- Prioritize board bugs (source=board_bug), then milestone items (source=milestone) + +## Description style (English only — mandatory) +Write concise, professional engineering work logs suitable for management review. + +Rules: +- English only. No Chinese or other languages. +- Use past tense or present-perfect tense (e.g., "Investigated", "Implemented", "Refactored", "Validated"). +- Start with a strong action verb. +- Include the GitLab reference in parentheses, e.g. (super-cloud/scc-flex#1319). +- Do NOT paste the raw GitLab title verbatim — rewrite as what was accomplished that day. +- One sentence, 12–25 words. +- Sound credible and specific, not generic. +- When the same gitlab_task_id appears on multiple days, each description MUST be a unique paraphrase: + vary verbs and daily focus (investigated, reproduced, patched, validated, refactored, reviewed, etc.). +- Never reuse identical description text across entries. + +Good examples: +- "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319)." +- "Refactored Jobs Schedule facade error responses for API consistency (super-cloud/scc-flex#1294)." +- "Validated firmware baseline history progress calculation under multi-target runs (super-cloud/scc-flex#1296)." + +Bad examples: +- "Worked on bug" (too vague) +- Copy-paste of full GitLab title without rewrite +- Any non-English text + +## Workdays +{json.dumps(days_payload)} + +## GitLab tasks +{json.dumps(task_payload, indent=2)} + +Return JSON only: +{{ + "summary": "Brief English summary of the overall plan", + "notes": "English explanation of allocation logic", + "days": [ + {{ + "date": "YYYY-MM-DD", + "entries": [ + {{ + "gitlab_task_id": 123, + "description": "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319).", + "take_hours": 2.5, + "reasoning": "English rationale for task selection and hours" + }} + ] + }} + ] +}} +""" + + async def _chat(self, prompt: str, *, temperature: float = 0.2) -> str: + payload = { + "model": self.model, + "messages": [ + { + "role": "system", + "content": ( + "You are a professional technical writing assistant. " + "Output valid JSON only. All text fields must be in English." + ), + }, + {"role": "user", "content": prompt}, + ], + "temperature": temperature, + "max_tokens": 4096, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{self.base_url}/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + data = response.json() + + content = data["choices"][0]["message"]["content"] + if not content: + raise ValueError("Grok returned empty content") + return content + + def _parse_json(self, text: str) -> dict[str, Any]: + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + + try: + return json.loads(cleaned) + except json.JSONDecodeError: + match = re.search(r"\{.*\}", cleaned, re.DOTALL) + if not match: + raise ValueError("Grok response is not valid JSON") from None + return json.loads(match.group()) + + def _to_plan( + self, + parsed: dict[str, Any], + tasks: list[GitLabTask], + workdays: list[date], + hour_step: float, + max_hours_per_day: float, + ) -> dict[str, list[DayEntry]]: + valid_ids = {task.id for task in tasks} + task_by_id = {task.id: task for task in tasks} + allowed_days = {d.isoformat() for d in workdays} + plan: dict[str, list[DayEntry]] = {d.isoformat(): [] for d in workdays} + + for day_block in parsed.get("days", []): + day = str(day_block.get("date", "")) + if day not in allowed_days: + continue + + entries: list[DayEntry] = [] + total_hours = 0.0 + for item in day_block.get("entries", []): + task_id = item.get("gitlab_task_id") + if task_id not in valid_ids: + continue + + hours = float(item.get("take_hours", 0)) + hours = round(round(hours / hour_step) * hour_step, 1) + if hours <= 0: + continue + if total_hours + hours > max_hours_per_day: + hours = max(0.0, max_hours_per_day - total_hours) + hours = round(round(hours / hour_step) * hour_step, 1) + if hours <= 0: + continue + + description = str(item.get("description", "")).strip() + if not description or self._contains_cjk(description): + description = format_professional_description(task_by_id[task_id]) + + entries.append( + DayEntry( + date=day, + description=description, + take_hours=hours, + gitlab_task_id=task_id, + ) + ) + total_hours += hours + + if entries: + plan[day] = entries + + if not any(plan.values()): + raise ValueError("Grok plan contained no valid entries") + + return plan + + def _contains_cjk(self, text: str) -> bool: + return bool(re.search(r"[\u4e00-\u9fff\u3400-\u4dbf]", text)) + + def _match_task_name(self, name: str, allowed: list[str], fallback: str) -> str: + cleaned = str(name or "").strip() + if cleaned in allowed: + return cleaned + lowered = cleaned.lower() + for option in allowed: + if lowered == option.lower(): + return option + for option in allowed: + if lowered in option.lower() or option.lower() in lowered: + return option + return fallback if fallback in allowed else allowed[0] + + async def classify_calendar_meetings( + self, + meetings: list[dict[str, Any]], + *, + pts_task_options: list[str], + default_task_name: str, + ) -> tuple[dict[str, dict[str, str]], dict[str, Any]]: + if not meetings: + return {}, {"source": "skipped", "reason": "no_meetings"} + + allowed = pts_task_options or DEFAULT_PTS_TASK_TYPES + fallback = self._match_task_name(default_task_name, allowed, "Meeting") + + prompt = f"""You classify Microsoft Teams / Outlook calendar events into PTS timesheet task types for Supermicro engineers. + +For EACH meeting below, pick the best PTS task type and write a professional English work-log description. + +## Allowed PTS task types (pick exactly one per meeting) +{json.dumps(allowed, ensure_ascii=False)} + +## Classification guide +- Standups, syncs, team meetings, 1:1s, general discussions -> Meeting +- Sprint planning, roadmap, status review, project sync -> Project Management +- Design review, architecture review, UI/UX review -> Design +- Code review, PR review -> Code Review +- Training, workshop, onboarding session -> Training +- Customer call, support escalation, troubleshooting call -> Support or Trouble Shooting +- Interview (candidate) -> Interview +- Demo, POC review -> POC +- Requirements gathering, user research -> Survey +- Incident/postmortem investigation -> Investigating +- Documentation walkthrough -> Document + +## Description rules +- English only, one sentence, 12–25 words, past tense +- Mention Teams/meeting context naturally +- Do NOT change take_hours + +## Meetings +{json.dumps(meetings, ensure_ascii=False, indent=2)} + +Return JSON only: +{{ + "summary": "brief note on classification approach", + "meetings": [ + {{ + "key": "YYYY-MM-DD#0", + "pts_task_name": "Meeting", + "description": "Attended sprint planning for SCC v3.9 release scope.", + "reasoning": "sprint planning keyword" + }} + ] +}} +""" + + raw = await self._chat(prompt, temperature=0.3) + data = self._parse_json(raw) + classified: dict[str, dict[str, str]] = {} + for item in data.get("meetings", []): + key = str(item.get("key", "")).strip() + if not key: + continue + description = str(item.get("description", "")).strip() + if not description or self._contains_cjk(description): + continue + task_name = self._match_task_name( + str(item.get("pts_task_name", "")), + allowed, + fallback, + ) + classified[key] = { + "pts_task_name": task_name, + "description": description, + "reasoning": str(item.get("reasoning", "")).strip(), + } + + return classified, { + "source": "grok_meeting_classify", + "summary": data.get("summary", ""), + "classified_count": len(classified), + } + + async def diversify_descriptions( + self, + plan: dict[str, list[DayEntry]], + tasks: list[GitLabTask], + ) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]: + flat: list[dict[str, Any]] = [] + for day_key in sorted(plan.keys()): + for idx, entry in enumerate(plan[day_key]): + flat.append( + { + "key": f"{day_key}#{idx}", + "date": day_key, + "gitlab_task_id": entry.gitlab_task_id, + "current_description": entry.description, + "take_hours": entry.take_hours, + } + ) + + if len(flat) <= 1: + return plan, {"source": "variation_skipped", "reason": "single_entry"} + + task_by_id = {t.id: t for t in tasks} + task_context = [ + { + "id": t.id, + "reference": t.reference, + "title": t.title, + "source": t.source, + } + for t in tasks + ] + + prompt = f"""Rewrite PTS timesheet descriptions for Supermicro engineering work logs. + +For EACH entry below, produce a UNIQUE professional English description that: +- Keeps the same GitLab issue reference and gitlab_task_id when present +- Stays faithful to the task title / current description — describe plausible daily engineering work +- Uses different wording, verbs, and focus than every other entry in this batch +- Sounds like a real one-day work log (one sentence, 12–25 words), not copy-paste +- English only. Past tense or present-perfect. Start with a strong action verb. +- Do NOT change dates or take_hours + +GitLab tasks (context): +{json.dumps(task_context, ensure_ascii=False, indent=2)} + +Entries to rewrite: +{json.dumps(flat, ensure_ascii=False, indent=2)} + +Return JSON only: +{{ + "summary": "short English note on variation approach", + "entries": [ + {{"key": "YYYY-MM-DD#0", "description": "unique English work log"}} + ] +}} +""" + + raw = await self._chat(prompt, temperature=0.65) + data = self._parse_json(raw) + rewritten = { + item["key"]: str(item.get("description", "")).strip() + for item in data.get("entries", []) + if item.get("key") + } + if not rewritten: + raise ValueError("Grok variation returned no entries") + + new_plan: dict[str, list[DayEntry]] = {} + for day_key in sorted(plan.keys()): + new_plan[day_key] = [] + for idx, entry in enumerate(plan[day_key]): + key = f"{day_key}#{idx}" + description = rewritten.get(key, entry.description) + if (not description or self._contains_cjk(description)) and entry.gitlab_task_id in task_by_id: + description = format_professional_description(task_by_id[entry.gitlab_task_id]) + new_plan[day_key].append( + DayEntry( + date=day_key, + description=description, + take_hours=entry.take_hours, + gitlab_task_id=entry.gitlab_task_id, + ) + ) + + return new_plan, { + "source": "grok_variation", + "summary": data.get("summary", ""), + "rewritten_count": len(rewritten), + } \ No newline at end of file diff --git a/backend/services/holidays_util.py b/backend/services/holidays_util.py new file mode 100644 index 0000000..36363b0 --- /dev/null +++ b/backend/services/holidays_util.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import date, timedelta + +import holidays + + +def is_workday(d: date, country: str = "TW") -> bool: + if d.weekday() >= 5: + return False + tw_holidays = holidays.country_holidays(country, years={d.year}) + return d not in tw_holidays + + +def iter_workdays(start: date, end: date, country: str = "TW") -> list[date]: + if start > end: + start, end = end, start + days: list[date] = [] + current = start + while current <= end: + if is_workday(current, country): + days.append(current) + current += timedelta(days=1) + return days \ No newline at end of file diff --git a/backend/services/meeting_planner.py b/backend/services/meeting_planner.py new file mode 100644 index 0000000..a5f3719 --- /dev/null +++ b/backend/services/meeting_planner.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import logging +import re + +from config import settings +from services.calendar_models import CalendarMeeting, format_meeting_description +from services.distributor import DayEntry, _from_units, _to_units +from services.grok_client import DEFAULT_PTS_TASK_TYPES, GrokClient +from services.teams_calendar_scraper import TeamsCalendarScraper + +logger = logging.getLogger(__name__) + +_KEYWORD_TASK_RULES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"design|architecture|ux|ui review", re.I), "Design"), + (re.compile(r"code review|pr review", re.I), "Code Review"), + (re.compile(r"sprint|planning|roadmap|project|status", re.I), "Project Management"), + (re.compile(r"train|workshop|onboard|tutorial", re.I), "Training"), + (re.compile(r"interview", re.I), "Interview"), + (re.compile(r"support|customer|escalat", re.I), "Support"), + (re.compile(r"incident|postmortem|troubleshoot|debug", re.I), "Trouble Shooting"), + (re.compile(r"investigat|root cause", re.I), "Investigating"), + (re.compile(r"demo|poc|proof of concept", re.I), "POC"), + (re.compile(r"survey|research|requirement", re.I), "Survey"), + (re.compile(r"document|spec|write.?up", re.I), "Document"), + (re.compile(r"1:1|one on one|manager", re.I), "Manager Task"), +] + + +def _scale_entry_hours(entries: list[DayEntry], target_hours: float, hour_step: float) -> list[DayEntry]: + if not entries or target_hours <= 0: + return [] + + current = sum(entry.take_hours for entry in entries) + if current <= 0: + return entries + + target_units = _to_units(target_hours, hour_step) + if target_units <= 0: + return [] + + scaled: list[DayEntry] = [] + allocated = 0 + for index, entry in enumerate(entries): + if index == len(entries) - 1: + units = max(1, target_units - allocated) + else: + units = max(1, int(round((entry.take_hours / current) * target_units))) + allocated += units + scaled.append( + DayEntry( + date=entry.date, + description=entry.description, + take_hours=_from_units(units, hour_step), + gitlab_task_id=entry.gitlab_task_id, + pts_task_name=entry.pts_task_name, + ) + ) + return scaled + + +def _guess_meeting_task(subject: str, allowed: list[str], fallback: str) -> str: + for pattern, task_name in _KEYWORD_TASK_RULES: + if pattern.search(subject): + if task_name in allowed: + return task_name + for option in allowed: + if option.lower() == task_name.lower(): + return option + return fallback if fallback in allowed else (allowed[0] if allowed else "Meeting") + + +async def _load_pts_task_options() -> list[str]: + try: + from services.pts_client import PTSClient + + options = await PTSClient().get_task_type_options() + names = [str(opt.get("name", "")).strip() for opt in options if not opt.get("disable")] + return [name for name in names if name] + except Exception as exc: # noqa: BLE001 + logger.warning("Could not load PTS task options, using defaults: %s", exc) + return list(DEFAULT_PTS_TASK_TYPES) + + +async def _classify_meetings( + meeting_payload: list[dict], + *, + pts_task_options: list[str], +) -> tuple[dict[str, dict[str, str]], dict]: + fallback = settings.teams_meeting_task_name or "Meeting" + allowed = pts_task_options or list(DEFAULT_PTS_TASK_TYPES) + if fallback not in allowed: + fallback = _guess_meeting_task("", allowed, "Meeting") + + grok = GrokClient() + if settings.use_llm and grok.enabled and meeting_payload: + try: + classified, meta = await grok.classify_calendar_meetings( + meeting_payload, + pts_task_options=allowed, + default_task_name=fallback, + ) + if classified: + return classified, meta + except Exception as exc: # noqa: BLE001 + logger.warning("LLM meeting classification failed, using rules: %s", exc) + + classified = {} + for item in meeting_payload: + key = item["key"] + subject = str(item.get("subject", "")) + task_name = _guess_meeting_task(subject, allowed, fallback) + meeting = CalendarMeeting( + subject=subject, + start=item["start"], + end=item["end"], + hours=float(item["hours"]), + is_online=bool(item.get("is_online")), + ) + classified[key] = { + "pts_task_name": task_name, + "description": format_meeting_description(meeting), + "reasoning": "rule_based_keyword", + } + + return classified, {"source": "rule_based_meeting_classify", "classified_count": len(classified)} + + +async def merge_meetings_into_plan( + plan: dict[str, list[DayEntry]], + workday_keys: list[str], +) -> tuple[dict[str, list[DayEntry]], dict]: + client = TeamsCalendarScraper() + meta: dict = {"enabled": False} + + if not client.enabled: + return plan, meta + + try: + from datetime import date + + days = [date.fromisoformat(day_key) for day_key in workday_keys] + meetings_by_day = await client.fetch_meetings_for_days(days) + except Exception as exc: # noqa: BLE001 + return plan, {"enabled": True, "error": str(exc)} + + # Some calendar "meetings" are just automated reminders (e.g. HR clock-out + # nudges) that show up fine in the preview list but should never turn into + # a PTS work-log line. Filter those out here only — list_meetings()/preview + # keeps showing them since it reads fetch_meetings_for_days() directly. + exclude_keywords = [kw.lower() for kw in settings.teams_meeting_exclude_keyword_list] + excluded_count = 0 + if exclude_keywords: + filtered_by_day: dict[str, list[CalendarMeeting]] = {} + for day_key, meetings in meetings_by_day.items(): + kept = [] + for meeting in meetings: + subject_l = meeting.subject.lower() + if any(kw in subject_l for kw in exclude_keywords): + excluded_count += 1 + continue + kept.append(meeting) + filtered_by_day[day_key] = kept + meetings_by_day = filtered_by_day + + pts_task_options = await _load_pts_task_options() + meeting_payload: list[dict] = [] + for day_key in workday_keys: + for idx, meeting in enumerate(meetings_by_day.get(day_key, [])): + meeting_payload.append( + { + "key": f"{day_key}#{idx}", + "date": day_key, + "subject": meeting.subject, + "hours": meeting.hours, + "is_online": meeting.is_online, + "start": meeting.start.isoformat(), + "end": meeting.end.isoformat(), + } + ) + + classified, classify_meta = await _classify_meetings( + meeting_payload, + pts_task_options=pts_task_options, + ) + max_hours = settings.max_hours_per_day + hour_step = settings.hour_step + merged: dict[str, list[DayEntry]] = {} + meeting_count = 0 + meeting_hours_total = 0.0 + task_breakdown: dict[str, int] = {} + + for day_key in workday_keys: + meetings: list[CalendarMeeting] = meetings_by_day.get(day_key, []) + meeting_entries: list[DayEntry] = [] + for idx, meeting in enumerate(meetings): + key = f"{day_key}#{idx}" + info = classified.get(key, {}) + task_name = info.get("pts_task_name") or settings.teams_meeting_task_name or "Meeting" + description = info.get("description") or format_meeting_description(meeting) + meeting_entries.append( + DayEntry( + date=day_key, + description=description, + take_hours=meeting.hours, + gitlab_task_id=None, + pts_task_name=task_name, + ) + ) + task_breakdown[task_name] = task_breakdown.get(task_name, 0) + 1 + + meeting_hours = round(sum(entry.take_hours for entry in meeting_entries), 1) + meeting_count += len(meeting_entries) + meeting_hours_total += meeting_hours + + if meeting_hours >= max_hours: + merged[day_key] = _scale_entry_hours(meeting_entries, max_hours, hour_step) + continue + + remaining = max(0.0, max_hours - meeting_hours) + gitlab_entries = _scale_entry_hours(plan.get(day_key, []), remaining, hour_step) + merged[day_key] = meeting_entries + gitlab_entries + + meta = { + "enabled": True, + "meeting_count": meeting_count, + "meeting_hours_total": round(meeting_hours_total, 1), + "task_breakdown": task_breakdown, + "classification": classify_meta, + "excluded_count": excluded_count, + } + return merged, meta \ No newline at end of file diff --git a/backend/services/planner.py b/backend/services/planner.py new file mode 100644 index 0000000..d92b305 --- /dev/null +++ b/backend/services/planner.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import logging +from datetime import date +from typing import Any, Optional + +from config import settings +from services.distributor import ( + DayEntry, + build_plan_for_dates, + build_plan_for_day_list, + entries_from_last_state, +) +from services.gitlab_client import GitLabClient, GitLabTask +from services.grok_client import GrokClient +from services.holidays_util import iter_workdays +from services.meeting_planner import merge_meetings_into_plan +from services.state_store import get_last_entries + +logger = logging.getLogger(__name__) + + +class Planner: + def __init__(self) -> None: + self.gitlab = GitLabClient() + self.grok = GrokClient() + + async def build_plan( + self, + start: date, + end: date, + *, + use_llm: Optional[bool] = None, + use_last_if_empty: bool = True, + ) -> dict[str, Any]: + workdays = iter_workdays(start, end) + return await self._build_plan_for_days( + workdays, + use_llm=use_llm, + use_last_if_empty=use_last_if_empty, + rule_builder=lambda tasks, _: build_plan_for_dates(tasks, start, end), + ) + + async def build_plan_for_days( + self, + days: list[date], + *, + use_llm: Optional[bool] = None, + use_last_if_empty: bool = True, + ) -> dict[str, Any]: + sorted_days = sorted(set(days)) + return await self._build_plan_for_days( + sorted_days, + use_llm=use_llm, + use_last_if_empty=use_last_if_empty, + rule_builder=lambda tasks, workdays: build_plan_for_day_list(tasks, workdays), + ) + + async def _build_plan_for_days( + self, + workdays: list[date], + *, + use_llm: Optional[bool], + use_last_if_empty: bool, + rule_builder, + ) -> dict[str, Any]: + gitlab_tasks = await self.gitlab.get_assigned_tasks() + + if gitlab_tasks: + use_llm = settings.use_llm if use_llm is None else use_llm + if use_llm and self.grok.enabled: + try: + plan, analysis = await self.grok.plan_pts_entries( + gitlab_tasks, + workdays, + pts_project_name=settings.pts_project_name, + pts_task_name=settings.pts_default_task_name, + max_hours_per_day=settings.max_hours_per_day, + hour_step=settings.hour_step, + ) + plan, analysis = await self._apply_variation( + plan, + gitlab_tasks, + analysis, + source="llm", + ) + return await self._finalize_plan( + plan, + source="llm", + gitlab_tasks=gitlab_tasks, + workdays=workdays, + analysis=analysis, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("LLM planning failed, fallback to rules: %s", exc) + + plan = rule_builder(gitlab_tasks, workdays) + plan, analysis = await self._apply_variation( + plan, + gitlab_tasks, + {"fallback_reason": "rule_based_distribution"}, + source="rule_based", + ) + return await self._finalize_plan( + plan, + source="rule_based", + gitlab_tasks=gitlab_tasks, + workdays=workdays, + analysis=analysis, + ) + + if use_last_if_empty: + plan = entries_from_last_state(get_last_entries(), workdays) + plan, analysis = await self._apply_variation( + plan, + [], + {}, + source="last_entries", + ) + return await self._finalize_plan( + plan, + source="last_entries", + gitlab_tasks=[], + workdays=workdays, + analysis=analysis, + ) + + return await self._finalize_plan( + {d.isoformat(): [] for d in workdays}, + source="empty", + gitlab_tasks=[], + workdays=workdays, + analysis={}, + ) + + def _has_duplicate_descriptions(self, plan: dict[str, list[DayEntry]]) -> bool: + descriptions = [entry.description for entries in plan.values() for entry in entries] + return len(descriptions) != len(set(descriptions)) + + async def _apply_variation( + self, + plan: dict[str, list[DayEntry]], + tasks: list[GitLabTask], + analysis: dict[str, Any], + *, + source: str, + ) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]: + if not settings.use_description_variation or not self.grok.enabled: + return plan, analysis + + entry_count = sum(len(entries) for entries in plan.values()) + if entry_count <= 1: + return plan, {**analysis, "variation": {"source": "variation_skipped", "reason": "single_entry"}} + + if source == "llm" and not self._has_duplicate_descriptions(plan): + return plan, {**analysis, "variation": {"source": "variation_skipped", "reason": "llm_unique"}} + + try: + plan, variation = await self.grok.diversify_descriptions(plan, tasks) + return plan, {**analysis, "variation": variation} + except Exception as exc: # noqa: BLE001 + logger.warning("Description variation failed: %s", exc) + return plan, {**analysis, "variation": {"source": "variation_failed", "error": str(exc)}} + + async def _finalize_plan( + self, + plan: dict[str, list[DayEntry]], + *, + source: str, + gitlab_tasks: list[GitLabTask], + workdays: list[date], + analysis: dict[str, Any], + ) -> dict[str, Any]: + workday_keys = [d.isoformat() for d in workdays] + plan, meeting_meta = await merge_meetings_into_plan(plan, workday_keys) + if meeting_meta: + analysis = {**analysis, "meetings": meeting_meta} + return self._wrap_plan( + plan, + source=source, + gitlab_tasks=gitlab_tasks, + workdays=workdays, + analysis=analysis, + ) + + def _wrap_plan( + self, + plan: dict[str, list[DayEntry]], + *, + source: str, + gitlab_tasks: list[GitLabTask], + workdays: list[date], + analysis: dict[str, Any], + ) -> dict[str, Any]: + return { + "source": source, + "gitlab_task_count": len(gitlab_tasks), + "workdays": [d.isoformat() for d in workdays], + "llm_enabled": self.grok.enabled, + "analysis": analysis, + "plan": { + day: [ + { + "description": entry.description, + "take_hours": entry.take_hours, + "gitlab_task_id": entry.gitlab_task_id, + "pts_task_name": entry.pts_task_name, + } + for entry in entries + ] + for day, entries in plan.items() + }, + "_plan_entries": plan, + } \ No newline at end of file diff --git a/backend/services/pts_client.py b/backend/services/pts_client.py new file mode 100644 index 0000000..cd8f9f4 --- /dev/null +++ b/backend/services/pts_client.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any + +import httpx + +from config import settings +from services.secret_box import decrypt_secret +from services.state_store import get_pts_session, set_pts_session + +logger = logging.getLogger(__name__) + +TASK_NAME_ALIASES = { + "development": "Implement", + "dev": "Implement", +} + +_SESSION_HELP = ( + "PTS session not found. Enter PTS account/password on the web UI, " + "or sync via Chrome extension." +) + + +def normalize_pts_base_url(url: str) -> str: + """PTS REST APIs are hosted under /PTS, not the site root.""" + base = url.rstrip("/") + if base.lower().endswith("/pts"): + return base + return f"{base}/PTS" + + +def _normalize_session_payload(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise ValueError("PTS login returned unexpected payload") + access = data.get("accessToken") or data.get("AccessToken") + if not access: + raise ValueError("PTS login did not return accessToken") + session = dict(data) + session["accessToken"] = access + if data.get("refreshToken") or data.get("RefreshToken"): + session["refreshToken"] = data.get("refreshToken") or data.get("RefreshToken") + group = data.get("groupCode") or data.get("GroupCode") + if not group and session.get("accessToken") and session["accessToken"].count(".") >= 2: + try: + payload = session["accessToken"].split(".")[1] + payload += "=" * (-len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + group = decoded.get("groupCode") + except (json.JSONDecodeError, ValueError, TypeError): + group = None + if group: + session["groupCode"] = str(group) + return session + + +class PTSClient: + def __init__(self) -> None: + self.base_url = normalize_pts_base_url(settings.pts_url) + + def _require_session(self) -> dict[str, Any]: + session = get_pts_session() + if not session or not session.get("accessToken"): + raise ValueError(_SESSION_HELP) + return session + + def _auth_headers(self) -> dict[str, str]: + session = self._require_session() + return { + "Authorization": f"Bearer {session['accessToken']}", + "Content-Type": "application/json", + } + + def _group_code_from_session(self, session: dict[str, Any]) -> str | None: + group_code = session.get("groupCode") + if group_code: + return str(group_code) + + refresh_token = session.get("refreshToken") + if not refresh_token or refresh_token.count(".") < 2: + return None + + try: + payload = refresh_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + group_code = decoded.get("groupCode") + return str(group_code) if group_code else None + except (json.JSONDecodeError, ValueError, TypeError): + return None + + def _stored_credentials(self) -> tuple[str, str] | None: + username = (getattr(settings, "pts_username", "") or "").strip() + encrypted = getattr(settings, "pts_password", "") or "" + if not username or not encrypted: + return None + try: + password = decrypt_secret(encrypted) + except ValueError: + return None + if not password: + return None + return username, password + + async def login( + self, + username: str, + password: str, + *, + group_code: str | None = None, + ) -> dict[str, Any]: + username = username.strip() + if not username or not password: + raise ValueError("PTS username and password are required") + + errors: list[str] = [] + session: dict[str, Any] | None = None + + try: + session = await self._login_windows_ntlm(username, password) + except Exception as exc: # noqa: BLE001 + errors.append(f"Windows/NTLM: {exc}") + logger.info("PTS Windows auth failed: %s", exc) + + if session is None: + try: + session = await self._login_forms(username, password) + except Exception as exc: # noqa: BLE001 + errors.append(f"Forms: {exc}") + logger.info("PTS Forms auth failed: %s", exc) + + if session is None: + detail = "; ".join(errors) if errors else "unknown error" + raise ValueError( + "PTS login failed. Check account/password (Windows domain format " + f"DOMAIN\\user is supported). Details: {detail}" + ) + + if group_code: + session = await self.switch_group(session, group_code) + + set_pts_session(session) + logger.info("PTS login succeeded for %s", username) + return { + "ok": True, + "message": "PTS login succeeded", + "group_code": session.get("groupCode"), + "has_session": True, + } + + async def login_with_stored_credentials(self) -> dict[str, Any]: + creds = self._stored_credentials() + if not creds: + raise ValueError( + "No stored PTS credentials. Enter username/password on the web UI." + ) + username, password = creds + return await self.login(username, password) + + async def _login_windows_ntlm(self, username: str, password: str) -> dict[str, Any]: + try: + from httpx_ntlm import HttpNtlmAuth + except ImportError as exc: + raise ValueError( + "httpx-ntlm is not installed. Run: pip install httpx-ntlm" + ) from exc + + url = f"{self.base_url}/api/Login/WindowsAuthentication" + auth = HttpNtlmAuth(username, password) + async with httpx.AsyncClient(timeout=60.0, auth=auth) as client: + response = await client.get(url) + if response.status_code in {401, 403}: + raise ValueError("invalid username/password or NTLM denied") + response.raise_for_status() + data = response.json() + return _normalize_session_payload(data) + + async def _login_forms(self, username: str, password: str) -> dict[str, Any]: + url = f"{self.base_url}/api/Login/FormsAuthentication" + bodies = [ + {"userName": username, "password": password}, + {"username": username, "password": password}, + {"UserName": username, "Password": password}, + {"account": username, "password": password}, + {"id": username, "password": password}, + {"employeeId": username, "password": password}, + ] + async with httpx.AsyncClient(timeout=60.0) as client: + last_status = None + for body in bodies: + response = await client.post( + url, + headers={"Content-Type": "application/json"}, + json=body, + ) + last_status = response.status_code + if response.status_code == 404: + break + if response.status_code >= 400: + continue + try: + data = response.json() + except json.JSONDecodeError: + continue + if isinstance(data, dict) and (data.get("accessToken") or data.get("AccessToken")): + return _normalize_session_payload(data) + raise ValueError(f"FormsAuthentication unavailable (HTTP {last_status})") + + async def switch_group(self, session: dict[str, Any], group_code: str) -> dict[str, Any]: + url = f"{self.base_url}/api/Login/SwitchGroup" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + url, + headers={ + "Authorization": f"Bearer {session['accessToken']}", + "Content-Type": "application/json", + }, + json={"groupCode": group_code, "accessToken": session["accessToken"]}, + ) + response.raise_for_status() + data = response.json() + if not data: + session["groupCode"] = group_code + return session + return _normalize_session_payload(data) + + async def refresh_access_token(self) -> str: + session = self._require_session() + refresh_token = session.get("refreshToken") + group_code = self._group_code_from_session(session) + if not refresh_token or not group_code: + return await self._relogin_or_raise( + "PTS session expired and cannot refresh. Re-login with password or Extension." + ) + + url = f"{self.base_url}/api/Login/RefreshToken" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + url, + headers={"Content-Type": "application/json"}, + json={"refreshToken": refresh_token, "groupCode": group_code}, + ) + if response.status_code == 401: + return await self._relogin_or_raise( + "PTS session expired. Re-login with password or Extension." + ) + response.raise_for_status() + data = response.json() + + access_token = data.get("accessToken") + if not access_token: + raise ValueError("PTS refresh did not return accessToken") + + session["accessToken"] = access_token + session["groupCode"] = group_code + set_pts_session(session) + logger.info("PTS access token refreshed") + return access_token + + async def _relogin_or_raise(self, message: str) -> str: + if self._stored_credentials(): + await self.login_with_stored_credentials() + session = get_pts_session() or {} + token = session.get("accessToken") + if token: + return str(token) + raise ValueError(message) + + async def _request( + self, + method: str, + path: str, + *, + json_body: Any = None, + retry_on_unauthorized: bool = True, + ) -> Any: + url = f"{normalize_pts_base_url(self.base_url)}{path}" + async with httpx.AsyncClient(timeout=60.0) as client: + for attempt in range(2): + response = await client.request( + method, + url, + headers=self._auth_headers(), + json=json_body, + ) + if ( + response.status_code == 401 + and retry_on_unauthorized + and attempt == 0 + and path != "/api/Login/RefreshToken" + ): + await self.refresh_access_token() + continue + if response.status_code == 401: + raise ValueError( + "PTS session expired. Re-login with password or Extension." + ) + if response.status_code == 404 and "/PTS/" not in str(response.request.url): + raise ValueError( + "PTS API returned 404 because the URL is missing /PTS. " + f"Set PTS URL to {normalize_pts_base_url(settings.pts_url)} " + "and run: make restart" + ) from None + response.raise_for_status() + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + return response.json() + return response.text + raise RuntimeError("PTS request retry loop exhausted") + + async def get_project_options(self) -> list[dict[str, Any]]: + result = await self._request("GET", "/api/ProjectCode/GetProjectOptionsForPersonal") + return result or [] + + async def get_task_type_options(self) -> list[dict[str, Any]]: + result = await self._request("GET", "/api/TaskType/GetTaskTypeOptions") + return result or [] + + async def search_reports( + self, + begin_date: str, + end_date: str, + *, + project_code: str | None = None, + task_id: int | None = None, + ) -> list[dict[str, Any]]: + payload: dict[str, Any] = { + "beginDate": begin_date, + "endDate": end_date, + } + if project_code: + payload["projectCode"] = project_code + if task_id is not None: + payload["taskId"] = task_id + result = await self._request("POST", "/api/Report/SearchForMember", json_body=payload) + return result or [] + + async def create_report(self, payload: dict[str, Any]) -> int | str | None: + result = await self._request("POST", "/api/Report/CreateReport", json_body=payload) + if isinstance(result, dict): + return result.get("id") + return result + + async def resolve_project_code(self, project_name: str | None = None) -> str: + name = project_name or settings.pts_project_name + options = await self.get_project_options() + for opt in options: + if opt.get("name") == name or opt.get("code") == name: + return opt["code"] + lowered = name.lower() + for opt in options: + if lowered in str(opt.get("name", "")).lower(): + return opt["code"] + available = [f"{o.get('name')} ({o.get('code')})" for o in options[:20]] + raise ValueError( + f"Project '{name}' not found. Available: {', '.join(available)}" + ) + + async def resolve_task_id(self, task_name: str | None = None) -> int: + name = task_name or settings.pts_default_task_name + alias = TASK_NAME_ALIASES.get(name.lower()) + if alias: + name = alias + + options = await self.get_task_type_options() + for opt in options: + if opt.get("name") == name: + return int(opt["id"]) + lowered = name.lower() + for opt in options: + if lowered in str(opt.get("name", "")).lower(): + return int(opt["id"]) + available = [o.get("name") for o in options if not o.get("disable")] + raise ValueError( + f"Task '{task_name}' not found. Available: {', '.join(available)}" + ) + + async def verify_connection(self) -> dict[str, Any]: + projects = await self.get_project_options() + tasks = await self.get_task_type_options() + project_code = await self.resolve_project_code() + task_id = await self.resolve_task_id() + task_name = next( + (t.get("name") for t in tasks if int(t.get("id", -1)) == task_id), + str(task_id), + ) + return { + "base_url": self.base_url, + "project_count": len(projects), + "task_type_count": len(tasks), + "resolved_project": project_code, + "resolved_task_id": task_id, + "resolved_task_name": task_name, + } \ No newline at end of file diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py new file mode 100644 index 0000000..d86c030 --- /dev/null +++ b/backend/services/scheduler.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import logging +from datetime import datetime + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from config import settings +from services.fill_service import FillService + +logger = logging.getLogger(__name__) + +_scheduler: AsyncIOScheduler | None = None + + +async def _auto_fill_job() -> None: + service = FillService() + result = await service.fill_today() + logger.info("Auto fill result: %s", result.get("message") or result) + + +def start_scheduler() -> AsyncIOScheduler | None: + global _scheduler + + if not settings.auto_fill_enabled: + logger.info("Auto fill scheduler disabled") + return None + + if _scheduler is not None: + return _scheduler + + _scheduler = AsyncIOScheduler() + _scheduler.add_job( + _auto_fill_job, + CronTrigger( + hour=settings.auto_fill_hour, + minute=settings.auto_fill_minute, + ), + id="daily_pts_fill", + replace_existing=True, + ) + _scheduler.start() + logger.info( + "Scheduler started: daily fill at %02d:%02d", + settings.auto_fill_hour, + settings.auto_fill_minute, + ) + return _scheduler + + +def stop_scheduler() -> None: + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None + + +def reschedule_scheduler() -> None: + from config import reload_settings + + reload_settings() + stop_scheduler() + start_scheduler() \ No newline at end of file diff --git a/backend/services/secret_box.py b/backend/services/secret_box.py new file mode 100644 index 0000000..6cc9486 --- /dev/null +++ b/backend/services/secret_box.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken + +from paths import DATA_DIR + +KEY_FILE = DATA_DIR / ".secret_key" +ENCRYPTED_PREFIX = "enc:" + + +def _ensure_key() -> bytes: + KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + if KEY_FILE.exists(): + raw = KEY_FILE.read_bytes().strip() + if raw: + return raw + key = Fernet.generate_key() + KEY_FILE.write_bytes(key) + try: + KEY_FILE.chmod(0o600) + except OSError: + pass + return key + + +def _fernet() -> Fernet: + return Fernet(_ensure_key()) + + +def encrypt_secret(plain: str) -> str: + if not plain: + return "" + if plain.startswith(ENCRYPTED_PREFIX): + return plain + token = _fernet().encrypt(plain.encode("utf-8")).decode("ascii") + return f"{ENCRYPTED_PREFIX}{token}" + + +def decrypt_secret(value: str) -> str: + if not value: + return "" + if not value.startswith(ENCRYPTED_PREFIX): + return value + token = value[len(ENCRYPTED_PREFIX) :].encode("ascii") + try: + return _fernet().decrypt(token).decode("utf-8") + except InvalidToken as exc: + raise ValueError( + "Could not decrypt stored password. Secret key may have changed; re-enter password." + ) from exc + + +def is_encrypted(value: str) -> bool: + return bool(value) and value.startswith(ENCRYPTED_PREFIX) diff --git a/backend/services/settings_api.py b/backend/services/settings_api.py new file mode 100644 index 0000000..7296cbf --- /dev/null +++ b/backend/services/settings_api.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class SettingsUpdatePayload(BaseModel): + gitlab_url: Optional[str] = None + gitlab_token: Optional[str] = None + gitlab_project_path: Optional[str] = None + gitlab_board_id: Optional[int] = None + gitlab_board_labels: Optional[str] = None + gitlab_milestone_title: Optional[str] = None + + pts_url: Optional[str] = None + pts_project_name: Optional[str] = None + pts_default_task_name: Optional[str] = None + pts_username: Optional[str] = None + pts_password: Optional[str] = None + + auto_fill_enabled: Optional[bool] = None + auto_fill_hour: Optional[int] = Field(default=None, ge=0, le=23) + auto_fill_minute: Optional[int] = Field(default=None, ge=0, le=59) + + max_hours_per_day: Optional[float] = Field(default=None, gt=0, le=24) + hour_step: Optional[float] = Field(default=None, gt=0, le=8) + + xai_api_key: Optional[str] = None + xai_base_url: Optional[str] = None + grok_model: Optional[str] = None + use_llm: Optional[bool] = None + use_description_variation: Optional[bool] = None + + teams_calendar_enabled: Optional[bool] = None + teams_username: Optional[str] = None + teams_password: Optional[str] = None + teams_calendar_timezone: Optional[str] = None + teams_meeting_task_name: Optional[str] = None + teams_headless: Optional[bool] = None + teams_meeting_exclude_keywords: Optional[str] = None + azure_client_id: Optional[str] = None + azure_tenant_id: Optional[str] = None + + compbase_list_url: Optional[str] = None + compbase_fill_url: Optional[str] = None + compbase_username: Optional[str] = None + compbase_password: Optional[str] = None + compbase_default_days: Optional[int] = Field(default=None, ge=1, le=62) + compbase_out_time_mode: Optional[str] = None + + def to_update_dict(self) -> dict[str, Any]: + return {k: v for k, v in self.model_dump().items() if v is not None} + + +class DatesFilePayload(BaseModel): + content: str = "" \ No newline at end of file diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py new file mode 100644 index 0000000..99d9d26 --- /dev/null +++ b/backend/services/settings_service.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from config import CONFIGURABLE_FIELDS, reload_settings +from paths import get_dates_file_path +from services.pts_client import normalize_pts_base_url +from services.secret_box import encrypt_secret +from services.settings_store import ( + PASSWORD_FIELDS, + is_masked_or_empty, + load_overrides, + public_view, + save_overrides, + SECRET_FIELDS, +) +from config import get_settings_dict + + +class SettingsService: + def get_public_settings(self) -> dict[str, Any]: + return public_view(get_settings_dict()) + + def save_settings(self, payload: dict[str, Any]) -> dict[str, Any]: + overrides = load_overrides() + updates = {k: payload[k] for k in CONFIGURABLE_FIELDS if k in payload} + + for field in SECRET_FIELDS: + if field in updates and is_masked_or_empty(updates[field]): + updates.pop(field) + elif field in updates and updates[field] is not None: + # strip whitespace / accidental quotes from pasted tokens + updates[field] = str(updates[field]).strip().strip("\"'") + + for field in PASSWORD_FIELDS: + if field in updates and updates[field]: + updates[field] = encrypt_secret(str(updates[field])) + + if "pts_url" in updates and updates["pts_url"]: + updates["pts_url"] = normalize_pts_base_url(str(updates["pts_url"])) + + overrides.update(updates) + save_overrides(overrides) + + from services.scheduler import reschedule_scheduler + + reload_settings() + reschedule_scheduler() + return self.get_public_settings() + + def get_dates_file(self) -> str: + path = get_dates_file_path() + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + def save_dates_file(self, content: str) -> None: + path = get_dates_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") \ No newline at end of file diff --git a/backend/services/settings_store.py b/backend/services/settings_store.py new file mode 100644 index 0000000..357bc28 --- /dev/null +++ b/backend/services/settings_store.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from paths import DATA_DIR + +SETTINGS_FILE = DATA_DIR / "settings.json" +SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) +SECRET_FIELDS = { + "gitlab_token", + "xai_api_key", + "pts_password", + "teams_password", + "compbase_password", +} +PASSWORD_FIELDS = {"pts_password", "teams_password", "compbase_password"} +MASK_PLACEHOLDER = "********" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def load_overrides() -> dict[str, Any]: + if not SETTINGS_FILE.exists(): + return {} + try: + payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + return payload.get("values", payload) if isinstance(payload, dict) else {} + except (json.JSONDecodeError, OSError): + return {} + + +def save_overrides(values: dict[str, Any]) -> None: + SETTINGS_FILE.write_text( + json.dumps( + {"updated_at": _now_iso(), "values": values}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + +def mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 10: + return MASK_PLACEHOLDER + return f"{value[:6]}...{value[-4:]}" + + +def is_masked_or_empty(value: Any) -> bool: + if value is None: + return True + text = str(value).strip() + if not text: + return True + if text == MASK_PLACEHOLDER: + return True + if "..." in text and len(text) < 30: + return True + return False + + +def merge_secret_updates( + current: dict[str, Any], + updates: dict[str, Any], + existing_secrets: dict[str, str], +) -> dict[str, Any]: + merged = {**current, **updates} + for field in SECRET_FIELDS: + if field in updates and is_masked_or_empty(updates[field]): + if existing_secrets.get(field): + merged[field] = existing_secrets[field] + else: + merged.pop(field, None) + return merged + + +def public_view(settings: dict[str, Any]) -> dict[str, Any]: + public = dict(settings) + for field in SECRET_FIELDS: + raw = public.get(field, "") + public[f"{field}_set"] = bool(raw) + public[field] = mask_secret(raw) if raw else "" + public["gitlab_board_label_list"] = [ + part.strip() + for part in str(public.get("gitlab_board_labels", "")).split(",") + if part.strip() + ] + public["auto_fill_time"] = ( + f"{int(public.get('auto_fill_hour', 18)):02d}:" + f"{int(public.get('auto_fill_minute', 0)):02d}" + ) + return public \ No newline at end of file diff --git a/backend/services/state_store.py b/backend/services/state_store.py new file mode 100644 index 0000000..6def692 --- /dev/null +++ b/backend/services/state_store.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from paths import DATA_DIR + +STATE_FILE = DATA_DIR / "state.json" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def load_state() -> dict[str, Any]: + if not STATE_FILE.exists(): + return {} + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def save_state(state: dict[str, Any]) -> None: + STATE_FILE.write_text( + json.dumps(state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def get_pts_session() -> dict[str, Any] | None: + state = load_state() + session = state.get("pts_session") + return session if isinstance(session, dict) else None + + +def set_pts_session(session: dict[str, Any]) -> None: + state = load_state() + state["pts_session"] = session + state["pts_session_updated_at"] = _now_iso() + save_state(state) + + +def clear_pts_session() -> None: + state = load_state() + state.pop("pts_session", None) + state.pop("pts_session_updated_at", None) + save_state(state) + + +def get_teams_browser_meta() -> dict[str, Any]: + state = load_state() + meta = state.get("teams_browser") + return meta if isinstance(meta, dict) else {} + + +def set_teams_browser_meta(meta: dict[str, Any]) -> None: + state = load_state() + state["teams_browser"] = meta + state["teams_browser_updated_at"] = _now_iso() + save_state(state) + + +def clear_teams_browser_meta() -> None: + state = load_state() + state.pop("teams_browser", None) + state.pop("teams_browser_updated_at", None) + save_state(state) + + +def get_last_entries() -> list[dict[str, Any]]: + state = load_state() + entries = state.get("last_entries") + return entries if isinstance(entries, list) else [] + + +def set_last_entries(entries: list[dict[str, Any]]) -> None: + state = load_state() + state["last_entries"] = entries + state["last_entries_updated_at"] = _now_iso() + save_state(state) + + +def get_filled_dates() -> set[str]: + state = load_state() + dates = state.get("filled_dates") + if not isinstance(dates, list): + return set() + return set(dates) + + +def mark_dates_filled(dates: list[str]) -> None: + state = load_state() + existing = set(state.get("filled_dates") or []) + existing.update(dates) + state["filled_dates"] = sorted(existing) + save_state(state) + + +def get_graph_session() -> dict[str, Any] | None: + state = load_state() + session = state.get("graph_session") + return session if isinstance(session, dict) else None + + +def set_graph_session(session: dict[str, Any]) -> None: + state = load_state() + state["graph_session"] = session + state["graph_session_updated_at"] = _now_iso() + save_state(state) + + +def clear_graph_session() -> None: + state = load_state() + state.pop("graph_session", None) + state.pop("graph_session_updated_at", None) + save_state(state) + + +def get_graph_device_auth() -> dict[str, Any] | None: + state = load_state() + pending = state.get("graph_device_auth") + return pending if isinstance(pending, dict) else None + + +def set_graph_device_auth(payload: dict[str, Any]) -> None: + state = load_state() + state["graph_device_auth"] = payload + save_state(state) + + +def clear_graph_device_auth() -> None: + state = load_state() + state.pop("graph_device_auth", None) + save_state(state) \ No newline at end of file diff --git a/backend/services/teams_calendar_scraper.py b/backend/services/teams_calendar_scraper.py new file mode 100644 index 0000000..946d144 --- /dev/null +++ b/backend/services/teams_calendar_scraper.py @@ -0,0 +1,1436 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from datetime import date, datetime, time, timedelta, timezone +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import httpx + +from config import settings +from paths import DATA_DIR +from services.calendar_models import CalendarMeeting, format_meeting_description +from services.secret_box import decrypt_secret +from services.state_store import ( + clear_graph_session, + clear_teams_browser_meta, + get_graph_session, + get_teams_browser_meta, + set_graph_session, + set_teams_browser_meta, +) + +logger = logging.getLogger(__name__) + +BROWSER_STATE_FILE = DATA_DIR / "teams_browser_state.json" +MFA_PREVIEW_FILE = DATA_DIR / "teams_mfa_preview.png" +OUTLOOK_CALENDAR_URL = "https://outlook.office.com/calendar/view/workweek" +LOGIN_TIMEOUT_MS = 5 * 60 * 1000 +# Number-match / Authenticator MFA can be slow; keep browser open for interaction +MFA_WAIT_MS = 10 * 60 * 1000 +# Public Microsoft Office client — used only for optional ROPC (password grant). +# Tenants with MFA / CA usually reject this; then we fall back to browser login. +DEFAULT_PUBLIC_CLIENT_ID = "d3590ed6-52b3-4102-aeff-aad2292ab01c" +GRAPH_SCOPES = "offline_access Calendars.Read User.Read openid profile" + +# Extra Chromium flags for containers (no X11 / small /dev/shm) +_DOCKER_CHROME_ARGS = [ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", +] + + +def _running_in_docker() -> bool: + if os.environ.get("PTS_IN_DOCKER", "").strip().lower() in {"1", "true", "yes"}: + return True + if Path("/.dockerenv").exists(): + return True + try: + cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="ignore") + if "docker" in cgroup or "containerd" in cgroup or "kubepods" in cgroup: + return True + except OSError: + pass + return False + + +def _display_available() -> bool: + return bool(os.environ.get("DISPLAY", "").strip()) + + +def _must_run_headless() -> bool: + """True only when a browser window cannot be shown at all. + + Docker with DISPLAY (Xvfb + noVNC) → headed OK, user interacts via :6080. + Linux without DISPLAY/WAYLAND → headless only. + macOS / Windows → headed works without DISPLAY. + """ + if _display_available() or os.environ.get("WAYLAND_DISPLAY", "").strip(): + return False + if _running_in_docker(): + return True + if sys.platform.startswith("linux"): + return True + return False + + +def resolve_playwright_headless(requested: bool | None = None) -> bool: + """Decide Playwright headless mode. + + Preference order: + 1. No display possible → always headless + 2. Explicit argument (login sends headless=false for MFA) + 3. settings.teams_headless + """ + if _must_run_headless(): + if requested is False: + logger.warning( + "Headed Playwright requested but no DISPLAY " + "(Docker without Xvfb?) — forcing headless=True." + ) + return True + if requested is not None: + return bool(requested) + return bool(settings.teams_headless) + + +def playwright_launch_args() -> list[str]: + # Always use sandbox-friendly flags in Docker (headed on Xvfb still needs them) + if _running_in_docker() or _must_run_headless(): + return list(_DOCKER_CHROME_ARGS) + return ["--disable-blink-features=AutomationControlled"] + + +def browser_view_info() -> dict[str, Any]: + """How the user can see / interact with the Teams login browser.""" + vnc_port = int(os.environ.get("TEAMS_VNC_PORT", "6080") or "6080") + has_display = _display_available() + in_docker = _running_in_docker() + interactive = in_docker and has_display + return { + "in_docker": in_docker, + "display": os.environ.get("DISPLAY") or None, + "interactive": interactive, + "vnc_port": vnc_port, + "mfa_preview": MFA_PREVIEW_FILE.exists(), + "message": ( + "Docker:登入時會開啟虛擬 Chrome,請用互動畫面完成 MFA(可點、可輸入)。" + if interactive + else ( + "本機:請在跳出的 Chromium 視窗完成 MFA。" + if not in_docker + else "Docker 但無 DISPLAY:無法顯示驗證畫面,請確認容器已啟動 Xvfb。" + ) + ), + } + + + +class TeamsCalendarScraper: + """Calendar via username/password: Graph API first, browser session as fallback.""" + + def __init__(self) -> None: + self.timezone = ZoneInfo(settings.teams_calendar_timezone) + + @property + def enabled(self) -> bool: + return bool(settings.teams_calendar_enabled) + + @property + def has_credentials(self) -> bool: + return bool((settings.teams_username or "").strip() and (settings.teams_password or "").strip()) + + @property + def has_browser_state(self) -> bool: + return BROWSER_STATE_FILE.exists() and BROWSER_STATE_FILE.stat().st_size > 10 + + @property + def has_graph_token(self) -> bool: + """Unused for fetch path (crawl-only). Kept for status diagnostics.""" + session = get_graph_session() or {} + return bool(session.get("access_token") and session.get("refresh_token")) + + def status(self) -> dict[str, Any]: + meta = get_teams_browser_meta() + mode = meta.get("auth_mode") or ("browser_session" if self.has_browser_state else None) + last_error = str(meta.get("last_error") or "") + # Only treat as stale for real session-expiry signals (not old Graph errors) + stale = bool( + last_error + and any( + k in last_error.lower() + for k in ("session expired", "re-login", "browser session expired", "session 過期") + ) + ) + # Crawl mode: connected = browser cookies present + connected = self.has_browser_state and not stale + return { + "enabled": self.enabled, + "configured": self.has_credentials or self.has_browser_state, + "has_credentials": self.has_credentials, + "connected": connected, + "username": (settings.teams_username or "").strip() or None, + "last_login_at": meta.get("last_login_at"), + "last_error": meta.get("last_error"), + "message": meta.get("message") or "crawl-only (no Graph API)", + "auth_mode": mode, + "source": "teams_browser_crawl", + } + + def format_meeting_description(self, meeting: CalendarMeeting) -> str: + return format_meeting_description(meeting) + + def _round_hours(self, hours: float) -> float: + step = settings.hour_step + if hours <= 0: + return 0.0 + units = max(1, int(round(hours / step))) + return round(units * step, 1) + + def _event_hours(self, start: datetime, end: datetime) -> float: + duration = (end - start).total_seconds() / 3600 + return self._round_hours(duration) + + def _parse_time(self, value: str | datetime) -> datetime: + if isinstance(value, datetime): + dt = value + else: + cleaned = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(cleaned) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + def _day_window(self, day: date) -> tuple[datetime, datetime]: + start = datetime.combine(day, time.min, tzinfo=self.timezone) + end = start + timedelta(days=1) + return start, end + + def _credentials(self) -> tuple[str, str]: + username = (settings.teams_username or "").strip() + password = decrypt_secret(settings.teams_password or "") + if not username or not password: + raise ValueError("Teams username/password not set. Login on the web UI first.") + return username, password + + async def login( + self, + username: str | None = None, + password: str | None = None, + *, + headless: bool | None = None, + ) -> dict[str, Any]: + user = (username or settings.teams_username or "").strip() + pwd = password if password is not None else decrypt_secret(settings.teams_password or "") + if not user or not pwd: + raise ValueError("Teams username and password are required") + + # Crawl-only: browser login + save cookies (no Graph API / ROPC) + clear_graph_session() + use_headless = resolve_playwright_headless(headless) + try: + from playwright.async_api import async_playwright + except ImportError as exc: + raise ValueError( + "playwright is not installed. Run: pip install playwright && playwright install chromium" + ) from exc + + view = browser_view_info() + set_teams_browser_meta( + { + "message": ( + f"正在登入 Outlook(headless={use_headless})。" + + ( + f" 請開啟 noVNC 埠 {view['vnc_port']} 完成 MFA;瀏覽器會保持開啟最多 {MFA_WAIT_MS // 60000} 分鐘。" + if not use_headless and _running_in_docker() + else " 請在跳出的視窗完成 MFA。" + if not use_headless + else " 無畫面模式:若卡住請確認 Docker 已啟用 Xvfb/noVNC。" + ) + ), + "last_error": None, + "mfa_interactive": not use_headless, + "vnc_port": view.get("vnc_port"), + } + ) + + probe_count = 0 + async with async_playwright() as p: + launch_kwargs: dict[str, Any] = { + "headless": use_headless, + "channel": "chromium", + "args": playwright_launch_args(), + } + if _running_in_docker(): + launch_kwargs["env"] = { + **os.environ, + "DISPLAY": os.environ.get("DISPLAY", ":99"), + } + browser = await p.chromium.launch(**launch_kwargs) + context = await browser.new_context( + user_agent=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" + ), + locale="zh-TW", + timezone_id=settings.teams_calendar_timezone, + viewport={"width": 1280, "height": 800}, + ) + page = await context.new_page() + try: + await page.goto("https://login.microsoftonline.com/", wait_until="domcontentloaded") + await self._snapshot_mfa(page, "login_start") + await self._fill_microsoft_login(page, user, pwd) + await page.wait_for_timeout(1500) + await self._snapshot_mfa(page, "after_password") + # If MFA / number-match is already on screen, do NOT navigate away + # (would refresh the number). Only open Outlook when not mid-identity. + if not self._on_identity_page(page.url): + try: + await page.goto( + OUTLOOK_CALENDAR_URL, + wait_until="domcontentloaded", + timeout=LOGIN_TIMEOUT_MS, + ) + except Exception as nav_exc: # noqa: BLE001 + logger.info("Outlook nav after login (MFA may be active): %s", nav_exc) + else: + logger.info("Identity/MFA page active — keeping page open for user: %s", page.url) + await self._wait_for_outlook_ready(page) + # Wait for calendar UI + cookies to settle (browser still open) + await page.wait_for_timeout(5000) + try: + await page.wait_for_load_state("networkidle", timeout=20000) + except Exception: # noqa: BLE001 + pass + + # Verify we are not still on login + if "login.microsoftonline.com" in page.url.lower(): + await self._snapshot_mfa(page, "still_login") + raise ValueError( + "仍在 Microsoft 登入頁 — 請在互動畫面完成 MFA/密碼後再等一下。" + ) + + # Probe calendar fetch while session is live (same browser) + try: + today = datetime.now(self.timezone).date() + probe_start, _ = self._day_window(today) + _, probe_end = self._day_window(today + timedelta(days=7)) + probe_events = await self._page_fetch_calendar_events(page, probe_start, probe_end) + probe_count = len(probe_events) + logger.info("Login probe fetched %s events in live session", probe_count) + except Exception as probe_exc: # noqa: BLE001 + logger.warning("Login probe fetch failed (session still saved): %s", probe_exc) + + BROWSER_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + await context.storage_state(path=str(BROWSER_STATE_FILE)) + await self._snapshot_mfa(page, "success") + except Exception as exc: # noqa: BLE001 + try: + await self._snapshot_mfa(page, "error") + except Exception: # noqa: BLE001 + pass + set_teams_browser_meta( + { + "last_error": str(exc), + "message": ( + "Teams 登入失敗。請在 noVNC 互動畫面完成 MFA 後再試。" + if _running_in_docker() + else "Teams 登入失敗。請在瀏覽器視窗完成 MFA 後再試。" + ), + } + ) + raise ValueError(f"Teams login failed: {exc}") from exc + finally: + # Close only after success or hard failure — not mid-MFA + await browser.close() + + set_teams_browser_meta( + { + "last_login_at": datetime.now(timezone.utc).isoformat(), + "last_error": None, + "message": ( + f"Browser crawl session saved. Login probe: {probe_count} events." + if probe_count + else "Browser crawl session saved. If test is 0, re-login and complete MFA fully." + ), + "username": user, + "auth_mode": "browser_crawl", + "login_probe_events": probe_count, + } + ) + return { + "ok": True, + "message": ( + f"Teams 瀏覽器登入成功(爬蟲)。即時探測到 {probe_count} 筆會議。" + if probe_count + else "Teams 瀏覽器登入完成。若測試仍 0 筆,請再登入一次並完成 MFA。" + ), + "connected": True, + "auth_mode": "browser_crawl", + "login_probe_events": probe_count, + } + + async def logout(self) -> None: + if BROWSER_STATE_FILE.exists(): + BROWSER_STATE_FILE.unlink() + clear_graph_session() + clear_teams_browser_meta() + + def _token_client_id(self) -> str: + return (settings.azure_client_id or "").strip() or DEFAULT_PUBLIC_CLIENT_ID + + def _token_tenant(self) -> str: + return (settings.azure_tenant_id or "").strip() or "organizations" + + async def _login_graph_ropc(self, username: str, password: str) -> dict[str, Any]: + """Resource Owner Password Credentials — direct API, no browser.""" + url = f"https://login.microsoftonline.com/{self._token_tenant()}/oauth2/v2.0/token" + payload = { + "client_id": self._token_client_id(), + "scope": GRAPH_SCOPES, + "username": username, + "password": password, + "grant_type": "password", + } + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, data=payload) + data = response.json() if response.content else {} + if response.status_code >= 400: + err = data.get("error_description") or data.get("error") or response.text + raise ValueError(str(err)[:300]) + access = data.get("access_token") + if not access: + raise ValueError("Graph token response missing access_token") + set_graph_session( + { + "access_token": access, + "refresh_token": data.get("refresh_token"), + "scope": data.get("scope"), + "expires_in": data.get("expires_in"), + "token_type": data.get("token_type"), + "auth_mode": "ropc", + } + ) + return data + + async def _refresh_graph_token(self) -> str: + session = get_graph_session() or {} + refresh = session.get("refresh_token") + if not refresh: + raise ValueError("No Graph refresh_token") + url = f"https://login.microsoftonline.com/{self._token_tenant()}/oauth2/v2.0/token" + payload = { + "client_id": self._token_client_id(), + "scope": GRAPH_SCOPES, + "refresh_token": refresh, + "grant_type": "refresh_token", + } + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, data=payload) + data = response.json() if response.content else {} + if response.status_code >= 400: + raise ValueError(data.get("error_description") or data.get("error") or "refresh failed") + access = data.get("access_token") + if not access: + raise ValueError("refresh missing access_token") + session["access_token"] = access + if data.get("refresh_token"): + session["refresh_token"] = data["refresh_token"] + set_graph_session(session) + return access + + async def _graph_access_token(self) -> str: + session = get_graph_session() or {} + token = session.get("access_token") + if token: + return str(token) + return await self._refresh_graph_token() + + @staticmethod + def _on_identity_page(url: str) -> bool: + u = (url or "").lower() + return any( + host in u + for host in ( + "login.microsoftonline.com", + "login.live.com", + "login.microsoft.com", + "device.login.microsoftonline.com", + "aadcdn.msauth.net", + "aadcdn.msftauth.net", + ) + ) + + async def _snapshot_mfa(self, page: Any, label: str = "") -> None: + """Write latest login page screenshot for UI polling (Docker MFA).""" + try: + MFA_PREVIEW_FILE.parent.mkdir(parents=True, exist_ok=True) + await page.screenshot(path=str(MFA_PREVIEW_FILE), full_page=False, type="png") + if label: + set_teams_browser_meta( + { + **get_teams_browser_meta(), + "mfa_preview_label": label, + "mfa_preview_at": datetime.now(timezone.utc).isoformat(), + "mfa_page_url": page.url, + } + ) + except Exception as exc: # noqa: BLE001 + logger.debug("MFA snapshot failed (%s): %s", label, exc) + + async def _fill_microsoft_login(self, page: Any, username: str, password: str) -> None: + # Email step + email = page.locator('input[type="email"], input[name="loginfmt"]') + await email.first.wait_for(state="visible", timeout=30000) + await email.first.fill(username) + await self._snapshot_mfa(page, "email_filled") + await page.locator('input[type="submit"], input[data-report-event="Signin_Submit"]').first.click() + + # Password step (may be skipped if SSO/MFA device) + try: + pwd = page.locator('input[type="password"], input[name="passwd"]') + await pwd.first.wait_for(state="visible", timeout=15000) + await pwd.first.fill(password) + await self._snapshot_mfa(page, "password_filled") + await page.locator('input[type="submit"], input[data-report-event="Signin_Submit"]').first.click() + except Exception: # noqa: BLE001 + logger.info("Password field not shown (SSO/MFA path)") + + await self._snapshot_mfa(page, "after_credentials") + # Stay signed in? (may appear after MFA — also handled in wait loop) + try: + stay = page.locator('#idSIButton9, input[value="Yes"], input[value="是"]') + await stay.first.wait_for(state="visible", timeout=8000) + await stay.first.click() + except Exception: # noqa: BLE001 + pass + + async def _wait_for_outlook_ready(self, page: Any) -> None: + """Keep Chromium open while user completes MFA in noVNC / local window.""" + deadline = asyncio.get_event_loop().time() + MFA_WAIT_MS / 1000 + last_snap = 0.0 + while asyncio.get_event_loop().time() < deadline: + url = page.url.lower() + now = asyncio.get_event_loop().time() + # Refresh screenshot ~every 1.5s so UI shows the number / OTP page + if now - last_snap >= 1.5: + await self._snapshot_mfa(page, "mfa_wait") + last_snap = now + remaining = int(deadline - now) + set_teams_browser_meta( + { + **get_teams_browser_meta(), + "message": ( + f"等待 MFA/進到 Outlook… 剩餘約 {remaining} 秒。" + " 請在互動畫面操作,不要關閉。" + ), + "mfa_page_url": page.url, + } + ) + + if "outlook.office.com" in url and "login" not in url and "login.microsoftonline" not in url: + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: # noqa: BLE001 + pass + return + + # "Stay signed in?" after MFA + try: + stay = page.locator('#idSIButton9, input[value="Yes"], input[value="是"]') + if await stay.first.is_visible(): + await stay.first.click() + await self._snapshot_mfa(page, "stay_signed_in") + except Exception: # noqa: BLE001 + pass + + # MFA finished but still not on Outlook — open calendar once + if not self._on_identity_page(page.url) and "outlook.office.com" not in url: + try: + await page.goto( + OUTLOOK_CALENDAR_URL, + wait_until="domcontentloaded", + timeout=60000, + ) + await self._snapshot_mfa(page, "nav_outlook") + except Exception as nav_exc: # noqa: BLE001 + logger.debug("Deferred Outlook nav: %s", nav_exc) + + await asyncio.sleep(0.8) + await self._snapshot_mfa(page, "timeout") + raise TimeoutError( + f"等待 Outlook 逾時({MFA_WAIT_MS // 60000} 分鐘)。" + "請在 noVNC 互動畫面完成 MFA 後再按一次登入。" + ) + + async def fetch_meetings_for_days(self, days: list[date]) -> dict[str, list[CalendarMeeting]]: + if not days: + return {} + if not self.enabled: + return {d.isoformat(): [] for d in days} + + # Crawl-only path — never call Graph API (no refresh_token / MFA issues) + clear_graph_session() + + if not self.has_browser_state: + if self.has_credentials: + # Docker has no X server — never force headed mode + await self.login(headless=resolve_playwright_headless(None)) + else: + raise ValueError("Teams 未登入。請到設定頁登入 Teams(瀏覽器 / MFA)。") + + sorted_days = sorted(set(days)) + range_start, _ = self._day_window(sorted_days[0]) + _, range_end = self._day_window(sorted_days[-1]) + + errors: list[str] = [] + raw_events: list[dict[str, Any]] = [] + session_expired = False + + try: + raw_events = await self._scrape_calendar_events(range_start, range_end) + logger.info("Browser crawl returned %s raw events", len(raw_events)) + except Exception as first_exc: # noqa: BLE001 + msg = f"Browser crawl: {first_exc}" + errors.append(msg) + logger.warning("Teams crawl failed: %s", first_exc) + err_l = str(first_exc).lower() + session_expired = "expired" in err_l or "login" in err_l or "re-login" in err_l + if session_expired and BROWSER_STATE_FILE.exists(): + try: + BROWSER_STATE_FILE.unlink() + logger.info("Cleared expired Teams browser state") + except OSError: + pass + + # Do NOT auto re-login during fetch (MFA needs human). Ask user to login in Settings. + if not raw_events and (session_expired or not self.has_browser_state): + raise ValueError( + "Teams 網頁 session 無效或過期。請到設定頁重新「登入 Teams」並完成 MFA," + "再按「測試抓會議」。" + + (" 詳情: " + " | ".join(errors) if errors else "") + ) + + meetings_by_day: dict[str, list[CalendarMeeting]] = {d.isoformat(): [] for d in sorted_days} + allowed = set(meetings_by_day.keys()) + seen_meetings: set[tuple[str, str, str]] = set() + + for item in raw_events: + try: + start = self._parse_time(item["start"]) + end = self._parse_time(item["end"]) + except Exception: # noqa: BLE001 + continue + if end <= start: + continue + if item.get("is_cancelled"): + continue + show_as = str(item.get("show_as") or "").lower() + # free / workingelsewhere often still real meetings in some tenants — only drop free + if show_as == "free": + continue + + local_day = start.astimezone(self.timezone).date().isoformat() + if local_day not in allowed: + continue + hours = self._event_hours(start, end) + if hours <= 0: + continue + subject = str(item.get("subject") or "Meeting").strip() + dedupe_key = ( + subject.lower(), + start.astimezone(timezone.utc).isoformat(), + end.astimezone(timezone.utc).isoformat(), + ) + if dedupe_key in seen_meetings: + continue + seen_meetings.add(dedupe_key) + meetings_by_day[local_day].append( + CalendarMeeting( + subject=subject, + start=start, + end=end, + hours=hours, + is_online=bool(item.get("is_online")), + ) + ) + + if not any(meetings_by_day.values()) and errors: + set_teams_browser_meta( + { + **get_teams_browser_meta(), + "last_error": " | ".join(errors)[:500], + "message": "會議同步回傳 0 筆,可能是 session 過期或 token 無效", + } + ) + return meetings_by_day + + async def list_meetings( + self, + start: date, + end: date, + *, + skip_holidays: bool = True, + debug: bool = False, + ) -> dict[str, Any]: + """Public payload for the web UI: meetings by day + totals. + + When skip_holidays=True (default), only Taiwan workdays are included + (weekends + TW public holidays skipped), matching PTS fill range behavior. + """ + from services.holidays_util import is_workday, iter_workdays + + if end < start: + start, end = end, start + + if skip_holidays: + days = iter_workdays(start, end) + calendar_span = (end - start).days + 1 + skipped_days = max(0, calendar_span - len(days)) + else: + days = [] + cursor = start + while cursor <= end: + days.append(cursor) + cursor += timedelta(days=1) + skipped_days = 0 + + debug_info: dict[str, Any] = { + "mode": "browser_crawl_only", + "graph_api": "disabled", + "has_browser_state": self.has_browser_state, + "has_credentials": self.has_credentials, + "enabled": self.enabled, + "day_count": len(days), + "days": [d.isoformat() for d in days], + "steps": [], + } + + meetings_by_day: dict[str, list[CalendarMeeting]] = {} + fetch_error: str | None = None + try: + if days: + meetings_by_day = await self.fetch_meetings_for_days(days) + debug_info["steps"].append("fetch_meetings_for_days: ok") + else: + debug_info["steps"].append("no days after holiday filter") + except Exception as exc: # noqa: BLE001 + fetch_error = str(exc) + debug_info["steps"].append(f"fetch_meetings_for_days: ERROR {exc}") + logger.exception("list_meetings fetch failed") + + days_out: list[dict[str, Any]] = [] + total_hours = 0.0 + total_count = 0 + + for day in days: + key = day.isoformat() + items = meetings_by_day.get(key, []) + day_hours = round(sum(m.hours for m in items), 1) + total_hours += day_hours + total_count += len(items) + days_out.append( + { + "date": key, + "hours": day_hours, + "count": len(items), + "is_workday": is_workday(day), + "meetings": [ + { + "subject": m.subject, + "start": m.start.astimezone(self.timezone).isoformat(), + "end": m.end.astimezone(self.timezone).isoformat(), + "hours": m.hours, + "is_online": m.is_online, + "time_label": ( + f"{m.start.astimezone(self.timezone).strftime('%H:%M')}" + f"–{m.end.astimezone(self.timezone).strftime('%H:%M')}" + ), + } + for m in items + ], + } + ) + + meta = get_teams_browser_meta() + payload: dict[str, Any] = { + "ok": fetch_error is None, + "enabled": self.enabled, + "connected": self.status().get("connected"), + "timezone": settings.teams_calendar_timezone, + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "skip_holidays": skip_holidays, + "workday_count": len(days), + "skipped_days": skipped_days, + "total_hours": round(total_hours, 1), + "total_count": total_count, + "days": days_out, + "synced_at": datetime.now(timezone.utc).isoformat(), + "error": fetch_error, + "status": self.status(), + "fetch_hint": ( + None + if total_count + else ( + fetch_error + or meta.get("last_error") + or meta.get("message") + or "若一直是 0 筆:請先「登入 Teams」完成 MFA,再按測試。" + ) + ), + } + if debug: + payload["debug"] = debug_info + return payload + + async def _scrape_calendar_events( + self, + range_start: datetime, + range_end: datetime, + ) -> list[dict[str, Any]]: + try: + from playwright.async_api import async_playwright + except ImportError as exc: + raise ValueError("playwright is not installed") from exc + + if not self.has_browser_state: + raise ValueError("No Teams browser session — please login on Settings page") + + collected: list[dict[str, Any]] = [] + graph_payloads: list[dict[str, Any]] = [] + captured_bearers: list[str] = [] + capture_errors: list[str] = [] + seen_urls: list[str] = [] + + async with async_playwright() as p: + # channel="chromium" forces Chrome's "new" headless mode (same full + # browser binary as headed, just no window) instead of Playwright's + # lightweight headless-shell. The shell's fingerprint (GPU/canvas, + # navigator.plugins, missing APIs, etc.) diverges enough from a real + # headed browser that Azure AD's sign-in risk detection was bouncing + # our cookie-reuse requests back to the login page — which we then + # (wrongly) treated as an expired session, deleting the saved cookies + # and forcing a fresh interactive MFA login on every single fetch. + browser = await p.chromium.launch( + headless=resolve_playwright_headless(True), + channel="chromium", + args=playwright_launch_args(), + ) + context = await browser.new_context( + storage_state=str(BROWSER_STATE_FILE), + user_agent=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" + ), + locale="zh-TW", + timezone_id=settings.teams_calendar_timezone, + ) + page = await context.new_page() + + def on_request(request: Any) -> None: + try: + auth = (request.headers or {}).get("authorization") or "" + if auth.lower().startswith("bearer ") and len(auth) > 40: + token = auth.split(" ", 1)[1].strip() + if token not in captured_bearers: + captured_bearers.append(token) + except Exception: # noqa: BLE001 + return + + async def on_response(response: Any) -> None: + try: + if response.status != 200: + return + url = response.url + url_l = url.lower() + if len(seen_urls) < 80: + seen_urls.append(url[:180]) + interesting = any( + key in url_l + for key in ( + "calendarview", + "calendar/getschedule", + "calendarservice", + "/me/events", + "finditem", + "getcalendarview", + "service.svc", + "outlook.office.com/api/", + "graph.microsoft.com", + "substrate.office.com", + "calendar", + ) + ) + if not interesting: + return + ctype = (response.headers.get("content-type") or "").lower() + if "json" not in ctype and "javascript" not in ctype and "text" not in ctype: + return + try: + data = await response.json() + except Exception: # noqa: BLE001 + return + if isinstance(data, dict): + graph_payloads.append(data) + elif isinstance(data, list): + graph_payloads.append({"value": data}) + except Exception: # noqa: BLE001 + return + + page.on("request", on_request) + page.on("response", on_response) + + try: + # Load calendar SPA + date_param = range_start.date().isoformat() + urls_to_try = [ + f"https://outlook.office.com/calendar/view/week?startdt={date_param}", + OUTLOOK_CALENDAR_URL, + "https://outlook.office.com/calendar/view/month", + "https://outlook.office.com/mail/", + ] + landed = False + for cal_url in urls_to_try: + try: + await page.goto(cal_url, wait_until="domcontentloaded", timeout=90000) + await page.wait_for_timeout(3000) + url_now = page.url.lower() + if "login.microsoftonline.com" in url_now or "login.live.com" in url_now: + if BROWSER_STATE_FILE.exists(): + try: + BROWSER_STATE_FILE.unlink() + except OSError: + pass + set_teams_browser_meta( + { + **get_teams_browser_meta(), + "last_error": "session expired", + "message": "Browser session expired. Re-login Teams in Settings (complete MFA).", + "auth_mode": "browser_crawl", + } + ) + raise ValueError( + "Teams browser session expired — open Settings → login Teams (complete MFA)." + ) + landed = True + if "calendar" in url_now or "outlook.office.com" in url_now: + break + except ValueError: + raise + except Exception as nav_exc: # noqa: BLE001 + capture_errors.append(f"nav {cal_url}: {nav_exc}") + if not landed: + raise ValueError("Could not open Outlook with saved session") + + # Wait for SPA network + try: + await page.wait_for_load_state("networkidle", timeout=20000) + except Exception: # noqa: BLE001 + await page.wait_for_timeout(5000) + + # A) Page-context fetch (cookies + SPA origin) — most reliable crawl + try: + page_events = await self._page_fetch_calendar_events(page, range_start, range_end) + if page_events: + collected = page_events + logger.info("Page-context fetch returned %s events", len(collected)) + except Exception as pe: # noqa: BLE001 + capture_errors.append(f"page-fetch: {pe}") + logger.info("Page-context fetch failed: %s", pe) + + # B) Use bearer tokens the SPA itself sent (not Graph app registration) + if not collected and captured_bearers: + for token in captured_bearers[:3]: + try: + events = await self._fetch_graph_calendar_view( + context, token, range_start, range_end + ) + if events: + collected = events + logger.info( + "SPA-intercepted bearer returned %s events", len(collected) + ) + break + except Exception as te: # noqa: BLE001 + capture_errors.append(f"bearer: {te}") + + # C) Parse any intercepted JSON payloads. A single page view only + # shows "this week" though — to cover a wider requested range + # (e.g. the past month) we must page through every week in + # [range_start, range_end] so each one fires its own calendarView + # XHR that on_response() can capture. + if not collected: + await self._page_through_weeks(page, range_start, range_end, capture_errors) + collected = self._events_from_graph_payloads(graph_payloads) + if collected: + logger.info("XHR crawl intercept returned %s events", len(collected)) + else: + capture_errors.append( + f"XHR payloads={len(graph_payloads)} bearers={len(captured_bearers)}" + ) + logger.info( + "No calendar events. sample_urls=%s", + seen_urls[:15], + ) + + await context.storage_state(path=str(BROWSER_STATE_FILE)) + finally: + await browser.close() + + if not collected and capture_errors: + logger.warning("Teams crawl empty. errors=%s", capture_errors) + return collected + + async def _jump_to_date(self, page: Any, target: date) -> bool: + """Click the mini date-picker's day cell to move the main calendar view. + + Reloading the page with a `?startdt=` query param does NOT work — OWA + ignores it on a fresh navigation and always renders "today"'s week. The + mini date-picker (the small monthly calendar in the folder pane) is the + only reliable way to jump the main view to an arbitrary date without a + full reload. Its day buttons are labelled e.g. "1, 7 月, 2026" (day, + month, year) in zh-TW. If the target date's month isn't currently + rendered in the picker, we click "移至上個月" (previous month) to page + the mini-calendar itself back until the day becomes visible. + """ + label = f"{target.day}, {target.month} 月, {target.year}" + day_button = page.locator(f'button[aria-label="{label}"]') + prev_month_button = page.locator('button[aria-label^="移至上個月"]') + for _ in range(36): # safety cap: ~3 years of previous-month clicks + try: + if await day_button.count() > 0: + await day_button.first.click(timeout=5000) + return True + except Exception: # noqa: BLE001 + pass + try: + if await prev_month_button.count() == 0: + return False + await prev_month_button.first.click(timeout=5000) + await page.wait_for_timeout(400) + except Exception: # noqa: BLE001 + return False + return False + + async def _page_through_weeks( + self, + page: Any, + range_start: datetime, + range_end: datetime, + capture_errors: list[str], + ) -> None: + """Walk the Outlook SPA's own calendar view across the requested range. + + The XHR-sniffing fallback (path C) only sees whatever calendar view is + currently on screen. Without this, a request for "the past month" would + silently only ever capture the current week. We click through the mini + date-picker to each week in the range so every jump fires a fresh + calendarView call for that week, which on_response() captures into + graph_payloads — accumulated across every iteration. + """ + cursor = range_start.date() + end_date = range_end.date() + max_weeks = 14 # safety cap (~3 months) so a huge range can't hang forever + visited = 0 + while cursor <= end_date and visited < max_weeks: + jumped = False + try: + jumped = await self._jump_to_date(page, cursor) + except Exception as exc: # noqa: BLE001 + capture_errors.append(f"date-picker-nav {cursor.isoformat()}: {exc}") + if not jumped: + # Fall back to a full reload — slower and often ignored by OWA, + # but better than skipping the week entirely. + try: + week_url = ( + "https://outlook.office.com/calendar/view/week" + f"?startdt={cursor.isoformat()}" + ) + await page.goto(week_url, wait_until="domcontentloaded", timeout=60000) + await page.wait_for_timeout(3000) + except Exception as exc: # noqa: BLE001 + capture_errors.append(f"week-nav {cursor.isoformat()}: {exc}") + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: # noqa: BLE001 + await page.wait_for_timeout(3500) + # Nudge the SPA so it actually re-issues the calendarView XHR for + # this specific week (some builds cache/skip the fetch otherwise). + try: + await page.keyboard.press("ArrowRight") + await page.wait_for_timeout(1000) + await page.keyboard.press("ArrowLeft") + await page.wait_for_timeout(1500) + except Exception: # noqa: BLE001 + pass + cursor += timedelta(days=7) + visited += 1 + logger.info( + "Paged through %s week(s) covering %s..%s", + visited, + range_start.date().isoformat(), + range_end.date().isoformat(), + ) + + async def _page_fetch_calendar_events( + self, + page: Any, + range_start: datetime, + range_end: datetime, + ) -> list[dict[str, Any]]: + """Run fetch() inside the Outlook page (full cookie + same-site context).""" + start_s = range_start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end_s = range_end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + tz = settings.teams_calendar_timezone + result = await page.evaluate( + """async ({ startS, endS, tz }) => { + const urls = [ + `https://outlook.office.com/api/v2.0/me/calendarview?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200&$orderby=Start/DateTime`, + `https://outlook.office.com/api/v2.0/me/calendar/calendarView?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200`, + `https://graph.microsoft.com/v1.0/me/calendarView?startDateTime=${encodeURIComponent(startS)}&endDateTime=${encodeURIComponent(endS)}&$top=200&$orderby=start/dateTime`, + `https://outlook.office.com/api/v2.0/me/events?$top=100&$orderby=Start/DateTime&$filter=Start/DateTime ge '${startS}'`, + ]; + const out = []; + for (const url of urls) { + try { + const r = await fetch(url, { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Prefer': `outlook.timezone="${tz}"`, + }, + }); + const text = await r.text(); + let body = null; + try { body = JSON.parse(text); } catch (e) { body = text.slice(0, 200); } + out.push({ url, status: r.status, body }); + if (r.status === 200 && body && (body.value || body.Value)) { + return { ok: true, status: r.status, url, body }; + } + } catch (e) { + out.push({ url, error: String(e) }); + } + } + return { ok: false, attempts: out }; + }""", + {"startS": start_s, "endS": end_s, "tz": tz}, + ) + if not isinstance(result, dict): + return [] + if result.get("ok") and isinstance(result.get("body"), dict): + events = self._events_from_graph_payloads([result["body"]]) + logger.info("page fetch ok via %s → %s events", result.get("url"), len(events)) + return events + # log attempts briefly + attempts = result.get("attempts") or [] + for a in attempts[:6]: + logger.info( + "page-fetch attempt status=%s url=%s err=%s", + a.get("status"), + str(a.get("url") or "")[:80], + a.get("error"), + ) + return [] + + async def _extract_access_token(self, page: Any) -> str | None: + """Pull MSAL / ADAL access tokens from browser storage.""" + try: + token = await page.evaluate( + """() => { + function looksLikeJwt(s) { + return typeof s === 'string' && s.split('.').length === 3 && s.length > 40; + } + function walk(obj, depth) { + if (!obj || depth > 6) return null; + if (typeof obj === 'string') { + if (looksLikeJwt(obj)) return obj; + return null; + } + if (typeof obj !== 'object') return null; + // MSAL credential entry + if (obj.secret && looksLikeJwt(obj.secret)) { + const ct = String(obj.credentialType || obj.credential_type || '').toLowerCase(); + if (!ct || ct.includes('access')) return obj.secret; + } + if (obj.access_token && looksLikeJwt(obj.access_token)) return obj.access_token; + if (obj.accessToken && looksLikeJwt(obj.accessToken)) return obj.accessToken; + for (const v of Object.values(obj)) { + const found = walk(v, depth + 1); + if (found) return found; + } + return null; + } + const stores = [localStorage, sessionStorage]; + for (const store of stores) { + for (let i = 0; i < store.length; i++) { + const k = store.key(i); + if (!k) continue; + const raw = store.getItem(k); + if (!raw || raw.length < 30) continue; + // quick skip + if (!/access|msal|token|login/i.test(k + raw.slice(0, 80))) { + if (!raw.includes('eyJ')) continue; + } + try { + const data = JSON.parse(raw); + const found = walk(data, 0); + if (found) return found; + } catch (e) { + if (looksLikeJwt(raw)) return raw; + } + } + } + return null; + }""" + ) + return token if isinstance(token, str) and len(token) > 40 else None + except Exception as exc: # noqa: BLE001 + logger.info("Token extract failed: %s", exc) + return None + + async def _fetch_outlook_rest_calendar_view( + self, + context: Any, + range_start: datetime, + range_end: datetime, + ) -> list[dict[str, Any]]: + """Outlook REST v2 using OWA cookies (no Graph app registration).""" + from urllib.parse import quote + + start_s = range_start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end_s = range_end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + urls = [ + ( + "https://outlook.office.com/api/v2.0/me/calendarview" + f"?startDateTime={quote(start_s)}&endDateTime={quote(end_s)}" + "&$top=200&$orderby=Start/DateTime" + "&$select=Subject,Start,End,IsCancelled,IsOnlineMeeting,ShowAs" + ), + ( + "https://outlook.office365.com/api/v2.0/me/calendarview" + f"?startDateTime={quote(start_s)}&endDateTime={quote(end_s)}" + "&$top=200&$orderby=Start/DateTime" + ), + ] + headers = { + "Accept": "application/json", + "Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"', + } + for url in urls: + response = await context.request.get(url, headers=headers) + if response.status == 401 or response.status == 403: + logger.info("Outlook REST %s → %s", url.split("?")[0], response.status) + continue + if response.status != 200: + logger.info("Outlook REST failed %s: %s", response.status, (await response.text())[:160]) + continue + data = await response.json() + events = self._events_from_graph_payloads([data]) + if events: + return events + return [] + + async def _fetch_graph_calendar_view( + self, + context: Any, + token: str, + range_start: datetime, + range_end: datetime, + ) -> list[dict[str, Any]]: + from urllib.parse import quote + + start_s = range_start.isoformat() + end_s = range_end.isoformat() + url = ( + "https://graph.microsoft.com/v1.0/me/calendarView" + f"?startDateTime={quote(start_s)}" + f"&endDateTime={quote(end_s)}" + "&$select=subject,start,end,isCancelled,isOnlineMeeting,showAs" + "&$orderby=start/dateTime" + "&$top=200" + ) + response = await context.request.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"', + }, + ) + if response.status != 200: + body = "" + try: + body = (await response.text())[:200] + except Exception: # noqa: BLE001 + pass + logger.info("Graph calendarView via browser token failed: %s %s", response.status, body) + return [] + data = await response.json() + return self._events_from_graph_payloads([data]) + + async def _fetch_via_graph_api( + self, + range_start: datetime, + range_end: datetime, + ) -> list[dict[str, Any]]: + """Graph calendarView using stored access token (httpx).""" + from urllib.parse import quote + + token = await self._graph_access_token() + start_s = range_start.isoformat() + end_s = range_end.isoformat() + url = ( + "https://graph.microsoft.com/v1.0/me/calendarView" + f"?startDateTime={quote(start_s)}" + f"&endDateTime={quote(end_s)}" + "&$select=subject,start,end,isCancelled,isOnlineMeeting,showAs" + "&$orderby=start/dateTime" + "&$top=200" + ) + headers = { + "Authorization": f"Bearer {token}", + "Prefer": f'outlook.timezone="{settings.teams_calendar_timezone}"', + } + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(url, headers=headers) + if response.status_code == 401: + # try refresh then once more + try: + token = await self._refresh_graph_token() + headers["Authorization"] = f"Bearer {token}" + response = await client.get(url, headers=headers) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"Graph token expired and refresh failed: {exc}") from exc + if response.status_code >= 400: + raise ValueError( + f"Graph calendarView HTTP {response.status_code}: {response.text[:200]}" + ) + data = response.json() + return self._events_from_graph_payloads([data]) + + def _events_from_graph_payloads(self, payloads: list[dict[str, Any]]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for payload in payloads: + values = self._extract_item_list(payload) + for item in values: + if not isinstance(item, dict): + continue + start_s, end_s = self._extract_start_end(item) + if not start_s or not end_s: + continue + subject = ( + item.get("subject") + or item.get("Subject") + or item.get("name") + or item.get("Name") + or "Meeting" + ) + # The same calendar view can be intercepted more than once (initial + # load + the ArrowRight/ArrowLeft nudge used to trigger XHRs, or the + # page-fetch/bearer/XHR-intercept paths overlapping), so the identical + # event shows up in multiple captured payloads — sometimes with the + # SAME instant expressed in different formats (UTC "Z" vs. an + # already-shifted "+08:00" local offset). Comparing raw strings would + # miss those, so normalize start/end to UTC before deduping. + try: + start_key = self._parse_time(start_s).astimezone(timezone.utc).isoformat() + end_key = self._parse_time(end_s).astimezone(timezone.utc).isoformat() + except Exception: # noqa: BLE001 + start_key, end_key = str(start_s), str(end_s) + dedupe_key = (str(subject).strip().lower(), start_key, end_key) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + events.append( + { + "subject": str(subject), + "start": start_s, + "end": end_s, + "is_cancelled": bool( + item.get("isCancelled") + or item.get("IsCancelled") + or item.get("isCancelledEvent") + ), + "is_online": bool( + item.get("isOnlineMeeting") + or item.get("IsOnlineMeeting") + or item.get("onlineMeeting") + or item.get("OnlineMeeting") + or item.get("isOnlineMeeting") + ), + "show_as": str( + item.get("showAs") or item.get("ShowAs") or item.get("showas") or "" + ), + } + ) + return events + + def _extract_item_list(self, payload: Any) -> list[Any]: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + for key in ("value", "Value", "Items", "items", "events", "Events", "Body"): + nested = payload.get(key) + if isinstance(nested, list): + return nested + if isinstance(nested, dict): + # OWA Body.ResponseMessages... + deeper = self._extract_item_list(nested) + if deeper: + return deeper + # recursive shallow search for list of event-like dicts + for v in payload.values(): + if isinstance(v, list) and v and isinstance(v[0], dict): + sample = v[0] + if any(k in sample for k in ("Start", "start", "Subject", "subject", "End", "end")): + return v + if isinstance(v, dict): + deeper = self._extract_item_list(v) + if deeper: + return deeper + return [] + + def _extract_start_end(self, item: dict[str, Any]) -> tuple[str | None, str | None]: + start = item.get("start") or item.get("Start") or item.get("startTime") or item.get("StartTime") + end = item.get("end") or item.get("End") or item.get("endTime") or item.get("EndTime") + start_s = self._normalize_time_field(start) + end_s = self._normalize_time_field(end) + return start_s, end_s + + def _normalize_time_field(self, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + # OWA legacy: /Date(1710000000000)/ + if value.startswith("/Date("): + try: + ms = int(value.split("(")[1].split(")")[0].split("+")[0].split("-")[0]) + return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat() + except Exception: # noqa: BLE001 + return None + return value + if isinstance(value, (int, float)): + # epoch ms or seconds + ts = float(value) + if ts > 1e12: + ts = ts / 1000 + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + if isinstance(value, dict): + return ( + value.get("dateTime") + or value.get("DateTime") + or value.get("date") + or value.get("Date") + ) + return None diff --git a/dates.txt b/dates.txt new file mode 100644 index 0000000..0142d37 --- /dev/null +++ b/dates.txt @@ -0,0 +1 @@ +2025-12-24 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..864183d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +# Ephemeral: no host volume. /data is tmpfs — gone when container is removed. +services: + pts: + build: . + container_name: pts + ports: + - "${BACKEND_PORT:-8765}:8765" + # noVNC:Teams MFA 互動畫面(點擊/輸入驗證碼) + - "${TEAMS_VNC_PORT:-6080}:6080" + environment: + PTS_DATA_DIR: /data + PTS_RELOAD: "false" + PTS_IN_DOCKER: "true" + DISPLAY: ":99" + TEAMS_HEADLESS: "false" + TEAMS_VNC_PORT: "6080" + # Optional tokens from host shell / project .env (compose substitution only; not a data mount) + GITLAB_TOKEN: ${GITLAB_TOKEN:-} + GITLAB_URL: ${GITLAB_URL:-} + GITLAB_PROJECT_PATH: ${GITLAB_PROJECT_PATH:-} + XAI_API_KEY: ${XAI_API_KEY:-} + XAI_BASE_URL: ${XAI_BASE_URL:-} + GROK_MODEL: ${GROK_MODEL:-} + PTS_URL: ${PTS_URL:-} + PTS_PROJECT_NAME: ${PTS_PROJECT_NAME:-} + tmpfs: + - /data:size=64m,mode=1777 + # Do not mount host dirs. Session / settings only live in container tmpfs. + shm_size: "256mb" + restart: "no" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..df0ca26 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +# Ephemeral container: /data is typically tmpfs; never require a host mount. +DATA_DIR="${PTS_DATA_DIR:-/data}" +mkdir -p "$DATA_DIR" + +# Empty local files only inside the container (discarded when container is removed). +if [ ! -f "$DATA_DIR/.env" ]; then + touch "$DATA_DIR/.env" +fi +if [ ! -f "$DATA_DIR/dates.txt" ]; then + touch "$DATA_DIR/dates.txt" +fi + +# Virtual display + noVNC so Teams MFA can be completed interactively in Docker. +# Chromium runs headed on DISPLAY; user opens :6080 in browser to click/type. +DISPLAY_NUM="${DISPLAY#:}" +DISPLAY_NUM="${DISPLAY_NUM:-99}" +export DISPLAY=":${DISPLAY_NUM}" +VNC_PORT="${TEAMS_VNC_PORT:-6080}" +RFB_PORT="${TEAMS_RFB_PORT:-5900}" + +NOVNC_WEB="" +for d in /usr/share/novnc /usr/share/novnc/web /usr/share/webapps/novnc; do + if [ -d "$d" ] && { [ -f "$d/vnc.html" ] || [ -f "$d/vnc_lite.html" ]; }; then + NOVNC_WEB="$d" + break + fi +done +if [ -z "$NOVNC_WEB" ] && [ -d /usr/share/novnc ]; then + NOVNC_WEB=/usr/share/novnc +fi + +start_vnc_stack() { + if ! command -v Xvfb >/dev/null 2>&1; then + echo "WARN: Xvfb not installed — Teams MFA interactive view unavailable" + return 0 + fi + + # Already running (container restart of app only) + if [ -S "/tmp/.X11-unix/X${DISPLAY_NUM}" ] 2>/dev/null || \ + pgrep -x Xvfb >/dev/null 2>&1; then + : + else + echo "==> Xvfb ${DISPLAY} (Teams MFA virtual display)" + Xvfb "${DISPLAY}" -screen 0 1400x900x24 -ac +extension RANDR -nolisten tcp >/tmp/xvfb.log 2>&1 & + sleep 0.5 + fi + + if command -v fluxbox >/dev/null 2>&1; then + if ! pgrep -x fluxbox >/dev/null 2>&1; then + fluxbox >/tmp/fluxbox.log 2>&1 & + fi + fi + + if command -v x11vnc >/dev/null 2>&1; then + if ! pgrep -x x11vnc >/dev/null 2>&1; then + echo "==> x11vnc :${RFB_PORT}" + x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${RFB_PORT}" \ + -nopw -xkb -listen 0.0.0.0 -ncache 10 -ncache_cr \ + >/tmp/x11vnc.log 2>&1 & + sleep 0.3 + fi + fi + + if command -v websockify >/dev/null 2>&1; then + if ! pgrep -f "websockify.*${VNC_PORT}" >/dev/null 2>&1; then + echo "==> noVNC http://0.0.0.0:${VNC_PORT}/ (interactive Chrome for MFA)" + if [ -n "$NOVNC_WEB" ]; then + websockify --web="${NOVNC_WEB}" "${VNC_PORT}" "localhost:${RFB_PORT}" \ + >/tmp/websockify.log 2>&1 & + else + websockify "${VNC_PORT}" "localhost:${RFB_PORT}" >/tmp/websockify.log 2>&1 & + fi + fi + fi +} + +start_vnc_stack + +exec "$@" diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..65790be --- /dev/null +++ b/extension/background.js @@ -0,0 +1,10 @@ +const DEFAULT_BACKEND = "http://localhost:8765"; + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === "SYNC_TO_BACKEND") { + syncSession(message.auth, message.backendUrl) + .then((result) => sendResponse(result)) + .catch((err) => sendResponse({ ok: false, error: err.message })); + return true; + } +}); \ No newline at end of file diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..00ccb50 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,18 @@ +const AUTH_KEY = "pts_authdata"; + +function readPtsAuth() { + const raw = localStorage.getItem(AUTH_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === "GET_PTS_AUTH") { + sendResponse({ ok: true, auth: readPtsAuth() }); + } + return true; +}); \ No newline at end of file diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..3474af7 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "吉八小 · 憑證同步", + "version": "1.1.0", + "description": "把工時系統登入憑證同步到吉八小,集滿八小時", + "permissions": ["storage", "activeTab", "scripting"], + "host_permissions": [ + "https://tw-timesheet.supermicro.com/*", + "http://localhost:8765/*", + "http://127.0.0.1:8765/*" + ], + "action": { + "default_popup": "popup.html", + "default_title": "吉八小 · 憑證同步" + }, + "content_scripts": [ + { + "matches": ["https://tw-timesheet.supermicro.com/*"], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "background.js" + } +} \ No newline at end of file diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..0ef2cc9 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,22 @@ + + + + + + + +

吉八小 · 憑證同步

+ + + +
+ + + \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..ee3a259 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,105 @@ +const AUTH_KEY = "pts_authdata"; +const statusEl = document.getElementById("status"); +const backendInput = document.getElementById("backendUrl"); + +chrome.storage.local.get(["backendUrl"], (data) => { + if (data.backendUrl) backendInput.value = data.backendUrl; +}); + +function isPtsUrl(url) { + return !!url && url.includes("tw-timesheet.supermicro.com"); +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab; +} + +function readAuthViaMessage(tabId) { + return new Promise((resolve) => { + chrome.tabs.sendMessage(tabId, { type: "GET_PTS_AUTH" }, (response) => { + if (chrome.runtime.lastError) { + resolve({ + ok: false, + error: chrome.runtime.lastError.message, + }); + return; + } + resolve(response || { ok: false, error: "No response from content script" }); + }); + }); +} + +async function readAuthViaInjection(tabId) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (key) => { + const raw = localStorage.getItem(key); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + args: [AUTH_KEY], + }); + const auth = results?.[0]?.result ?? null; + return { ok: true, auth }; +} + +async function readAuthFromPage(tab) { + let result = await readAuthViaMessage(tab.id); + + if (!result.ok || !result.auth) { + result = await readAuthViaInjection(tab.id); + } + + return result; +} + +async function syncSession(auth, backendUrl) { + if (!auth?.accessToken) { + throw new Error("找不到登入憑證,請確認已登入工時系統"); + } + const res = await fetch(`${backendUrl}/api/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + groupCode: auth.groupCode, + raw: auth, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || res.statusText); + return data; +} + +document.getElementById("syncBtn").addEventListener("click", async () => { + statusEl.textContent = "同步中..."; + const backendUrl = backendInput.value.trim().replace(/\/$/, ""); + chrome.storage.local.set({ backendUrl }); + + try { + const tab = await getActiveTab(); + if (!tab?.id || !isPtsUrl(tab.url)) { + throw new Error("請先切到工時系統分頁(tw-timesheet.supermicro.com)再按同步"); + } + + const pageAuth = await readAuthFromPage(tab); + if (!pageAuth.auth) { + throw new Error( + pageAuth.error + ? `${pageAuth.error}。請重新整理工時系統頁面後再試` + : "無法讀取登入憑證,請重新整理工時系統頁面並確認已登入" + ); + } + + const result = await syncSession(pageAuth.auth, backendUrl); + statusEl.textContent = `成功:${result.message || "已同步"}`; + } catch (err) { + statusEl.textContent = `失敗:${err.message}`; + } +}); \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..70e0102 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,286 @@ +/** + * Home flow: + * - range mode: start~end, skip TW holidays/weekends + * - list mode: exact dates, fill even on holidays + * Preview/fill: Teams meetings + GitLab issues → PTS + */ + +function setDefaultDates() { + const today = todayStr(); + document.getElementById("startDate").value = today; + document.getElementById("endDate").value = today; +} + +function getDateMode() { + const checked = document.querySelector('input[name="dateMode"]:checked'); + return checked ? checked.value : "range"; +} + +function getDateRange() { + const start = document.getElementById("startDate")?.value || todayStr(); + let end = document.getElementById("endDate")?.value || start; + if (end < start) { + document.getElementById("endDate").value = start; + document.getElementById("startDate").value = end; + return { start: end, end: start }; + } + return { start, end }; +} + +function getDateListText() { + return (document.getElementById("dateList")?.value || "").trim(); +} + +function parseDateList(text) { + const dates = []; + for (const line of text.split(/\n+/)) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + for (const part of t.split(/[\s,;]+/)) { + const d = part.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(d)) dates.push(d); + } + } + return [...new Set(dates)].sort(); +} + +function setRangeToToday() { + const today = todayStr(); + document.getElementById("startDate").value = today; + document.getElementById("endDate").value = today; + updateRangeMeta(); +} + +function updateDateModeUI() { + const mode = getDateMode(); + document.getElementById("rangePanel")?.classList.toggle("hidden", mode !== "range"); + document.getElementById("listPanel")?.classList.toggle("hidden", mode !== "list"); + updateRangeMeta(); +} + +function updateRangeMeta() { + const el = document.getElementById("rangeMeta"); + if (!el) return; + if (getDateMode() === "list") { + const dates = parseDateList(getDateListText()); + el.textContent = dates.length + ? `指定 ${dates.length} 天(含假日也填):${dates.join("、")}` + : "請輸入至少一個日期"; + return; + } + const { start, end } = getDateRange(); + el.textContent = `區間 ${start} → ${end}(自動跳過週末/台灣假日)`; +} + +function setActionStatus(text, ok) { + const el = document.getElementById("actionStatus"); + if (!el) return; + el.textContent = text || ""; + el.className = "settings-status" + (ok === true ? " ok" : ok === false ? " err" : ""); +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +async function refreshConn() { + const line = document.getElementById("connLine"); + try { + const data = await api("/api/status"); + const teams = data.teams_calendar || {}; + const parts = [ + data.has_pts_session ? "工時 ✓" : "工時 ✗", + data.gitlab_configured && !data.gitlab_error ? "任務 ✓" : "任務 ✗", + data.teams_calendar_enabled ? (teams.connected ? "行事曆 ✓" : "行事曆 ✗") : "行事曆 關", + ]; + if (line) { + line.textContent = parts.join(" · "); + if (data.gitlab_error) line.textContent += " — " + data.gitlab_error; + } + } catch (err) { + if (line) line.textContent = err.message; + } +} + +function renderPlan(data, { filled } = {}) { + const root = document.getElementById("planView"); + if (!root) return; + + const workdays = data.workdays || []; + const plan = data.plan || {}; + const meetings = data.analysis?.meetings || data.meetings || {}; + const source = data.source || ""; + const gitlabN = data.gitlab_task_count ?? "—"; + + const meetingNote = + meetings.enabled === false + ? "會議未啟用" + : meetings.error + ? `會議:${meetings.error}` + : meetings.meeting_count != null + ? `會議 ${meetings.meeting_count} 場 · ${meetings.meeting_hours_total ?? 0}h` + : ""; + + if (!workdays.length && !Object.keys(plan).length) { + root.innerHTML = `

沒有可填日期。區間模式會跳過假日;指定多日請確認日期格式。

`; + return; + } + + const days = workdays.length ? workdays : Object.keys(plan).sort(); + const dayBlocks = days + .map((day) => { + const entries = plan[day] || []; + if (!entries.length) { + return `
+
${escapeHtml(day)}無項目
+
`; + } + const hours = entries.reduce((s, e) => s + Number(e.take_hours || 0), 0); + const rows = entries + .map((e) => { + const desc = String(e.description || ""); + const isMeeting = + (e.pts_task_name || "").toLowerCase().includes("meet") || + desc.toLowerCase().includes("meeting") || + desc.toLowerCase().includes("attended"); + const tag = isMeeting + ? '會議' + : e.gitlab_task_id + ? 'GitLab' + : ""; + return `
  • + ${escapeHtml(String(e.take_hours))}h + ${tag}${escapeHtml(e.pts_task_name || "")} · ${escapeHtml(desc)} +
  • `; + }) + .join(""); + return `
    +
    + ${escapeHtml(day)} + ${entries.length} 筆 · ${hours}h +
    +
      ${rows}
    +
    `; + }) + .join(""); + + const title = filled ? "已填寫" : "預覽"; + const dry = data.dry_run ? "(試跑)" : ""; + root.innerHTML = ` +
    + ${pill(title + dry, source || "ok", true)} + ${pill("天數", String(days.length), null)} + ${pill("GitLab", String(gitlabN), null)} + ${meetingNote ? pill("Teams", meetingNote, meetings.error ? false : null) : ""} +
    +
    ${dayBlocks}
    + `; +} + +async function runPreview() { + const mode = getDateMode(); + setActionStatus("預覽中…"); + document.getElementById("planView").innerHTML = `

    會議 + GitLab 分配中…

    `; + + try { + let data; + if (mode === "list") { + const dates = parseDateList(getDateListText()); + if (!dates.length) throw new Error("請至少輸入一個日期(YYYY-MM-DD)"); + data = await api("/api/preview/dates", { + method: "POST", + body: JSON.stringify({ dates_text: dates.join("\n") }), + }); + document.getElementById("rangeMeta").textContent = + `指定 ${dates.length} 天(含假日):${dates.join("、")}`; + } else { + const { start, end } = getDateRange(); + data = await api( + `/api/preview?start_date=${encodeURIComponent(start)}&end_date=${encodeURIComponent(end)}` + ); + document.getElementById("rangeMeta").textContent = + `區間 ${start} → ${end} · 工作日 ${(data.workdays || []).length} 天(已跳過假日)`; + } + renderPlan(data, { filled: false }); + setActionStatus("預覽完成(尚未送出)", true); + } catch (err) { + document.getElementById("planView").innerHTML = + `

    ${escapeHtml(err.message)}

    `; + setActionStatus(err.message, false); + } +} + +async function runFill() { + const mode = getDateMode(); + const dryRun = document.getElementById("dryRun").checked; + setActionStatus(`${dryRun ? "試跑" : "填寫"}中…`); + document.getElementById("planView").innerHTML = `

    處理中…

    `; + + try { + let data; + if (mode === "list") { + const dates = parseDateList(getDateListText()); + if (!dates.length) throw new Error("請至少輸入一個日期(YYYY-MM-DD)"); + data = await api("/api/fill/dates", { + method: "POST", + body: JSON.stringify({ + dates_text: dates.join("\n"), + dry_run: dryRun, + skip_existing: true, + }), + }); + document.getElementById("rangeMeta").textContent = + `指定 ${dates.length} 天` + (dryRun ? " · 試跑" : " · 已送出"); + } else { + const { start, end } = getDateRange(); + data = await api("/api/fill/range", { + method: "POST", + body: JSON.stringify({ + start_date: start, + end_date: end, + dry_run: dryRun, + skip_existing: true, + }), + }); + document.getElementById("rangeMeta").textContent = + `區間 ${start} → ${end}` + (dryRun ? " · 試跑" : " · 已送出"); + } + + if (data.plan || data.workdays) { + renderPlan({ ...data, dry_run: dryRun }, { filled: !dryRun }); + } else { + document.getElementById("planView").innerHTML = ` +
    + ${pill(dryRun ? "試跑完成" : "填寫完成", "ok", true)} + ${pill("新增", String((data.created || []).length), null)} + ${pill("略過", String((data.skipped || []).length), null)} +
    +
    ${escapeHtml(JSON.stringify(data, null, 2))}
    + `; + } + setActionStatus(dryRun ? "試跑完成" : "填寫完成", true); + await refreshConn(); + } catch (err) { + document.getElementById("planView").innerHTML = + `

    ${escapeHtml(err.message)}

    `; + setActionStatus(err.message, false); + } +} + +document.querySelectorAll('input[name="dateMode"]').forEach((el) => { + el.addEventListener("change", updateDateModeUI); +}); +document.getElementById("setTodayBtn")?.addEventListener("click", setRangeToToday); +document.getElementById("previewBtn")?.addEventListener("click", () => runPreview().catch(console.error)); +document.getElementById("fillBtn")?.addEventListener("click", () => runFill().catch(console.error)); +document.getElementById("startDate")?.addEventListener("change", updateRangeMeta); +document.getElementById("endDate")?.addEventListener("change", updateRangeMeta); +document.getElementById("dateList")?.addEventListener("input", updateRangeMeta); + +setDefaultDates(); +updateDateModeUI(); +refreshConn().catch(console.error); diff --git a/frontend/common.js b/frontend/common.js new file mode 100644 index 0000000..0b4c3fa --- /dev/null +++ b/frontend/common.js @@ -0,0 +1,76 @@ +const API = ""; + +const THEME_KEY = "pts_theme"; + +function resolveTheme(pref) { + if (pref === "light" || pref === "dark") return pref; + // system + return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; +} + +function getThemePref() { + return localStorage.getItem(THEME_KEY) || "system"; +} + +function applyTheme(pref) { + const resolved = resolveTheme(pref); + document.documentElement.setAttribute("data-theme", resolved); + document.documentElement.dataset.themePref = pref; + document.documentElement.classList.toggle("dark", resolved === "dark"); + document.querySelectorAll("[data-theme-option]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-theme-option") === pref); + }); +} + +function setThemePref(pref) { + localStorage.setItem(THEME_KEY, pref); + applyTheme(pref); +} + +function initTheme() { + applyTheme(getThemePref()); + // react to OS changes when using system + window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { + if (getThemePref() === "system") applyTheme("system"); + }); + document.querySelectorAll("[data-theme-option]").forEach((btn) => { + btn.addEventListener("click", () => { + setThemePref(btn.getAttribute("data-theme-option")); + }); + }); +} + +async function api(path, options = {}) { + const res = await fetch(`${API}${path}`, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = data.detail; + const message = + typeof detail === "string" + ? detail + : Array.isArray(detail) + ? detail.map((d) => d.msg || JSON.stringify(d)).join("; ") + : res.statusText; + throw new Error(message || res.statusText); + } + return data; +} + +function todayStr() { + return new Date().toISOString().slice(0, 10); +} + +function pill(label, value, ok) { + const cls = ok === true ? "ok" : ok === false ? "warn" : ""; + return `${label}${value}`; +} + +// apply ASAP if script loads after head bootstrap +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initTheme); +} else { + initTheme(); +} diff --git a/frontend/compbase.html b/frontend/compbase.html new file mode 100644 index 0000000..4fa678d --- /dev/null +++ b/frontend/compbase.html @@ -0,0 +1,87 @@ + + + + + + 刷卡補填 · 吉八小 + + + + + + + + + +
    +
    +
    +

    刷卡補填

    +

    檢查 CompBase 連線…

    +
    +
    +
    + + + +
    + +
    +
    + +
    +

    一鍵補漏刷退

    +

    + 登入 CompBase → 出勤檢視 → 找出「應刷未刷」→ 自動選正常下班時間送出。 + 與 PTS 工時無關;預設使用設定裡的 PTS 帳密做 NTLM。 +

    + +
    + + +
    + + + +
    + + +
    +

    +
    + +
    +

    結果

    +
    +

    選天數後按「掃描」或「一鍵補填」。

    +
    +
    +
    + + + + diff --git a/frontend/compbase.js b/frontend/compbase.js new file mode 100644 index 0000000..4a7e04f --- /dev/null +++ b/frontend/compbase.js @@ -0,0 +1,201 @@ +/** + * CompBase attendance: scan missing clock-out (應刷未刷) and auto-fill via RID page. + */ + +function setActionStatus(text, ok) { + const el = document.getElementById("actionStatus"); + if (!el) return; + el.textContent = text || ""; + el.className = "settings-status" + (ok === true ? " ok" : ok === false ? " err" : ""); +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function getDays() { + const n = Number(document.getElementById("days")?.value || 14); + if (!Number.isFinite(n) || n < 1) return 14; + return Math.min(62, Math.floor(n)); +} + +function getOutTimeMode() { + return document.getElementById("outTimeMode")?.value || "expected"; +} + +function isDryRun() { + return Boolean(document.getElementById("dryRun")?.checked); +} + +async function refreshConn() { + const line = document.getElementById("connLine"); + try { + const data = await api("/api/compbase/status"); + if (data.default_days && document.getElementById("days")) { + document.getElementById("days").value = data.default_days; + } + if (data.out_time_mode && document.getElementById("outTimeMode")) { + document.getElementById("outTimeMode").value = data.out_time_mode; + } + if (!line) return; + if (!data.has_credentials) { + line.textContent = "未設定帳密(請到設定登入 PTS,或填 CompBase 帳密)"; + return; + } + const name = data.display_name || data.username || "—"; + const emp = data.emp_id ? ` · ${data.emp_id}` : ""; + const src = data.credential_source === "compbase" ? "CompBase 帳密" : "PTS 帳密"; + line.textContent = data.connected + ? `CompBase ✓ · ${name}${emp} · ${src}` + : `CompBase ✗ · ${data.error || "連線失敗"} · ${src}`; + } catch (err) { + if (line) line.textContent = err.message; + } +} + +function rowStatusLabel(row, kind) { + if (kind === "filled") { + if (row.dry_run) return `試跑 · 將送 ${escapeHtml(row.chosen_out_time || "—")}`; + return `已送出 · ${escapeHtml(row.chosen_out_time || "—")}${row.message ? " · " + escapeHtml(row.message) : ""}`; + } + if (kind === "error") { + return `失敗 · ${escapeHtml(row.error || row.message || "unknown")}`; + } + const reason = row.reason || row.status || "skipped"; + const map = { + no_rid: "無 RID(可能缺刷進)", + missing_in: "缺刷進", + missing_out: "缺刷退但無連結", + other: "其他異常", + }; + return map[reason] || escapeHtml(String(reason)); +} + +function renderResults(data, mode) { + const root = document.getElementById("resultView"); + if (!root) return; + + const filled = data.filled || data.fillable || []; + const skipped = data.skipped || []; + const errors = data.errors || []; + const isScan = mode === "scan"; + + const summaryParts = [ + `區間 ${escapeHtml(data.start || "—")} → ${escapeHtml(data.end || "—")}`, + isScan + ? `可補 ${data.fillable_count ?? filled.length}` + : `完成 ${data.filled_count ?? filled.length}`, + `略過 ${data.skipped_count ?? skipped.length}`, + ]; + if (!isScan) { + summaryParts.push(`失敗 ${data.error_count ?? errors.length}`); + if (data.dry_run) summaryParts.push("試跑"); + } + + const blocks = []; + blocks.push(`

    ${summaryParts.join(" · ")}

    `); + + function table(title, rows, kind) { + if (!rows.length) return ""; + const body = rows + .map((r) => { + return ` + ${escapeHtml(r.date || "—")} + ${escapeHtml(r.rid || "—")} + ${escapeHtml(r.actual_in || r.in_time || "—")} + ${escapeHtml(r.exp_out || "—")} + ${rowStatusLabel(r, kind)} + `; + }) + .join(""); + return `
    +
    ${escapeHtml(title)}${rows.length}
    +
    + + + ${body} +
    日期RID刷進應下班狀態
    +
    +
    `; + } + + if (isScan) { + blocks.push(table("可補填(有 RID)", filled, "filled")); + blocks.push(table("略過", skipped, "skipped")); + } else { + blocks.push(table(data.dry_run ? "試跑將送出" : "已送出", filled, "filled")); + blocks.push(table("略過", skipped, "skipped")); + blocks.push(table("失敗", errors, "error")); + } + + if (blocks.length === 1) { + blocks.push(`

    近 ${escapeHtml(String(data.days || ""))} 天沒有可處理的漏刷退。

    `); + } + + root.innerHTML = blocks.join(""); +} + +async function runScan() { + const days = getDays(); + setActionStatus(`掃描近 ${days} 天…`); + try { + const data = await api("/api/compbase/scan", { + method: "POST", + body: JSON.stringify({ days }), + }); + renderResults(data, "scan"); + setActionStatus( + `掃描完成:可補 ${data.fillable_count ?? 0}、略過 ${data.skipped_count ?? 0}`, + true + ); + } catch (err) { + setActionStatus(err.message, false); + } +} + +async function runFill() { + const days = getDays(); + const dryRun = isDryRun(); + const outTimeMode = getOutTimeMode(); + if (!dryRun) { + const ok = window.confirm( + `將對近 ${days} 天的「應刷未刷」送出刷退補填到 CompBase。\n確定繼續?` + ); + if (!ok) return; + } + setActionStatus(dryRun ? `試跑近 ${days} 天…` : `補填近 ${days} 天…`); + try { + const data = await api("/api/compbase/fill", { + method: "POST", + body: JSON.stringify({ + days, + dry_run: dryRun, + out_time_mode: outTimeMode, + }), + }); + renderResults(data, "fill"); + const ok = (data.error_count ?? 0) === 0; + setActionStatus( + `${dryRun ? "試跑" : "補填"}完成:成功 ${data.filled_count ?? 0}、略過 ${data.skipped_count ?? 0}、失敗 ${data.error_count ?? 0}`, + ok + ); + } catch (err) { + setActionStatus(err.message, false); + } +} + +function init() { + document.getElementById("scanBtn")?.addEventListener("click", runScan); + document.getElementById("fillBtn")?.addEventListener("click", runFill); + refreshConn(); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ddc4046 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,119 @@ + + + + + + 吉八小 · 集滿八小時 + + + + + + + + + +
    +
    +
    +

    吉八小

    +

    集滿八小時 · 檢查連線…

    +
    +
    +
    + + + +
    + +
    +
    + +
    +

    1. 選日期

    + +
    + + +
    + +
    +
    + + +
    +
    + +
    +
    + + + +

    +
    + +
    +

    2. 預覽 / 填寫

    +

    Teams 會議 + GitLab issue → 分配時數 → 寫入 PTS。

    + +
    + + +
    +

    +
    + +
    +

    CompBase 刷卡補填

    +

    掃出勤「應刷未刷」、一鍵補刷退(與上方 PTS 工時無關)。

    + +
    + +
    +

    3. 結果

    +
    +

    選好日期後按「預覽」或「填寫」。

    +
    +
    +
    + + + + diff --git a/frontend/settings.html b/frontend/settings.html new file mode 100644 index 0000000..19b5b86 --- /dev/null +++ b/frontend/settings.html @@ -0,0 +1,245 @@ + + + + + + 設定 · 吉八小 + + + + + + + + + +
    +
    +
    +

    設定

    +

    吉八小 · 帳號連線與系統參數

    +
    +
    +
    + + + +
    + +
    +
    + +
    +

    帳號連線

    +

    輸入帳密登入。能打 API 就直接打;需 MFA 時才開瀏覽器。密碼加密存本機。

    + +
    +
    +
    +

    PTS

    +
    檢查中…
    +
    +
    + + +
    +
    + + +
    +

    +
    + +
    +
    +

    Teams 行事曆

    +
    選配
    +
    +
    + + + +
    +
    + + +
    +

    +

    Docker:登入會開虛擬 Chrome,可用互動畫面完成 MFA(最多約 10 分鐘,不會中途關掉)。

    +
    +
    +
    + + + + +
    +

    測試:Teams 抓會議

    +

    只測行事曆爬蟲(不走 Graph API)。先「登入 Teams」完成 MFA,再選日期測試。不寫 PTS、不碰 GitLab。

    +
    + + + +
    +
    + + +
    +

    +
    +
    +
    尚無測試結果
    +
    + +
    +

    系統參數

    +
    +
    + GitLab +
    + + +

    + + + + +
    +
    + +
    + 工時填寫 +
    + + + + + + + + +
    +
    + +
    + CompBase 刷卡補填(選填) +

    預設沿用上方 PTS 帳密做 NTLM。僅在帳號不同時才填這裡。

    +
    + + + + + + +
    +
    + +
    + Grok LLM +
    + + + + + +
    +
    + +
    + 自動排程 +
    + + + +
    +
    + +
    + 補寫日期 (dates.txt) + +
    + +
    + + 回主頁 + +
    +
    +
    + +
    +

    備援:瀏覽器擴充功能

    +

    帳密登入不可用時,可用擴充功能同步工時系統登入憑證。

    + +
      +
    1. 解壓後於 chrome://extensions 載入未封裝項目
    2. +
    3. 登入工時系統網頁 → 重新整理 → 擴充功能同步憑證
    4. +
    +
    +
    + + + + diff --git a/frontend/settings.js b/frontend/settings.js new file mode 100644 index 0000000..3319d54 --- /dev/null +++ b/frontend/settings.js @@ -0,0 +1,485 @@ +const BOOL_FIELDS = [ + "use_llm", + "use_description_variation", + "auto_fill_enabled", +]; +const NUMBER_FIELDS = [ + "gitlab_board_id", + "auto_fill_hour", + "auto_fill_minute", + "max_hours_per_day", + "hour_step", + "compbase_default_days", +]; + +const SECRET_INPUTS = new Set(["gitlab_token", "xai_api_key", "compbase_password"]); + +function fillSettingsForm(data) { + const form = document.getElementById("settingsForm"); + for (const el of form.querySelectorAll("[name]")) { + const name = el.name; + if (name === "dates_file_content") continue; + if (BOOL_FIELDS.includes(name)) { + el.checked = Boolean(data[name]); + continue; + } + // Never put masked secrets back into password fields (looks like a real token) + if (SECRET_INPUTS.has(name)) { + el.value = ""; + el.placeholder = data[`${name}_set`] + ? "已設定(重新貼上才會更新)" + : "貼上 token"; + continue; + } + if (data[name] !== undefined && data[name] !== null) { + el.value = data[name]; + } + } + if (data.pts_username) { + document.getElementById("ptsUsername").value = data.pts_username; + } + if (data.teams_username) { + document.getElementById("teamsUsername").value = data.teams_username; + } + document.getElementById("teamsEnabled").checked = Boolean(data.teams_calendar_enabled); + + const gitlabHint = document.getElementById("gitlabTokenHint"); + if (gitlabHint) { + gitlabHint.textContent = data.gitlab_token_set + ? "Token 已儲存。若無法使用請重新產生並貼上新的 Personal Access Token(權限至少 api)。" + : "到 GitLab → Preferences → Access Tokens 建立,勾選 api。"; + } +} + +function collectSettingsPayload(form) { + const payload = {}; + for (const el of form.querySelectorAll("[name]")) { + const name = el.name; + if (name === "dates_file_content") continue; + if (BOOL_FIELDS.includes(name)) { + payload[name] = el.checked; + continue; + } + if (NUMBER_FIELDS.includes(name)) { + const num = el.value === "" ? null : Number(el.value); + if (num !== null && !Number.isNaN(num)) payload[name] = num; + continue; + } + const value = el.value.trim(); + if (name === "gitlab_milestone_title") { + payload[name] = value; + continue; + } + if (value !== "") payload[name] = value; + } + payload.teams_calendar_enabled = document.getElementById("teamsEnabled").checked; + return payload; +} + +function renderAuthStatus(data) { + const ptsEl = document.getElementById("ptsAuthStatus"); + if (ptsEl) { + ptsEl.textContent = data.has_pts_session ? "已連線" : "未連線"; + ptsEl.className = `badge ${data.has_pts_session ? "ok" : "warn"}`; + } + const teams = data.teams_calendar || data.graph_calendar || {}; + const teamsEl = document.getElementById("teamsAuthStatus"); + if (teamsEl) { + if (!data.teams_calendar_enabled) { + teamsEl.textContent = "未啟用"; + teamsEl.className = "badge"; + } else if (teams.connected) { + teamsEl.textContent = teams.auth_mode === "graph_api" ? "Graph API" : "已連線"; + teamsEl.className = "badge ok"; + } else { + teamsEl.textContent = "未連線"; + teamsEl.className = "badge warn"; + } + } +} + +async function refreshStatus() { + try { + const data = await api("/api/status"); + renderAuthStatus(data); + } catch (err) { + console.error(err); + } +} + +async function loadSettings() { + const [settings, dates] = await Promise.all([ + api("/api/settings"), + api("/api/dates-file"), + ]); + fillSettingsForm(settings); + document.getElementById("datesFileContent").value = dates.content || ""; +} + +async function saveSettings(event) { + event.preventDefault(); + const statusEl = document.getElementById("settingsStatus"); + statusEl.textContent = "儲存中…"; + statusEl.className = "settings-status"; + const form = document.getElementById("settingsForm"); + const payload = collectSettingsPayload(form); + const datesContent = document.getElementById("datesFileContent").value; + try { + await api("/api/settings", { + method: "PUT", + body: JSON.stringify(payload), + }); + await api("/api/dates-file", { + method: "PUT", + body: JSON.stringify({ content: datesContent }), + }); + await loadSettings(); + await refreshStatus(); + statusEl.textContent = "已儲存"; + statusEl.className = "settings-status ok"; + } catch (err) { + statusEl.textContent = err.message; + statusEl.className = "settings-status err"; + } +} + +async function ptsLogin() { + const msg = document.getElementById("ptsLoginMsg"); + const username = document.getElementById("ptsUsername").value.trim(); + const password = document.getElementById("ptsPassword").value; + msg.textContent = "登入中…"; + msg.className = "settings-status"; + try { + const data = await api("/api/pts/login", { + method: "POST", + body: JSON.stringify({ username, password, remember: true }), + }); + document.getElementById("ptsPassword").value = ""; + msg.textContent = data.message || "登入成功"; + msg.className = "settings-status ok"; + await refreshStatus(); + } catch (err) { + msg.textContent = err.message; + msg.className = "settings-status err"; + } +} + +async function ptsLogout() { + const msg = document.getElementById("ptsLoginMsg"); + try { + await api("/api/pts/logout", { method: "POST" }); + msg.textContent = "已清除 session"; + msg.className = "settings-status"; + await refreshStatus(); + } catch (err) { + msg.textContent = err.message; + msg.className = "settings-status err"; + } +} + +let _mfaPreviewTimer = null; +let _mfaMetaTimer = null; + +function vncPageUrl(port) { + const host = window.location.hostname || "localhost"; + const p = port || 6080; + // autoconnect + scale to fit modal + return `http://${host}:${p}/vnc.html?autoconnect=1&resize=scale&reconnect=1`; +} + +function openTeamsMfaModal(view) { + const modal = document.getElementById("teamsMfaModal"); + if (!modal) return; + modal.classList.remove("hidden"); + const port = (view && view.vnc_port) || 6080; + const interactive = !!(view && view.interactive); + const link = document.getElementById("teamsVncLink"); + const frame = document.getElementById("teamsVncFrame"); + const url = vncPageUrl(port); + if (link) { + link.href = url; + link.style.display = interactive ? "" : "none"; + } + if (frame) { + if (interactive) { + frame.src = url; + frame.style.display = ""; + } else { + frame.removeAttribute("src"); + frame.style.display = "none"; + } + } + const hint = document.getElementById("teamsMfaHint"); + if (hint) { + hint.innerHTML = interactive + ? "帳密送出後,請在下方互動畫面完成驗證(可點數字、輸入驗證碼)。瀏覽器最多保持約 10 分鐘,完成前不會關掉。" + : "請在跳出的 Chromium 視窗完成 MFA。右側會顯示即時截圖(若有)。"; + } + const status = document.getElementById("teamsMfaStatus"); + if (status) { + status.textContent = (view && view.message) || "登入中…"; + status.className = "settings-status"; + } + startMfaPreviewPoll(); +} + +function closeTeamsMfaModal() { + stopMfaPreviewPoll(); + const modal = document.getElementById("teamsMfaModal"); + if (modal) modal.classList.add("hidden"); + const frame = document.getElementById("teamsVncFrame"); + if (frame) { + frame.removeAttribute("src"); + } +} + +function startMfaPreviewPoll() { + stopMfaPreviewPoll(); + const img = document.getElementById("teamsMfaShot"); + const meta = document.getElementById("teamsMfaShotMeta"); + const status = document.getElementById("teamsMfaStatus"); + const tick = () => { + if (img) { + img.src = `/api/teams/mfa-preview?t=${Date.now()}`; + } + }; + tick(); + _mfaPreviewTimer = setInterval(tick, 1500); + _mfaMetaTimer = setInterval(async () => { + try { + const view = await api("/api/teams/browser-view"); + if (status && view.login_message) { + status.textContent = view.login_message; + } + if (meta) { + const parts = []; + if (view.mfa_preview_label) parts.push(view.mfa_preview_label); + if (view.mfa_page_url) parts.push(view.mfa_page_url.replace(/^https?:\/\//, "").slice(0, 48)); + meta.textContent = parts.join(" · ") || "更新中"; + } + } catch { + /* ignore */ + } + }, 2000); +} + +function stopMfaPreviewPoll() { + if (_mfaPreviewTimer) { + clearInterval(_mfaPreviewTimer); + _mfaPreviewTimer = null; + } + if (_mfaMetaTimer) { + clearInterval(_mfaMetaTimer); + _mfaMetaTimer = null; + } +} + +async function teamsLogin() { + const msg = document.getElementById("teamsLoginMsg"); + const username = document.getElementById("teamsUsername").value.trim(); + const password = document.getElementById("teamsPassword").value; + if (!username || !password) { + msg.textContent = "請輸入 Microsoft 帳號與密碼"; + msg.className = "settings-status err"; + return; + } + msg.textContent = "登入中…(請在 MFA 畫面完成驗證)"; + msg.className = "settings-status"; + document.getElementById("teamsEnabled").checked = true; + + let view = {}; + try { + view = await api("/api/teams/browser-view"); + } catch { + view = { interactive: false, vnc_port: 6080 }; + } + openTeamsMfaModal(view); + + try { + await api("/api/settings", { + method: "PUT", + body: JSON.stringify({ teams_calendar_enabled: true }), + }); + const data = await api("/api/teams/login", { + method: "POST", + body: JSON.stringify({ username, password, remember: true, headless: false }), + }); + document.getElementById("teamsPassword").value = ""; + msg.textContent = data.message || "登入成功"; + msg.className = "settings-status ok"; + const st = document.getElementById("teamsMfaStatus"); + if (st) { + st.textContent = data.message || "登入成功"; + st.className = "settings-status ok"; + } + await refreshStatus(); + setTimeout(closeTeamsMfaModal, 1500); + } catch (err) { + msg.textContent = err.message; + msg.className = "settings-status err"; + const st = document.getElementById("teamsMfaStatus"); + if (st) { + st.textContent = err.message; + st.className = "settings-status err"; + } + } +} + +async function teamsLogout() { + const msg = document.getElementById("teamsLoginMsg"); + try { + await api("/api/teams/logout", { method: "POST" }); + msg.textContent = "已中斷"; + msg.className = "settings-status"; + await refreshStatus(); + } catch (err) { + msg.textContent = err.message; + msg.className = "settings-status err"; + } +} + +function escapeHtml(text) { + return String(text ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function initTeamsTestDates() { + const today = new Date().toISOString().slice(0, 10); + const startEl = document.getElementById("teamsTestStart"); + const endEl = document.getElementById("teamsTestEnd"); + if (startEl && !startEl.value) startEl.value = today; + if (endEl && !endEl.value) endEl.value = today; +} + +async function teamsShowStatus() { + const msg = document.getElementById("teamsTestMsg"); + const debug = document.getElementById("teamsTestDebug"); + try { + const data = await api("/api/teams/status"); + if (msg) { + msg.textContent = data.connected + ? `連線中(${data.auth_mode || "session"})` + : "未連線或 session 過期,請先登入 Teams"; + msg.className = `settings-status ${data.connected ? "ok" : "err"}`; + } + if (debug) debug.textContent = JSON.stringify(data, null, 2); + } catch (err) { + if (msg) { + msg.textContent = err.message; + msg.className = "settings-status err"; + } + } +} + +async function teamsTestMeetings() { + const msg = document.getElementById("teamsTestMsg"); + const summary = document.getElementById("teamsTestSummary"); + const list = document.getElementById("teamsTestList"); + const debug = document.getElementById("teamsTestDebug"); + const start = document.getElementById("teamsTestStart")?.value; + const end = document.getElementById("teamsTestEnd")?.value || start; + const skip = document.getElementById("teamsTestSkipHolidays")?.checked !== false; + + if (!start) { + if (msg) { + msg.textContent = "請選開始日期"; + msg.className = "settings-status err"; + } + return; + } + + if (msg) { + msg.textContent = "測試中…(可能開瀏覽器做 MFA)"; + msg.className = "settings-status"; + } + if (list) list.innerHTML = ""; + if (summary) summary.innerHTML = ""; + if (debug) debug.textContent = "請求中…"; + + try { + const q = new URLSearchParams({ + start_date: start, + end_date: end, + skip_holidays: skip ? "true" : "false", + debug: "true", + }); + const data = await api(`/api/teams/meetings?${q.toString()}`); + + if (summary) { + summary.innerHTML = [ + `會議${data.total_count ?? 0}`, + `時數${data.total_hours ?? 0}h`, + `天數${data.workday_count ?? 0}`, + `連線${data.connected ? "是" : "否"}`, + ].join(""); + } + + const days = (data.days || []).filter((d) => d.count > 0); + if (list) { + if (!days.length) { + list.innerHTML = `

    ${escapeHtml(data.fetch_hint || data.error || "0 筆會議")}

    `; + } else { + list.innerHTML = days + .map((day) => { + const rows = (day.meetings || []) + .map( + (m) => + `
  • ${escapeHtml(String(m.hours))}h` + + `${escapeHtml(m.time_label || "")}` + + `${escapeHtml(m.subject || "")}
  • ` + ) + .join(""); + return `
    ${escapeHtml(day.date)}` + + `${day.count} 場 · ${day.hours}h
    ` + + `
      ${rows}
    `; + }) + .join(""); + } + } + + if (msg) { + if (data.error) { + msg.textContent = data.error; + msg.className = "settings-status err"; + } else if ((data.total_count || 0) > 0) { + msg.textContent = `成功:${data.total_count} 場會議 · ${data.total_hours}h`; + msg.className = "settings-status ok"; + } else { + msg.textContent = data.fetch_hint || "完成但 0 筆會議"; + msg.className = "settings-status err"; + } + } + if (debug) debug.textContent = JSON.stringify(data, null, 2); + } catch (err) { + if (msg) { + msg.textContent = err.message; + msg.className = "settings-status err"; + } + if (debug) debug.textContent = err.message; + } +} + +document.getElementById("settingsForm").addEventListener("submit", saveSettings); +document.getElementById("ptsLoginBtn").addEventListener("click", ptsLogin); +document.getElementById("ptsLogoutBtn").addEventListener("click", ptsLogout); +document.getElementById("teamsLoginBtn").addEventListener("click", teamsLogin); +document.getElementById("teamsLogoutBtn").addEventListener("click", teamsLogout); +document.getElementById("teamsTestBtn")?.addEventListener("click", () => { + teamsTestMeetings().catch(console.error); +}); +document.getElementById("teamsStatusBtn")?.addEventListener("click", () => { + teamsShowStatus().catch(console.error); +}); +document.getElementById("teamsMfaClose")?.addEventListener("click", () => { + // Only hide preview; login request keeps running until MFA done or timeout + closeTeamsMfaModal(); +}); +document.getElementById("teamsMfaShot")?.addEventListener("error", () => { + /* 404 until first snapshot — ignore */ +}); + +initTeamsTestDates(); +loadSettings().then(refreshStatus).catch(console.error); diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..91dd1dd --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,1091 @@ +/* ---------- Fonts ---------- */ +/* TaipeiSansTC loaded via https://font.emtech.cc/css/TaipeiSansTC.css in HTML */ + +/* ---------- Themes (Pokémon Palette) ---------- */ +:root, +[data-theme="light"] { + color-scheme: light; + --bg: #fcfdfd; + --card: #ffffff; + --text: #152825; + --muted: #62847f; + --primary: #20b49c; + --primary-hover: #189a85; + --primary-foreground: #ffffff; + --secondary: #e8f6f3; + --secondary-hover: #d5efe9; + --secondary-foreground: #152825; + --accent: #ff7b73; + --success: #20b49c; + --warning: #e86b64; + --danger: #ef4444; + --border: #e0ebe9; + --input-bg: #f2f7f7; + --panel-bg: #f2f7f7; + --pill-bg: #f2f7f7; + --code-bg: #e8f3f1; + --ring: #22c3a8; + --shadow: 0 4px 16px rgba(21, 40, 37, 0.06); + --ok-bg: rgba(32, 180, 156, 0.12); + --ok-border: rgba(32, 180, 156, 0.35); + --warn-bg: rgba(255, 123, 115, 0.12); + --warn-border: rgba(255, 123, 115, 0.35); + --err-bg: rgba(239, 68, 68, 0.1); + --err-border: rgba(239, 68, 68, 0.3); +} + +[data-theme="dark"], +.dark { + color-scheme: dark; + --bg: #141a19; + --card: #1b2725; + --text: #f4f6f5; + --muted: #98b3b0; + --primary: #3cddc2; + --primary-hover: #2fc9b0; + --primary-foreground: #0a0a0a; + --secondary: #253735; + --secondary-hover: #2f4643; + --secondary-foreground: #f4f6f5; + --accent: #ff7c75; + --success: #3cddc2; + --warning: #ff7c75; + --danger: #f87171; + --border: #2d4340; + --input-bg: #141a19; + --panel-bg: #253735; + --pill-bg: #253735; + --code-bg: #253735; + --ring: #3cddc2; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + --ok-bg: rgba(60, 221, 194, 0.12); + --ok-border: rgba(60, 221, 194, 0.35); + --warn-bg: rgba(255, 124, 117, 0.12); + --warn-border: rgba(255, 124, 117, 0.35); + --err-bg: rgba(248, 113, 113, 0.12); + --err-border: rgba(248, 113, 113, 0.35); +} + +/* ---------- Reset / base ---------- */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + font-family: + "Inter", + "TaipeiSansTC", + "Taipei Sans TC", + "Noto Sans TC", + "PingFang TC", + "Microsoft JhengHei", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-feature-settings: "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--bg); + color: var(--text); + line-height: 1.55; + letter-spacing: 0.01em; + overflow-x: hidden; + transition: background-color 0.2s ease, color 0.2s ease; +} + +img, +svg, +video { + max-width: 100%; + height: auto; + display: block; +} + +h1, h2, h3 { + font-family: inherit; + letter-spacing: -0.015em; + overflow-wrap: anywhere; +} + +h1 { + margin: 0 0 0.2rem; + font-size: clamp(1.25rem, 2.5vw, 1.5rem); + font-weight: 700; + line-height: 1.25; +} + +h2 { + margin: 0 0 0.85rem; + font-size: 1.05rem; + font-weight: 600; + line-height: 1.3; +} + +h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +p { + overflow-wrap: anywhere; +} + +code, +.preview, +.date-list, +.meeting-time { + font-family: + "Inter", + ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +a { + color: var(--primary); + word-break: break-word; +} + +/* ---------- Layout ---------- */ +.container { + width: 100%; + max-width: 880px; + margin: 0 auto; + padding: 1.25rem 1rem 3rem; +} + +.container.narrow { + max-width: 640px; +} + +.topbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 0.85rem 1rem; + margin-bottom: 1.15rem; +} + +.topbar.compact { + margin-bottom: 1rem; +} + +.plan-view { + min-width: 0; +} + +.table-wrap { + overflow-x: auto; + margin-top: 0.5rem; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid var(--border, rgba(127, 127, 127, 0.25)); + vertical-align: top; +} + +.data-table th { + font-weight: 600; + opacity: 0.85; +} + +.data-table code { + font-size: 0.8em; +} + +.plan-day { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-bg); + padding: 0.7rem 0.85rem; + margin-bottom: 0.65rem; + min-width: 0; +} + +.plan-day-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.4rem; + font-size: 0.9rem; +} + +.plan-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + padding: 0.35rem 0; + border-top: 1px solid var(--border); + font-size: 0.88rem; + align-items: baseline; +} + +.mode-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + gap: 0.55rem; + margin-bottom: 0.9rem; +} + +.mode-option { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.7rem 0.8rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-bg); + cursor: pointer; + min-width: 0; +} + +.mode-option input { + width: auto; + margin: 0 0 0.25rem; +} + +.mode-option span { + font-weight: 600; + font-size: 0.92rem; +} + +.mode-option small { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.mode-option:has(input:checked) { + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary); +} + +.date-panel { + min-width: 0; +} + +.date-panel.hidden, +.hidden { + display: none !important; +} + +.topbar > div:first-child { + min-width: 0; + flex: 1 1 12rem; +} + +.topbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + flex: 0 1 auto; + min-width: 0; +} + +.subtitle { + color: var(--muted); + margin: 0; + font-size: 0.9rem; +} + +/* ---------- Nav / theme ---------- */ +.nav { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + align-items: center; +} + +.nav-link { + color: var(--muted); + text-decoration: none; + padding: 0.45rem 0.85rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.88rem; + border: 1px solid var(--border); + background: var(--card); + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.nav-link:hover { + color: var(--text); + background: var(--panel-bg); +} + +.nav-link.active { + color: var(--text); + background: var(--card); + border-color: var(--border); + box-shadow: var(--shadow); +} + +.theme-toggle { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.15rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.18rem; + box-shadow: var(--shadow); + max-width: 100%; +} + +.theme-btn { + border: none; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 0.75rem; + font-weight: 600; + padding: 0.32rem 0.55rem; + border-radius: 999px; + cursor: pointer; + line-height: 1.2; + white-space: nowrap; +} + +.theme-btn:hover { + color: var(--text); +} + +.theme-btn.active { + background: var(--primary); + color: var(--primary-foreground); +} + +/* ---------- Cards ---------- */ +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.1rem 1.15rem; + margin-bottom: 0.85rem; + box-shadow: var(--shadow); + min-width: 0; + overflow: hidden; +} + +.muted-card { + opacity: 0.95; +} + +.status-strip { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.status-strip > div:first-child { + min-width: 0; + flex: 1 1 14rem; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; +} + +.section-head h2 { + margin: 0; + min-width: 0; +} + +/* ---------- Pills / badges ---------- */ +.pills, +.meetings-summary { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + min-width: 0; +} + +.meetings-summary { + margin-bottom: 0.75rem; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + background: var(--pill-bg); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.28rem 0.65rem; + font-size: 0.8rem; + min-width: 0; +} + +.pill.ok { + border-color: var(--ok-border); + background: var(--ok-bg); +} + +.pill.warn { + border-color: var(--warn-border); + background: var(--warn-bg); +} + +.pill-label { + color: var(--muted); + flex-shrink: 0; +} + +.pill-value { + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 12rem; +} + +.badge { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 0.28rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge.ok { + background: var(--ok-bg); + color: var(--success); + border-color: var(--ok-border); +} + +.badge.warn { + background: var(--warn-bg); + color: var(--warning); + border-color: var(--warn-border); +} + +.badge.err { + background: var(--err-bg); + color: var(--danger); + border-color: var(--err-border); +} + +/* ---------- Forms ---------- */ +.form-row { + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + align-items: flex-end; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + min-width: 0; +} + +.form-row > label { + flex: 1 1 9rem; + min-width: 8rem; + max-width: 100%; +} + +.hint { + color: var(--muted); + font-size: 0.85rem; + margin: 0 0 0.9rem; + overflow-wrap: anywhere; +} + +.hint.small { + margin-top: 0.5rem; + margin-bottom: 0; + font-size: 0.8rem; +} + +.settings-form fieldset { + border: 1px solid var(--border); + border-radius: 10px; + margin: 0 0 0.85rem; + padding: 0.85rem; + background: var(--panel-bg); + min-width: 0; +} + +.settings-form legend { + padding: 0 0.4rem; + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + gap: 0.75rem; + min-width: 0; +} + +.settings-grid > * { + min-width: 0; +} + +.settings-grid .full-span, +.hint.small.full-span { + grid-column: 1 / -1; + margin: 0; +} + +.settings-grid .checkbox-row, +.checkbox-row { + flex-direction: row; + align-items: center; + gap: 0.5rem; + padding-top: 0.25rem; +} + +.checkbox, +.dry-run-row { + flex-direction: row; + align-items: center; + gap: 0.45rem; + color: var(--muted); + padding-bottom: 0.1rem; + flex: 0 1 auto; + min-width: auto; +} + +input[type="text"], +input[type="url"], +input[type="password"], +input[type="number"], +input[type="date"], +textarea, +.settings-form input[type="text"], +.settings-form input[type="url"], +.settings-form input[type="password"], +.settings-form input[type="number"] { + width: 100%; + max-width: 100%; + min-width: 0; + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 0.5rem 0.7rem; + font-family: inherit; + font-size: 0.92rem; + line-height: 1.4; +} + +input[type="date"] { + min-height: 2.4rem; +} + +input:focus, +textarea:focus { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +input::placeholder, +textarea::placeholder { + color: var(--muted); + opacity: 0.85; +} + +.settings-status { + color: var(--muted); + font-size: 0.85rem; + overflow-wrap: anywhere; + min-width: 0; +} + +.settings-status.ok { color: var(--success); } +.settings-status.err { + color: var(--danger); + margin: 0.55rem 0 0; + display: block; +} + +#statusError[hidden] { + display: none !important; +} + +/* ---------- Buttons / actions ---------- */ +.actions { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; + align-items: center; + margin-top: 0.85rem; + min-width: 0; +} + +.actions.tight { + margin-top: 0; +} + +.btn { + border: none; + border-radius: 8px; + padding: 0.48rem 0.9rem; + font-weight: 600; + cursor: pointer; + font-size: 0.88rem; + font-family: inherit; + line-height: 1.25; + white-space: nowrap; + transition: background 0.15s ease, transform 0.05s ease, opacity 0.15s ease; +} + +.btn:active { + transform: translateY(1px); +} + +.btn.primary { + background: var(--primary); + color: var(--primary-foreground); +} + +.btn.primary:hover { + background: var(--primary-hover); +} + +.btn.secondary { + background: var(--secondary); + color: var(--secondary-foreground); + border: 1px solid var(--border); +} + +.btn.secondary:hover { + background: var(--secondary-hover); +} + +a.btn { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* ---------- Details / lists ---------- */ +.more-dates { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} + +.more-dates summary { + cursor: pointer; + color: var(--muted); + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.date-list { + width: 100%; + margin-top: 0.35rem; + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 0.75rem; + font-size: 0.85rem; + resize: vertical; + min-height: 6rem; +} + +.result-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.result-grid > div { + min-width: 0; +} + +.preview { + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.8rem; + overflow: auto; + max-height: 320px; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + margin: 0; + max-width: 100%; +} + +.subhead { + margin: 0 0 0.4rem; + font-size: 0.85rem; + color: var(--muted); + font-weight: 600; +} + +.task-list { + list-style: none; + padding: 0; + margin: 0.85rem 0 0; +} + +.task-list li { + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.88rem; + overflow-wrap: anywhere; +} + +.task-list .tag, +.meeting-subject .tag { + display: inline-block; + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + background: var(--ok-bg); + color: var(--primary); + margin-right: 0.3rem; + font-weight: 600; + vertical-align: middle; +} + +.task-list .err { + color: var(--danger); +} + +/* ---------- Auth panels ---------- */ +.auth-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + gap: 0.85rem; + min-width: 0; +} + +.auth-panel { + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem; + min-width: 0; + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; + min-width: 0; +} + +.panel-head h3 { + min-width: 0; +} + +.install-steps { + margin: 0.75rem 0 0; + padding-left: 1.2rem; + color: var(--muted); + font-size: 0.88rem; +} + +.install-steps li { + margin-bottom: 0.3rem; + overflow-wrap: anywhere; +} + +.install-steps code { + background: var(--code-bg); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; + word-break: break-all; +} + +/* ---------- Meetings ---------- */ +.muted-inline { + color: var(--muted); + font-size: 0.82rem; + overflow-wrap: anywhere; + min-width: 0; +} + +.meetings-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; +} + +.meeting-day { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-bg); + padding: 0.7rem 0.85rem; + min-width: 0; + overflow: hidden; +} + +.meeting-day-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.45rem; + font-size: 0.9rem; +} + +.meeting-items { + list-style: none; + margin: 0; + padding: 0; +} + +.meeting-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.75rem; + padding: 0.4rem 0; + border-top: 1px solid var(--border); + font-size: 0.88rem; + min-width: 0; +} + +.meeting-time { + color: var(--muted); + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + min-width: 5.5rem; +} + +.meeting-hours { + font-weight: 700; + color: var(--primary); + flex: 0 0 auto; + min-width: 2.5rem; +} + +.meeting-subject { + flex: 1 1 10rem; + min-width: 0; + overflow-wrap: anywhere; +} + +/* ---------- Responsive ---------- */ +@media (max-width: 720px) { + .container { + padding: 1rem 0.85rem 2.5rem; + } + + .result-grid { + grid-template-columns: 1fr; + } + + .topbar-actions { + width: 100%; + justify-content: space-between; + } + + .theme-btn { + padding: 0.3rem 0.45rem; + font-size: 0.72rem; + } + + .btn { + padding: 0.48rem 0.75rem; + } + + .card { + padding: 0.95rem; + } + + .pill-value { + max-width: 8rem; + } +} + +@media (max-width: 420px) { + .topbar-actions { + flex-direction: column; + align-items: stretch; + } + + .theme-toggle, + .nav { + width: 100%; + justify-content: center; + } + + .actions .btn, + .actions a.btn { + flex: 1 1 auto; + text-align: center; + } +} + +/* ---------- Teams MFA modal (noVNC + screenshot) ---------- */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(15, 25, 23, 0.55); + backdrop-filter: blur(4px); +} + +.modal.hidden { + display: none; +} + +.modal-card { + background: var(--card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); + max-width: 520px; + width: 100%; + max-height: min(92vh, 900px); + overflow: auto; + padding: 1.1rem 1.25rem 1.25rem; +} + +.modal-card.modal-wide { + max-width: min(1100px, 96vw); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.modal-head h2 { + margin: 0; + font-size: 1.15rem; +} + +.btn-sm { + padding: 0.35rem 0.7rem; + font-size: 0.85rem; +} + +.mfa-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 0.85rem; + margin-top: 0.75rem; +} + +@media (max-width: 800px) { + .mfa-grid { + grid-template-columns: 1fr; + } +} + +.mfa-panel { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-bg); + padding: 0.55rem; + min-height: 0; +} + +.mfa-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.4rem; + font-size: 0.9rem; +} + +.mfa-frame { + width: 100%; + height: min(52vh, 420px); + border: 1px solid var(--border); + border-radius: 8px; + background: #111; +} + +.mfa-shot { + display: block; + width: 100%; + height: min(52vh, 420px); + object-fit: contain; + border: 1px solid var(--border); + border-radius: 8px; + background: #0b1210; +} + +.linkish { + color: var(--primary); + font-size: 0.85rem; + text-decoration: none; +} + +.linkish:hover { + text-decoration: underline; +} + +.muted-inline { + color: var(--muted); + font-size: 0.8rem; +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..29106b8 --- /dev/null +++ b/run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ ! -d ".venv" ]; then + python3 -m venv .venv +fi + +source .venv/bin/activate +pip install -q -r backend/requirements.txt +cd backend +python main.py \ No newline at end of file diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh new file mode 100755 index 0000000..f2713e0 --- /dev/null +++ b/scripts/docker-start.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Start PTS in Docker with zero host data persistence (tmpfs /data only). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "==> 關閉本地 Python 伺服器(避免佔用 8765)" +"$ROOT/scripts/server.sh" stop 2>/dev/null || true +pgrep -f "$ROOT/backend.*main.py" 2>/dev/null | xargs kill 2>/dev/null || true + +echo "==> 建置並啟動 Docker(無 volume、不寫入本機 pts-data;.dockerignore 排除 secrets)" +cd "$ROOT" +# Full stop: remove container; no host data mount to clean +docker compose down --remove-orphans 2>/dev/null || true +# Rebuild so COPY respects latest .dockerignore +docker compose build --pull +docker compose up -d --force-recreate --remove-orphans + +PORT="${BACKEND_PORT:-8765}" + +echo "" +echo "吉八小 · 集滿八小時(容器,不留本機資料)已啟動" +echo " Web UI: http://localhost:$PORT" +echo " 資料: 僅容器內 tmpfs /data(停止/刪除容器即消失,不寫本機)" +echo " 查看 log: make docker-logs" +echo " 停止: make docker-down" +echo "" +echo "首次請在網頁登入 PTS(與選用 Teams);帳密只留在容器記憶體,不會存到主機。" diff --git a/scripts/server.sh b/scripts/server.sh new file mode 100755 index 0000000..c08353c --- /dev/null +++ b/scripts/server.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PID_FILE="$ROOT/backend/data/server.pid" +LOG_FILE="$ROOT/backend/data/server.log" +PYTHON="$ROOT/.venv/bin/python" +PORT="8765" + +read_port() { + if [ -f "$ROOT/.env" ]; then + local value + value="$(grep -E '^BACKEND_PORT=' "$ROOT/.env" | tail -1 | cut -d= -f2 | tr -d ' \r' || true)" + if [ -n "$value" ]; then + PORT="$value" + fi + fi +} + +is_running() { + if [ ! -f "$PID_FILE" ]; then + return 1 + fi + local pid + pid="$(cat "$PID_FILE")" + kill -0 "$pid" 2>/dev/null +} + +start_server() { + read_port + + if [ ! -x "$PYTHON" ]; then + echo "Missing virtualenv. Run: make setup" + exit 1 + fi + + if is_running; then + echo "Server already running (PID $(cat "$PID_FILE")) — http://localhost:$PORT" + exit 0 + fi + + mkdir -p "$(dirname "$PID_FILE")" + cd "$ROOT/backend" + PTS_RELOAD=false nohup "$PYTHON" main.py >>"$LOG_FILE" 2>&1 & + echo $! >"$PID_FILE" + sleep 1 + + if is_running; then + echo "Started PID $(cat "$PID_FILE") — http://localhost:$PORT" + echo "Log: $LOG_FILE" + exit 0 + fi + + echo "Failed to start server. Check $LOG_FILE" + rm -f "$PID_FILE" + exit 1 +} + +stop_server() { + read_port + local pid="" + + if is_running; then + pid="$(cat "$PID_FILE")" + kill "$pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.3 + done + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" + echo "Stopped server (PID $pid)" + return 0 + fi + + rm -f "$PID_FILE" + local pids="" + while IFS= read -r pid; do + [ -n "$pid" ] || continue + local cmd + cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" + case "$cmd" in + *"$ROOT/backend"*|*main.py*|*uvicorn*main:app*) + pids="$pids $pid" + ;; + esac + done < <(lsof -ti :"$PORT" 2>/dev/null || true) + + pids="$(echo "$pids" | xargs)" + if [ -n "$pids" ]; then + echo "$pids" | xargs kill 2>/dev/null || true + sleep 0.5 + echo "$pids" | xargs kill -9 2>/dev/null || true + echo "Stopped local PTS server on port $PORT" + return 0 + fi + + echo "Server not running" +} + +server_status() { + read_port + if is_running; then + echo "Running PID $(cat "$PID_FILE") — http://localhost:$PORT" + exit 0 + fi + echo "Not running" + exit 1 +} + +case "${1:-}" in + start) start_server ;; + stop) stop_server ;; + restart) + stop_server || true + sleep 0.5 + start_server + ;; + status) server_status ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac \ No newline at end of file