This commit is contained in:
王性驊 2026-07-17 14:55:36 +08:00
commit 863c92cad9
51 changed files with 8911 additions and 0 deletions

56
.dockerignore Normal file
View File

@ -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

38
.env.example Normal file
View File

@ -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

36
.gitignore vendored Normal file
View File

@ -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

47
Dockerfile Normal file
View File

@ -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"]

118
Makefile Normal file
View File

@ -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"

137
README.md Normal file
View File

@ -0,0 +1,137 @@
# 吉八小
**集滿八小時** — 自動從 GitLab 任務看板抓取工作項目,用 **Grok LLM** 分析後填入 [PTS 工時系統](https://tw-timesheet.supermicro.com/PTS/)。可選併入 **Teams / Outlook 網頁行事曆**會議時數。
## 功能
- 網頁輸入 **PTS 帳密** → 直接打 PTS Login APIWindows/NTLM可選 Forms
- 可選:網頁輸入 **Microsoft 帳密** → 優先 Graph 帳密 APIMFA 時改瀏覽器登入,之後仍打行事曆 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 UIhttp://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
```

196
backend/cli.py Normal file
View File

@ -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()))

209
backend/config.py Normal file
View File

@ -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()

447
backend/main.py Normal file
View File

@ -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,
)

21
backend/paths.py Normal file
View File

@ -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"

11
backend/requirements.txt Normal file
View File

@ -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

View File

View File

@ -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}."

View File

@ -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'<input[^>]+name=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']',
html,
re.I,
)
if not m:
m = re.search(
rf'<input[^>]+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'<select[^>]*name=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</select>',
html,
re.I,
):
name, body = m.group(1), m.group(2)
selected = re.search(
r'<option[^>]*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'<option[^>]*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"<input([^>]+)/?>", 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"&nbsp;", " ", text, flags=re.I)
return re.sub(r"\s+", " ", text).strip()
def _parse_table2(self, html: str) -> list[AbnormalRow]:
m = re.search(r'<table[^>]*id=["\']Table2["\'][^>]*>([\s\S]*?)</table>', html, re.I)
if not m:
return []
body = m.group(1)
rows: list[AbnormalRow] = []
for tr in re.finditer(r"<tr[^>]*>([\s\S]*?)</tr>", body, re.I):
cells = re.findall(r"<t[dh][^>]*>([\s\S]*?)</t[dh]>", 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'<span[^>]+id=["\']{re.escape(element_id)}["\'][^>]*>([\s\S]*?)</span>',
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'<select[^>]*name=["\']{re.escape(select_name)}["\'][^>]*>([\s\S]*?)</select>',
html,
re.I,
)
if not m:
return []
opts: list[str] = []
for om in re.finditer(r"<option[^>]*>([\s\S]*?)</option>", 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'<input[^>]+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]*?)</span>',
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

View File

@ -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

View File

@ -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}"

View File

@ -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

View File

@ -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()

View File

@ -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)."
)

View File

@ -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],
}

View File

@ -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, 1225 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, 1225 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, 1225 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),
}

View File

@ -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

View File

@ -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

214
backend/services/planner.py Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

@ -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()

View File

@ -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)

View File

@ -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 = ""

View File

@ -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")

View File

@ -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

View File

@ -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)

File diff suppressed because it is too large Load Diff

1
dates.txt Normal file
View File

@ -0,0 +1 @@
2025-12-24

30
docker-compose.yml Normal file
View File

@ -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"
# noVNCTeams 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"

82
docker/entrypoint.sh Executable file
View File

@ -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 "$@"

10
extension/background.js Normal file
View File

@ -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;
}
});

18
extension/content.js Normal file
View File

@ -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;
});

26
extension/manifest.json Normal file
View File

@ -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"
}
}

22
extension/popup.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<style>
body { font-family: system-ui, sans-serif; width: 320px; margin: 0; padding: 16px; }
h1 { font-size: 16px; margin: 0 0 12px; }
label { display: block; font-size: 12px; margin-bottom: 4px; color: #555; }
input { width: 100%; padding: 8px; margin-bottom: 12px; box-sizing: border-box; }
button { width: 100%; padding: 10px; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }
#status { margin-top: 12px; font-size: 12px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>吉八小 · 憑證同步</h1>
<label for="backendUrl">後端網址</label>
<input id="backendUrl" type="text" value="http://localhost:8765" />
<button id="syncBtn">同步登入憑證到後端</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>

105
extension/popup.js Normal file
View File

@ -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}`;
}
});

