e3cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ """e3cli schedule"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from e3cli.config import load_config
9
+ from e3cli.i18n import t
10
+ from e3cli.scheduler import cron
11
+
12
+ console = Console()
13
+ app = typer.Typer()
14
+
15
+
16
+ @app.command()
17
+ def enable(
18
+ interval: int = typer.Option(None, "--interval", "-i", help=t("sched.opt_interval")),
19
+ ):
20
+ """Enable automatic sync (install cron job)."""
21
+ cfg = load_config()
22
+ minutes = interval or cfg.schedule.interval_minutes
23
+ cron.install(minutes)
24
+ console.print(f"[green]{t('sched.enabled', m=minutes)}[/green]")
25
+
26
+
27
+ @app.command()
28
+ def disable():
29
+ """Disable automatic sync (remove cron job)."""
30
+ cron.uninstall()
31
+ console.print(f"[green]{t('sched.disabled')}[/green]")
32
+
33
+
34
+ @app.command()
35
+ def status():
36
+ """Show current schedule status."""
37
+ if cron.is_installed():
38
+ line = cron.get_schedule_line()
39
+ console.print(f"[green]{t('sched.status_on')}[/green]")
40
+ console.print(f" [dim]{line}[/dim]")
41
+ else:
42
+ console.print(f"[yellow]{t('sched.status_off')}[/yellow]")
@@ -0,0 +1,142 @@
1
+ """e3cli setup — interactive setup wizard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+
13
+ from e3cli import __version__
14
+ from e3cli.auth import AuthError, get_token
15
+ from e3cli.config import CONFIG_FILE, ensure_dirs, save_token
16
+ from e3cli.credential import save_credentials
17
+ from e3cli.i18n import set_lang, t
18
+
19
+ console = Console()
20
+ app = typer.Typer()
21
+
22
+ BANNER = r"""
23
+ _____ ____ _____ _ _____
24
+ / ____|___ \ / ____| | |_ _|
25
+ | |__ __) | | | | | |
26
+ | __| |__ <| | | | | |
27
+ | |____ ___) | |____| |____ _| |_
28
+ |______|____/ \_____|______|_____|
29
+ """
30
+
31
+
32
+ def is_first_run() -> bool:
33
+ return not CONFIG_FILE.exists()
34
+
35
+
36
+ def _choose_language() -> str:
37
+ """讓使用者選擇語言 / Let user choose language."""
38
+ console.print("[bold cyan]Language / 語言[/bold cyan]")
39
+ console.print(" [cyan]1[/cyan] 繁體中文")
40
+ console.print(" [cyan]2[/cyan] English")
41
+ choice = typer.prompt("Choose / 請選擇", default="1", show_default=True)
42
+ if choice.strip() in ("2", "en", "EN", "english", "English"):
43
+ return "en"
44
+ return "zh"
45
+
46
+
47
+ def run_setup_wizard() -> None:
48
+ console.print()
49
+ console.print(Panel(
50
+ Text(BANNER, style="cyan", justify="center"),
51
+ title=f"[bold]Welcome to e3cli v{__version__}[/bold]",
52
+ subtitle="NYCU E3 Moodle automation tool",
53
+ border_style="cyan",
54
+ ))
55
+ console.print()
56
+
57
+ # Step 0: Language
58
+ lang = _choose_language()
59
+ set_lang(lang)
60
+ console.print()
61
+
62
+ console.print(f"[bold]{t('setup.welcome')}[/bold]")
63
+ console.print()
64
+
65
+ # Step 1: Moodle URL
66
+ console.print(f"[bold cyan]Step 1/4[/bold cyan] — {t('setup.step_url')}")
67
+ console.print(f"[dim]{t('setup.step_url_hint')}[/dim]")
68
+ url = typer.prompt(
69
+ "Moodle URL",
70
+ default="https://e3p.nycu.edu.tw",
71
+ show_default=True,
72
+ ).rstrip("/")
73
+ console.print()
74
+
75
+ # Step 2: Download directory
76
+ console.print(f"[bold cyan]Step 2/4[/bold cyan] — {t('setup.step_dir')}")
77
+ console.print(f"[dim]{t('setup.step_dir_hint')}[/dim]")
78
+ default_dir = os.path.expanduser("~/e3-downloads")
79
+ download_dir = typer.prompt(
80
+ t("setup.step_dir"),
81
+ default=default_dir,
82
+ show_default=True,
83
+ )
84
+ console.print()
85
+
86
+ # Step 3: Save config (including language preference)
87
+ console.print(f"[bold cyan]Step 3/4[/bold cyan] — {t('setup.step_save')}")
88
+ ensure_dirs()
89
+ config_content = f"""[moodle]
90
+ url = "{url}"
91
+ service = "moodle_mobile_app"
92
+
93
+ [storage]
94
+ download_dir = "{download_dir}"
95
+
96
+ [schedule]
97
+ interval_minutes = 60
98
+ notify = true
99
+
100
+ [general]
101
+ lang = "{lang}"
102
+ """
103
+ CONFIG_FILE.write_text(config_content)
104
+ console.print(f"[green] {t('setup.config_saved', path=CONFIG_FILE)}[/green]")
105
+ console.print()
106
+
107
+ # Step 4: Login
108
+ console.print(f"[bold cyan]Step 4/4[/bold cyan] — {t('setup.step_login')}")
109
+ want_login = typer.confirm(t("setup.want_login"), default=True)
110
+
111
+ if want_login:
112
+ username = typer.prompt(f" {t('setup.prompt_id')}")
113
+ password = getpass.getpass(f" {t('login.prompt_pass')}")
114
+
115
+ console.print(f"[dim] {t('login.connecting', url=url)}[/dim]")
116
+ try:
117
+ token = get_token(url, username, password)
118
+ save_token(token)
119
+
120
+ save_creds = typer.confirm(f" {t('setup.want_save_creds')}", default=True)
121
+ if save_creds:
122
+ save_credentials(username, password)
123
+ console.print(f"[green] {t('login.success_saved')}[/green]")
124
+ else:
125
+ console.print(f"[green] {t('login.success')}[/green]")
126
+ except AuthError as e:
127
+ console.print(f"[red] ✗ {e}[/red]")
128
+ console.print(f"[dim] {t('setup.login_fail_hint')}[/dim]")
129
+ console.print()
130
+
131
+ # Done
132
+ console.print(Panel(
133
+ f"[bold green]{t('setup.done_title')}[/bold green]\n\n{t('setup.done_body')}",
134
+ title="[bold]Ready![/bold]",
135
+ border_style="green",
136
+ ))
137
+
138
+
139
+ @app.callback(invoke_without_command=True)
140
+ def setup():
141
+ """Re-run interactive setup wizard."""
142
+ run_setup_wizard()
@@ -0,0 +1,77 @@
1
+ """e3cli submit"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from e3cli.api.assignments import get_submission_status, save_submission
13
+ from e3cli.commands._common import get_client, get_db
14
+ from e3cli.i18n import t
15
+
16
+ console = Console()
17
+ app = typer.Typer()
18
+
19
+
20
+ @app.callback(invoke_without_command=True)
21
+ def submit(
22
+ assignment_id: int = typer.Argument(..., help="Assignment ID"),
23
+ files: list[Path] = typer.Argument(..., help="File(s) to submit"),
24
+ text: str = typer.Option("", "--text", "-t", help=t("submit.opt_text")),
25
+ force: bool = typer.Option(False, "--force", "-f", help=t("submit.opt_force")),
26
+ ):
27
+ """Upload and submit an assignment."""
28
+ for f in files:
29
+ if not f.exists():
30
+ console.print(f"[red]✗ {t('submit.not_found', f=f)}[/red]")
31
+ raise typer.Exit(1)
32
+
33
+ client = get_client()
34
+ db = get_db()
35
+
36
+ console.print(f"[dim]{t('submit.checking', id=assignment_id)}[/dim]")
37
+ try:
38
+ status = get_submission_status(client, assignment_id)
39
+ except Exception as e:
40
+ console.print(f"[red]{t('submit.check_fail', e=e)}[/red]")
41
+ raise typer.Exit(1)
42
+
43
+ assign_info = status.get("lastattempt", {}).get("assign", {})
44
+ duedate = assign_info.get("duedate", 0)
45
+ if duedate and duedate < int(time.time()) and not force:
46
+ dt = datetime.fromtimestamp(duedate).strftime("%Y-%m-%d %H:%M")
47
+ console.print(f"[red]✗ {t('submit.past_due', dt=dt)}[/red]")
48
+ raise typer.Exit(1)
49
+
50
+ console.print(f"[dim]{t('submit.uploading', n=len(files))}[/dim]")
51
+ itemid = 0
52
+ for f in files:
53
+ result = client.upload_file(f, itemid=itemid)
54
+ if result and isinstance(result, list):
55
+ itemid = result[0].get("itemid", itemid)
56
+ console.print(f" ✓ {f.name}")
57
+
58
+ console.print(f"[dim]{t('submit.submitting')}[/dim]")
59
+ save_submission(client, assignment_id, itemid, text)
60
+
61
+ verify = get_submission_status(client, assignment_id)
62
+ sub_status = (
63
+ verify.get("lastattempt", {})
64
+ .get("submission", {})
65
+ .get("status", "unknown")
66
+ )
67
+
68
+ if sub_status == "submitted":
69
+ console.print(f"[green]{t('submit.ok')}[/green]")
70
+ db.update_assignment_status(assignment_id, "submitted")
71
+ elif sub_status == "draft":
72
+ console.print(f"[yellow]{t('submit.draft')}[/yellow]")
73
+ db.update_assignment_status(assignment_id, "draft")
74
+ else:
75
+ console.print(f"[yellow]⚠ Status: {sub_status}[/yellow]")
76
+
77
+ db.close()
e3cli/commands/sync.py ADDED
@@ -0,0 +1,115 @@
1
+ """e3cli sync"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from e3cli.api.assignments import get_assignments
12
+ from e3cli.api.courses import get_course_contents, get_enrolled_courses
13
+ from e3cli.api.files import download_file
14
+ from e3cli.api.site import get_site_info
15
+ from e3cli.commands._common import get_client, get_db
16
+ from e3cli.config import load_config
17
+ from e3cli.i18n import t
18
+
19
+ console = Console()
20
+ app = typer.Typer()
21
+
22
+
23
+ def _sanitize(name: str) -> str:
24
+ return re.sub(r'[<>:"/\\|?*]', "_", name).strip().rstrip(".")
25
+
26
+
27
+ @app.callback(invoke_without_command=True)
28
+ def sync(
29
+ quiet: bool = typer.Option(False, "--quiet", "-q", help=t("sync.opt_quiet")),
30
+ ):
31
+ """Sync all course materials and assignment status."""
32
+ client = get_client()
33
+ db = get_db()
34
+ cfg = load_config()
35
+ download_dir = cfg.storage.download_dir
36
+ now = int(time.time())
37
+
38
+ info = get_site_info(client)
39
+ userid = info["userid"]
40
+ if not quiet:
41
+ console.print(f"[bold]{t('sync.syncing', name=info['fullname'])}[/bold]\n")
42
+
43
+ course_list = get_enrolled_courses(client, userid)
44
+ courseids = []
45
+ course_names = {}
46
+
47
+ for c in course_list:
48
+ cid = c["id"]
49
+ courseids.append(cid)
50
+ course_names[cid] = c.get("shortname", "")
51
+ db.upsert_course(cid, c.get("shortname", ""), c.get("fullname", ""))
52
+
53
+ new_files = 0
54
+ for c in course_list:
55
+ cid = c["id"]
56
+ cname = _sanitize(c.get("shortname", str(cid)))
57
+
58
+ try:
59
+ contents = get_course_contents(client, cid)
60
+ except Exception:
61
+ continue
62
+
63
+ for section in contents:
64
+ section_name = _sanitize(section.get("name", "unnamed"))
65
+ for module in section.get("modules", []):
66
+ mid = module.get("id", 0)
67
+ for file_info in module.get("contents", []):
68
+ fname = file_info.get("filename", "")
69
+ furl = file_info.get("fileurl", "")
70
+ fsize = file_info.get("filesize", 0)
71
+ ftime = file_info.get("timemodified", 0)
72
+
73
+ if not fname or not furl:
74
+ continue
75
+ if db.is_downloaded(cid, mid, fname, ftime):
76
+ continue
77
+
78
+ dest = download_dir / cname / section_name / fname
79
+ try:
80
+ download_file(client, furl, dest)
81
+ db.record_download(cid, mid, fname, furl, fsize, ftime, str(dest), now)
82
+ new_files += 1
83
+ if not quiet:
84
+ console.print(f" [green]↓[/green] {cname}/{section_name}/{fname}")
85
+ except Exception as e:
86
+ if not quiet:
87
+ console.print(f" [red]✗[/red] {fname}: {e}")
88
+
89
+ time.sleep(0.3)
90
+
91
+ new_assignments = 0
92
+ if courseids:
93
+ try:
94
+ data = get_assignments(client, courseids)
95
+ for course in data.get("courses", []):
96
+ cid = course["id"]
97
+ cname = course_names.get(cid, "")
98
+ for a in course.get("assignments", []):
99
+ is_new = db.upsert_assignment(
100
+ a["id"], cid, cname, a["name"], a.get("duedate", 0), now,
101
+ )
102
+ if is_new:
103
+ new_assignments += 1
104
+ if not quiet:
105
+ console.print(
106
+ f" [yellow]{t('sync.new_assign', course=cname, name=a['name'])}[/yellow]"
107
+ )
108
+ except Exception as e:
109
+ if not quiet:
110
+ console.print(f"[red]{t('sync.assign_fail', e=e)}[/red]")
111
+
112
+ if not quiet:
113
+ console.print(f"\n[green]{t('sync.done', files=new_files, assigns=new_assignments)}[/green]")
114
+
115
+ db.close()
e3cli/config.py ADDED
@@ -0,0 +1,98 @@
1
+ """設定管理 — 讀取/寫入 ~/.e3cli/config.toml"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ CONFIG_DIR = Path.home() / ".e3cli"
11
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
12
+ TOKEN_FILE = CONFIG_DIR / "token"
13
+ DB_PATH = CONFIG_DIR / "data" / "e3cli.db"
14
+ DEFAULT_DOWNLOAD_DIR = Path.home() / "e3-downloads"
15
+
16
+
17
+ @dataclass
18
+ class MoodleConfig:
19
+ url: str = "https://e3p.nycu.edu.tw"
20
+ service: str = "moodle_mobile_app"
21
+
22
+
23
+ @dataclass
24
+ class StorageConfig:
25
+ download_dir: Path = field(default_factory=lambda: DEFAULT_DOWNLOAD_DIR)
26
+ db_path: Path = field(default_factory=lambda: DB_PATH)
27
+
28
+
29
+ @dataclass
30
+ class ScheduleConfig:
31
+ interval_minutes: int = 60
32
+ notify: bool = True
33
+
34
+
35
+ @dataclass
36
+ class GeneralConfig:
37
+ lang: str = "" # "" = auto-detect, "zh", "en"
38
+
39
+
40
+ @dataclass
41
+ class Config:
42
+ moodle: MoodleConfig = field(default_factory=MoodleConfig)
43
+ storage: StorageConfig = field(default_factory=StorageConfig)
44
+ schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
45
+ general: GeneralConfig = field(default_factory=GeneralConfig)
46
+
47
+
48
+ def ensure_dirs() -> None:
49
+ """建立必要的目錄。"""
50
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
51
+ (CONFIG_DIR / "data").mkdir(exist_ok=True)
52
+
53
+
54
+ def load_config() -> Config:
55
+ """從 ~/.e3cli/config.toml 載入設定,不存在則使用預設值。"""
56
+ ensure_dirs()
57
+ cfg = Config()
58
+
59
+ if CONFIG_FILE.exists():
60
+ with open(CONFIG_FILE, "rb") as f:
61
+ data = tomllib.load(f)
62
+
63
+ if "moodle" in data:
64
+ m = data["moodle"]
65
+ cfg.moodle.url = m.get("url", cfg.moodle.url)
66
+ cfg.moodle.service = m.get("service", cfg.moodle.service)
67
+
68
+ if "storage" in data:
69
+ s = data["storage"]
70
+ if "download_dir" in s:
71
+ cfg.storage.download_dir = Path(os.path.expanduser(s["download_dir"]))
72
+ if "db_path" in s:
73
+ cfg.storage.db_path = Path(os.path.expanduser(s["db_path"]))
74
+
75
+ if "schedule" in data:
76
+ sc = data["schedule"]
77
+ cfg.schedule.interval_minutes = sc.get("interval_minutes", cfg.schedule.interval_minutes)
78
+ cfg.schedule.notify = sc.get("notify", cfg.schedule.notify)
79
+
80
+ if "general" in data:
81
+ g = data["general"]
82
+ cfg.general.lang = g.get("lang", cfg.general.lang)
83
+
84
+ return cfg
85
+
86
+
87
+ def save_token(token: str) -> None:
88
+ """儲存 token 到 ~/.e3cli/token (chmod 600)。"""
89
+ ensure_dirs()
90
+ TOKEN_FILE.write_text(token)
91
+ TOKEN_FILE.chmod(0o600)
92
+
93
+
94
+ def load_token() -> str | None:
95
+ """讀取已儲存的 token,不存在則回傳 None。"""
96
+ if TOKEN_FILE.exists():
97
+ return TOKEN_FILE.read_text().strip()
98
+ return None
e3cli/credential.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ 安全帳密儲存 — 純 Python stdlib 實作 (無需 cryptography/Rust)。
3
+
4
+ 加密流程:
5
+ 1. 首次使用時產生 32 bytes 隨機 key,存入 ~/.e3cli/key (chmod 600)
6
+ 2. 帳密 JSON → PBKDF2 派生加密金鑰 → AES-like XOR stream cipher → base64 編碼
7
+ 3. 加上 HMAC-SHA256 驗證完整性,防止竄改
8
+
9
+ 安全模型:
10
+ - key 檔案權限 0600,僅擁有者可讀
11
+ - key 與 credentials 分離
12
+ - HMAC 驗證確保資料完整性
13
+ - 密碼從不以明文寫入磁碟
14
+ - logout 時覆寫後再刪除
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import os
24
+
25
+ from e3cli.config import CONFIG_DIR, ensure_dirs
26
+
27
+ KEY_FILE = CONFIG_DIR / "key"
28
+ CRED_FILE = CONFIG_DIR / "credentials.enc"
29
+
30
+
31
+ def _get_or_create_key() -> bytes:
32
+ """取得加密金鑰,不存在則產生新的。"""
33
+ ensure_dirs()
34
+ if KEY_FILE.exists():
35
+ return KEY_FILE.read_bytes()
36
+ key = os.urandom(32)
37
+ KEY_FILE.write_bytes(key)
38
+ KEY_FILE.chmod(0o600)
39
+ return key
40
+
41
+
42
+ def _derive_key(master_key: bytes, salt: bytes) -> bytes:
43
+ """用 PBKDF2-HMAC-SHA256 派生加密金鑰。"""
44
+ return hashlib.pbkdf2_hmac("sha256", master_key, salt, iterations=100_000)
45
+
46
+
47
+ def _xor_bytes(data: bytes, key_stream: bytes) -> bytes:
48
+ """XOR data with repeating key stream."""
49
+ return bytes(d ^ key_stream[i % len(key_stream)] for i, d in enumerate(data))
50
+
51
+
52
+ def _encrypt(plaintext: bytes, master_key: bytes) -> bytes:
53
+ """加密:salt + HMAC + ciphertext,base64 編碼輸出。"""
54
+ salt = os.urandom(16)
55
+ derived = _derive_key(master_key, salt)
56
+ ciphertext = _xor_bytes(plaintext, derived)
57
+ mac = hmac.new(derived, ciphertext, hashlib.sha256).digest()
58
+ # Format: salt (16) + mac (32) + ciphertext (variable)
59
+ return base64.b64encode(salt + mac + ciphertext)
60
+
61
+
62
+ def _decrypt(encoded: bytes, master_key: bytes) -> bytes | None:
63
+ """解密,驗證 HMAC 後回傳明文,失敗回傳 None。"""
64
+ try:
65
+ raw = base64.b64decode(encoded)
66
+ if len(raw) < 48: # salt(16) + mac(32) minimum
67
+ return None
68
+ salt = raw[:16]
69
+ stored_mac = raw[16:48]
70
+ ciphertext = raw[48:]
71
+ derived = _derive_key(master_key, salt)
72
+ # 驗證 HMAC
73
+ expected_mac = hmac.new(derived, ciphertext, hashlib.sha256).digest()
74
+ if not hmac.compare_digest(stored_mac, expected_mac):
75
+ return None
76
+ return _xor_bytes(ciphertext, derived)
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def save_credentials(username: str, password: str) -> None:
82
+ """加密儲存帳號密碼。"""
83
+ ensure_dirs()
84
+ key = _get_or_create_key()
85
+ data = json.dumps({"username": username, "password": password}).encode()
86
+ encrypted = _encrypt(data, key)
87
+ CRED_FILE.write_bytes(encrypted)
88
+ CRED_FILE.chmod(0o600)
89
+
90
+
91
+ def load_credentials() -> tuple[str, str] | None:
92
+ """讀取已儲存的帳密,回傳 (username, password) 或 None。"""
93
+ if not CRED_FILE.exists() or not KEY_FILE.exists():
94
+ return None
95
+ try:
96
+ key = _get_or_create_key()
97
+ decrypted = _decrypt(CRED_FILE.read_bytes(), key)
98
+ if decrypted is None:
99
+ return None
100
+ data = json.loads(decrypted)
101
+ return data["username"], data["password"]
102
+ except (KeyError, json.JSONDecodeError):
103
+ return None
104
+
105
+
106
+ def clear_credentials() -> None:
107
+ """安全清除所有認證資料(帳密 + token + key)。"""
108
+ for path in [CRED_FILE, KEY_FILE, CONFIG_DIR / "token"]:
109
+ if path.exists():
110
+ path.write_bytes(b"\x00" * max(path.stat().st_size, 1))
111
+ path.unlink()
112
+
113
+
114
+ def has_credentials() -> bool:
115
+ """是否已儲存帳密。"""
116
+ return CRED_FILE.exists() and KEY_FILE.exists()