286
frontend/app.js Normal file
View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = `<p class="hint">沒有可填日期。區間模式會跳過假日;指定多日請確認日期格式。</p>`;
return;
}
const days = workdays.length ? workdays : Object.keys(plan).sort();
const dayBlocks = days
.map((day) => {
const entries = plan[day] || [];
if (!entries.length) {
return `<div class="plan-day">
<div class="plan-day-head"><strong>${escapeHtml(day)}</strong><span class="muted-inline"></span></div>
</div>`;
}
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
? '<span class="tag">會議</span>'
: e.gitlab_task_id
? '<span class="tag">GitLab</span>'
: "";
return `<li class="plan-row">
<span class="meeting-hours">${escapeHtml(String(e.take_hours))}h</span>
<span class="meeting-subject">${tag}${escapeHtml(e.pts_task_name || "")} · ${escapeHtml(desc)}</span>
</li>`;
})
.join("");
return `<div class="plan-day">
<div class="plan-day-head">
<strong>${escapeHtml(day)}</strong>
<span class="muted-inline">${entries.length} · ${hours}h</span>
</div>
<ul class="meeting-items">${rows}</ul>
</div>`;
})
.join("");
const title = filled ? "已填寫" : "預覽";
const dry = data.dry_run ? "(試跑)" : "";
root.innerHTML = `
<div class="meetings-summary">
${pill(title + dry, source || "ok", true)}
${pill("天數", String(days.length), null)}
${pill("GitLab", String(gitlabN), null)}
${meetingNote ? pill("Teams", meetingNote, meetings.error ? false : null) : ""}
</div>
<div class="meetings-list">${dayBlocks}</div>
`;
}
async function runPreview() {
const mode = getDateMode();
setActionStatus("預覽中…");
document.getElementById("planView").innerHTML = `<p class="hint">會議 + GitLab 分配中…</p>`;
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 =
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
setActionStatus(err.message, false);
}
}
async function runFill() {
const mode = getDateMode();
const dryRun = document.getElementById("dryRun").checked;
setActionStatus(`${dryRun ? "試跑" : "填寫"}中…`);
document.getElementById("planView").innerHTML = `<p class="hint">處理中…</p>`;
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 = `
<div class="meetings-summary">
${pill(dryRun ? "試跑完成" : "填寫完成", "ok", true)}
${pill("新增", String((data.created || []).length), null)}
${pill("略過", String((data.skipped || []).length), null)}
</div>
<pre class="preview">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
`;
}
setActionStatus(dryRun ? "試跑完成" : "填寫完成", true);
await refreshConn();
} catch (err) {
document.getElementById("planView").innerHTML =
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
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);

76
frontend/common.js Normal file
View File

@ -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 `<span class="pill ${cls}"><span class="pill-label">${label}</span><span class="pill-value">${value}</span></span>`;
}
// apply ASAP if script loads after head bootstrap
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initTheme);
} else {
initTheme();
}

87
frontend/compbase.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>刷卡補填 · 吉八小</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
<script>
(function () {
var p = localStorage.getItem("pts_theme") || "system";
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.classList.toggle("dark", t === "dark");
})();
</script>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container narrow">
<header class="topbar compact">
<div>
<h1>刷卡補填</h1>
<p class="subtitle" id="connLine">檢查 CompBase 連線…</p>
</div>
<div class="topbar-actions">
<div class="theme-toggle" title="外觀">
<button type="button" class="theme-btn" data-theme-option="light"></button>
<button type="button" class="theme-btn" data-theme-option="dark"></button>
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
</div>
<nav class="nav">
<a href="/" class="nav-link">填工時</a>
<a href="/compbase" class="nav-link active">刷卡補填</a>
<a href="/settings" class="nav-link">設定</a>
</nav>
</div>
</header>
<section class="card">
<h2>一鍵補漏刷退</h2>
<p class="hint">
登入 CompBase → 出勤檢視 → 找出「應刷未刷」→ 自動選正常下班時間送出。
與 PTS 工時無關;預設使用設定裡的 PTS 帳密做 NTLM。
</p>
<div class="form-row">
<label>
近 N 天
<input type="number" id="days" min="1" max="62" value="14" />
</label>
<label>
刷退時間
<select id="outTimeMode">
<option value="expected">正常工時下班(推薦)</option>
<option value="latest_option">下拉最後一個可用時間</option>
</select>
</label>
</div>
<label class="checkbox dry-run-row">
<input type="checkbox" id="dryRun" checked />
試跑(只掃描/預覽,不送出)
</label>
<div class="actions">
<button type="button" id="scanBtn" class="btn secondary">掃描</button>
<button type="button" id="fillBtn" class="btn primary">一鍵補填</button>
</div>
<p id="actionStatus" class="settings-status"></p>
</section>
<section class="card">
<h2>結果</h2>
<div id="resultView" class="plan-view">
<p class="hint">選天數後按「掃描」或「一鍵補填」。</p>
</div>
</section>
</div>
<script src="/static/common.js"></script>
<script src="/static/compbase.js"></script>
</body>
</html>

201
frontend/compbase.js Normal file
View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(`<p class="muted-inline">${summaryParts.join(" · ")}</p>`);
function table(title, rows, kind) {
if (!rows.length) return "";
const body = rows
.map((r) => {
return `<tr>
<td>${escapeHtml(r.date || "—")}</td>
<td><code>${escapeHtml(r.rid || "—")}</code></td>
<td>${escapeHtml(r.actual_in || r.in_time || "—")}</td>
<td>${escapeHtml(r.exp_out || "—")}</td>
<td>${rowStatusLabel(r, kind)}</td>
</tr>`;
})
.join("");
return `<div class="plan-day">
<div class="plan-day-head"><strong>${escapeHtml(title)}</strong><span class="muted-inline">${rows.length}</span></div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>日期</th><th>RID</th><th></th><th></th><th></th></tr></thead>
<tbody>${body}</tbody>
</table>
</div>
</div>`;
}
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(`<p class="hint">近 ${escapeHtml(String(data.days || ""))} 天沒有可處理的漏刷退。</p>`);
}
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();
}

119
frontend/index.html Normal file
View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>吉八小 · 集滿八小時</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
<script>
(function () {
var p = localStorage.getItem("pts_theme") || "system";
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.classList.toggle("dark", t === "dark");
})();
</script>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container narrow">
<header class="topbar compact">
<div>
<h1>吉八小</h1>
<p class="subtitle">集滿八小時 · <span id="connLine">檢查連線…</span></p>
</div>
<div class="topbar-actions">
<div class="theme-toggle" title="外觀">
<button type="button" class="theme-btn" data-theme-option="light"></button>
<button type="button" class="theme-btn" data-theme-option="dark"></button>
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
</div>
<nav class="nav">
<a href="/" class="nav-link active">填工時</a>
<a href="/compbase" class="nav-link">刷卡補填</a>
<a href="/settings" class="nav-link">設定</a>
</nav>
</div>
</header>
<section class="card">
<h2>1. 選日期</h2>
<div class="mode-row">
<label class="mode-option">
<input type="radio" name="dateMode" value="range" checked />
<span>日期區間</span>
<small>自動跳過週末/台灣假日</small>
</label>
<label class="mode-option">
<input type="radio" name="dateMode" value="list" />
<span>指定多日</span>
<small>照填,不跳假日</small>
</label>
</div>
<div id="rangePanel" class="date-panel">
<div class="form-row">
<label>
開始
<input type="date" id="startDate" />
</label>
<label>
結束
<input type="date" id="endDate" />
</label>
</div>
<div class="actions tight">
<button type="button" id="setTodayBtn" class="btn secondary">今天</button>
</div>
</div>
<div id="listPanel" class="date-panel hidden">
<label>
日期清單一行一個YYYY-MM-DD
<textarea id="dateList" class="date-list" rows="5" placeholder="2026-07-12&#10;2026-07-13&#10;2026-07-19"></textarea>
</label>
<p class="hint small">無論週末或台灣假日都會填這些日期。</p>
</div>
<p id="rangeMeta" class="muted-inline"></p>
</section>
<section class="card">
<h2>2. 預覽 / 填寫</h2>
<p class="hint">Teams 會議 + GitLab issue → 分配時數 → 寫入 PTS。</p>
<label class="checkbox dry-run-row">
<input type="checkbox" id="dryRun" />
試跑(不真的送出)
</label>
<div class="actions">
<button type="button" id="previewBtn" class="btn secondary">預覽</button>
<button type="button" id="fillBtn" class="btn primary">填寫</button>
</div>
<p id="actionStatus" class="settings-status"></p>
</section>
<section class="card">
<h2>CompBase 刷卡補填</h2>
<p class="hint">掃出勤「應刷未刷」、一鍵補刷退(與上方 PTS 工時無關)。</p>
<div class="actions">
<a href="/compbase" class="btn primary">前往刷卡補填</a>
</div>
</section>
<section class="card">
<h2>3. 結果</h2>
<div id="planView" class="plan-view">
<p class="hint">選好日期後按「預覽」或「填寫」。</p>
</div>
</section>
</div>
<script src="/static/common.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

245
frontend/settings.html Normal file
View File

@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>設定 · 吉八小</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
<script>
(function () {
var p = localStorage.getItem("pts_theme") || "system";
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.classList.toggle("dark", t === "dark");
})();
</script>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container">
<header class="topbar">
<div>
<h1>設定</h1>
<p class="subtitle">吉八小 · 帳號連線與系統參數</p>
</div>
<div class="topbar-actions">
<div class="theme-toggle" title="外觀">
<button type="button" class="theme-btn" data-theme-option="light">淺色</button>
<button type="button" class="theme-btn" data-theme-option="dark">深色</button>
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
</div>
<nav class="nav">
<a href="/" class="nav-link">填工時</a>
<a href="/compbase" class="nav-link">刷卡補填</a>
<a href="/settings" class="nav-link active">設定</a>
</nav>
</div>
</header>
<section class="card">
<h2>帳號連線</h2>
<p class="hint">輸入帳密登入。能打 API 就直接打;需 MFA 時才開瀏覽器。密碼加密存本機。</p>
<div class="auth-grid">
<div class="auth-panel">
<div class="panel-head">
<h3>PTS</h3>
<div id="ptsAuthStatus" class="badge warn">檢查中…</div>
</div>
<div class="settings-grid">
<label>帳號<input type="text" id="ptsUsername" placeholder="DOMAIN\user" autocomplete="username" /></label>
<label>密碼<input type="password" id="ptsPassword" placeholder="密碼" autocomplete="current-password" /></label>
</div>
<div class="actions">
<button type="button" id="ptsLoginBtn" class="btn primary">登入</button>
<button type="button" id="ptsLogoutBtn" class="btn secondary">清除 Session</button>
</div>
<p id="ptsLoginMsg" class="settings-status"></p>
</div>
<div class="auth-panel">
<div class="panel-head">
<h3>Teams 行事曆</h3>
<div id="teamsAuthStatus" class="badge">選配</div>
</div>
<div class="settings-grid">
<label class="checkbox-row"><input type="checkbox" id="teamsEnabled" /> 啟用會議併入工時</label>
<label>Microsoft 帳號<input type="text" id="teamsUsername" placeholder="you@company.com" autocomplete="username" /></label>
<label>密碼<input type="password" id="teamsPassword" placeholder="密碼" autocomplete="current-password" /></label>
</div>
<div class="actions">
<button type="button" id="teamsLoginBtn" class="btn primary">登入</button>
<button type="button" id="teamsLogoutBtn" class="btn secondary">中斷</button>
</div>
<p id="teamsLoginMsg" class="settings-status"></p>
<p class="hint small">Docker登入會開虛擬 Chrome可用互動畫面完成 MFA最多約 10 分鐘,不會中途關掉)。</p>
</div>
</div>
</section>
<!-- Teams MFAnoVNC 互動 + 截圖預覽 -->
<div id="teamsMfaModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="teamsMfaTitle">
<div class="modal-card modal-wide">
<div class="modal-head">
<h2 id="teamsMfaTitle">Teams 驗證MFA</h2>
<button type="button" id="teamsMfaClose" class="btn secondary btn-sm">關閉預覽</button>
</div>
<p class="hint" id="teamsMfaHint">
帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。
瀏覽器會保持開啟直到登入成功或逾時,請勿急著關掉。
</p>
<p class="settings-status" id="teamsMfaStatus">準備中…</p>
<div class="mfa-grid">
<div class="mfa-panel">
<div class="mfa-panel-head">
<strong>互動畫面</strong>
<a id="teamsVncLink" class="linkish" href="#" target="_blank" rel="noopener">新分頁開啟</a>
</div>
<iframe id="teamsVncFrame" class="mfa-frame" title="Teams 瀏覽器互動畫面" allow="clipboard-read; clipboard-write"></iframe>
<p class="hint small">若 iframe 空白,請用「新分頁開啟」。埠預設 6080。</p>
</div>
<div class="mfa-panel">
<div class="mfa-panel-head">
<strong>即時截圖</strong>
<span class="muted-inline" id="teamsMfaShotMeta"></span>
</div>
<img id="teamsMfaShot" class="mfa-shot" alt="MFA 截圖預覽" />
<p class="hint small">約每 1.5 秒更新,方便對號/看驗證畫面。</p>
</div>
</div>
</div>
</div>
<section class="card">
<h2>測試Teams 抓會議</h2>
<p class="hint">只測行事曆爬蟲(不走 Graph API。先「登入 Teams」完成 MFA再選日期測試。不寫 PTS、不碰 GitLab。</p>
<div class="form-row">
<label>
開始
<input type="date" id="teamsTestStart" />
</label>
<label>
結束
<input type="date" id="teamsTestEnd" />
</label>
<label class="checkbox dry-run-row">
<input type="checkbox" id="teamsTestSkipHolidays" checked />
跳過假日
</label>
</div>
<div class="actions">
<button type="button" id="teamsTestBtn" class="btn primary">測試抓會議</button>
<button type="button" id="teamsStatusBtn" class="btn secondary">連線狀態</button>
</div>
<p id="teamsTestMsg" class="settings-status"></p>
<div id="teamsTestSummary" class="meetings-summary"></div>
<div id="teamsTestList" class="meetings-list"></div>
<pre id="teamsTestDebug" class="preview">尚無測試結果</pre>
</section>
<section class="card">
<h2>系統參數</h2>
<form id="settingsForm" class="settings-form">
<fieldset>
<legend>GitLab</legend>
<div class="settings-grid">
<label>URL<input type="url" name="gitlab_url" /></label>
<label>GitLab Personal Access Token
<input type="password" name="gitlab_token" placeholder="貼上 token" autocomplete="off" />
</label>
<p id="gitlabTokenHint" class="hint small full-span"></p>
<label>Project Path<input type="text" name="gitlab_project_path" /></label>
<label>Board ID<input type="number" name="gitlab_board_id" min="1" /></label>
<label>Board Labels<input type="text" name="gitlab_board_labels" /></label>
<label>Milestone<input type="text" name="gitlab_milestone_title" placeholder="留空自動" /></label>
</div>
</fieldset>
<fieldset>
<legend>工時填寫</legend>
<div class="settings-grid">
<label>PTS URL<input type="url" name="pts_url" /></label>
<label>Project Name<input type="text" name="pts_project_name" /></label>
<label>Task Type<input type="text" name="pts_default_task_name" /></label>
<label>Max Hours / Day<input type="number" name="max_hours_per_day" min="0.5" max="24" step="0.5" /></label>
<label>Hour Step<input type="number" name="hour_step" min="0.5" max="8" step="0.5" /></label>
<label>時區<input type="text" name="teams_calendar_timezone" /></label>
<label>會議 Task 預設<input type="text" name="teams_meeting_task_name" /></label>
<label class="full-span">排除加入 PTS 的會議關鍵字(逗號分隔,符合的會議仍會顯示在下方列表,但不會寫入 PTS
<input type="text" name="teams_meeting_exclude_keywords" placeholder="例如:貼心的提醒,請記得下班刷退" />
</label>
</div>
</fieldset>
<fieldset>
<legend>CompBase 刷卡補填(選填)</legend>
<p class="hint small full-span">預設沿用上方 PTS 帳密做 NTLM。僅在帳號不同時才填這裡。</p>
<div class="settings-grid">
<label>List URL<input type="url" name="compbase_list_url" /></label>
<label>Fill URL<input type="url" name="compbase_fill_url" /></label>
<label>帳號<input type="text" name="compbase_username" placeholder="留空=用工時帳號" autocomplete="username" /></label>
<label>密碼<input type="password" name="compbase_password" placeholder="留空不變更/用工時密碼" autocomplete="new-password" /></label>
<label>預設近 N 天<input type="number" name="compbase_default_days" min="1" max="62" /></label>
<label>刷退模式
<select name="compbase_out_time_mode">
<option value="expected">expected正常工時</option>
<option value="latest_option">latest_option</option>
</select>
</label>
</div>
</fieldset>
<fieldset>
<legend>Grok LLM</legend>
<div class="settings-grid">
<label class="checkbox-row"><input type="checkbox" name="use_llm" /> 啟用 Grok</label>
<label class="checkbox-row"><input type="checkbox" name="use_description_variation" /> 描述變化</label>
<label>API Key<input type="password" name="xai_api_key" placeholder="留空不變更" autocomplete="off" /></label>
<label>Base URL<input type="url" name="xai_base_url" /></label>
<label>Model<input type="text" name="grok_model" /></label>
</div>
</fieldset>
<fieldset>
<legend>自動排程</legend>
<div class="settings-grid">
<label class="checkbox-row"><input type="checkbox" name="auto_fill_enabled" /> 每日自動填寫</label>
<label>Hour<input type="number" name="auto_fill_hour" min="0" max="23" /></label>
<label>Minute<input type="number" name="auto_fill_minute" min="0" max="59" /></label>
</div>
</fieldset>
<fieldset>
<legend>補寫日期 (dates.txt)</legend>
<textarea id="datesFileContent" name="dates_file_content" class="date-list" rows="6" placeholder="一行一個日期"></textarea>
</fieldset>
<div class="actions">
<button type="submit" class="btn primary">儲存設定</button>
<a href="/" class="btn secondary">回主頁</a>
<span id="settingsStatus" class="settings-status"></span>
</div>
</form>
</section>
<section class="card muted-card">
<h2>備援:瀏覽器擴充功能</h2>
<p class="hint">帳密登入不可用時,可用擴充功能同步工時系統登入憑證。</p>
<div class="actions">
<a href="/api/extension/download" class="btn secondary" download="jibaxiao-sync.zip">下載擴充功能</a>
</div>
<ol class="install-steps">
<li>解壓後於 <code>chrome://extensions</code> 載入未封裝項目</li>
<li>登入工時系統網頁 → 重新整理 → 擴充功能同步憑證</li>
</ol>
</section>
</div>
<script src="/static/common.js"></script>
<script src="/static/settings.js"></script>
</body>
</html>

485
frontend/settings.js Normal file
View File

@ -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
? "帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。瀏覽器最多保持約 <strong>10 分鐘</strong>,完成前不會關掉。"
: "請在跳出的 <strong>Chromium 視窗</strong>完成 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = [
`<span class="pill"><span class="pill-label">會議</span><span class="pill-value">${data.total_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">時數</span><span class="pill-value">${data.total_hours ?? 0}h</span></span>`,
`<span class="pill"><span class="pill-label">天數</span><span class="pill-value">${data.workday_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">連線</span><span class="pill-value">${data.connected ? "是" : "否"}</span></span>`,
].join("");
}
const days = (data.days || []).filter((d) => d.count > 0);
if (list) {
if (!days.length) {
list.innerHTML = `<p class="hint">${escapeHtml(data.fetch_hint || data.error || "0 筆會議")}</p>`;
} else {
list.innerHTML = days
.map((day) => {
const rows = (day.meetings || [])
.map(
(m) =>
`<li class="plan-row"><span class="meeting-hours">${escapeHtml(String(m.hours))}h</span>` +
`<span class="meeting-time">${escapeHtml(m.time_label || "")}</span>` +
`<span class="meeting-subject">${escapeHtml(m.subject || "")}</span></li>`
)
.join("");
return `<div class="plan-day"><div class="plan-day-head"><strong>${escapeHtml(day.date)}</strong>` +
`<span class="muted-inline">${day.count} 場 · ${day.hours}h</span></div>` +
`<ul class="meeting-items">${rows}</ul></div>`;
})
.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);

1091
frontend/style.css Normal file

File diff suppressed because it is too large Load Diff

12
run.sh Executable file
View File

@ -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

28
scripts/docker-start.sh Executable file
View File

@ -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帳密只留在容器記憶體不會存到主機。"

126
scripts/server.sh Executable file
View File

@ -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