===== DIFF: CORE ===== diff --git a/pyproject.toml b/pyproject.toml index dcc2b8d..97778ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,3 +17,9 @@ studyos = "studyos.main:main" [tool.setuptools.packages.find] include = ["studyos*"] + +[tool.setuptools.package-data] +studyos = ["studyos.1"] + +[tool.setuptools.data-files] +"share/man/man1" = ["docs/studyos.1"] diff --git a/studyos/allocator.py b/studyos/allocator.py index e1f3be5..64e2bfe 100644 --- a/studyos/allocator.py +++ b/studyos/allocator.py @@ -9,6 +9,7 @@ from .planner import ( load_events, ) from .tasks import list_tasks +from .config import MIN_STUDY_BLOCK_MINUTES, STUDY_WEEKENDS @dataclass @@ -75,16 +76,9 @@ def available_capacity_before_deadline( while day <= task.deadline: - if day.weekday() < 5: - daily_blocks = generate_daily_plan( - events, - day, - ) - - capacity += sum( - block.minutes - for block in daily_blocks - ) + if STUDY_WEEKENDS or day.weekday() < 5: + daily_blocks = generate_daily_plan(events, day) + capacity += sum(block.minutes for block in daily_blocks) day += timedelta(days=1) @@ -230,7 +224,6 @@ def allocate_block( task for task in tasks if task.remaining_minutes > 0 - and task.deadline >= current_day ] if not eligible: @@ -252,7 +245,7 @@ def allocate_block( ) # Never create a tiny study fragment. - if duration < 45: + if duration < MIN_STUDY_BLOCK_MINUTES: return [] end = block.start + timedelta( @@ -295,7 +288,6 @@ def allocate_day( task for task in tasks if task.remaining_minutes > 0 - and task.deadline >= day ] if not remaining: @@ -330,7 +322,7 @@ def allocate( while current <= end: - if current.weekday() < 5: + if STUDY_WEEKENDS or current.weekday() < 5: daily = allocate_day( events, diff --git a/studyos/calendars.py b/studyos/calendars.py index 7e16ae0..6478758 100644 --- a/studyos/calendars.py +++ b/studyos/calendars.py @@ -13,7 +13,15 @@ from .database import connect def initialize_calendar_records() -> None: now = datetime.now().astimezone().isoformat() + configured_ids = {calendar["id"] for calendar in CONFIG["calendars"]} + with connect() as connection: + rows = connection.execute("SELECT id FROM calendars").fetchall() + stale_ids = [row["id"] for row in rows if row["id"] not in configured_ids] + for calendar_id in stale_ids: + connection.execute("DELETE FROM calendar_events WHERE source = ?", (calendar_id,)) + connection.execute("DELETE FROM calendars WHERE id = ?", (calendar_id,)) + for calendar in CONFIG["calendars"]: connection.execute( """ diff --git a/studyos/config.py b/studyos/config.py index 8af5dce..125d8da 100644 --- a/studyos/config.py +++ b/studyos/config.py @@ -14,10 +14,7 @@ load_dotenv(ENV_PATH) def load_config() -> dict: if not CONFIG_PATH.exists(): - raise FileNotFoundError( - f"Missing configuration file: {CONFIG_PATH}\n" - "Create config.toml or run the setup command." - ) + return {"studyos": {}, "calendars": [], "exports": []} with CONFIG_PATH.open("rb") as file: config = tomllib.load(file) @@ -65,11 +62,6 @@ LUNCH_END = 13 SLEEP_START = 23 SLEEP_END = 7 -COURSES = { - "HF1005": "Informationsteknik och ingenjörsmetodik", - "HF1006": "Linjär algebra och analys", - "HI1024": "Programmering, grundkurs", -} def get_calendar_config(calendar_id: str) -> dict: diff --git a/studyos/database.py b/studyos/database.py index 3469af8..249c40e 100644 --- a/studyos/database.py +++ b/studyos/database.py @@ -77,26 +77,7 @@ def initialize_database() -> None: """ ) - connection.executemany( - """ - INSERT OR IGNORE INTO courses (code, name) - VALUES (?, ?) - """, - [ - ( - "HF1005", - "Informationsteknik och ingenjörsmetodik", - ), - ( - "HF1006", - "Linjär algebra och analys", - ), - ( - "HI1024", - "Programmering, grundkurs", - ), - ], - ) + initialize_calendar_metadata() diff --git a/studyos/planner.py b/studyos/planner.py index 9ee1a30..c2341ce 100644 --- a/studyos/planner.py +++ b/studyos/planner.py @@ -496,7 +496,7 @@ def print_day_plan( if not day_events: print( - " No scheduled KTH activities." + " No scheduled activities." ) for event in day_events: diff --git a/studyos/tasks.py b/studyos/tasks.py index eab672c..283996a 100644 --- a/studyos/tasks.py +++ b/studyos/tasks.py @@ -1,6 +1,5 @@ from datetime import date, datetime -from .config import COURSES from .database import connect @@ -12,12 +11,16 @@ VALID_STATUSES = { def validate_course(course_code: str) -> None: - if course_code not in COURSES: - valid = ", ".join(COURSES) + with connect() as connection: + row = connection.execute( + "SELECT 1 FROM courses WHERE code = ?", + (course_code.upper(),), + ).fetchone() + if row is None: raise ValueError( f"Unknown course '{course_code}'. " - f"Valid courses: {valid}" + "Run 'studyos course list' to see configured courses." ) @@ -56,6 +59,7 @@ def add_task( description: str | None = None, ) -> int: + course_code = course_code.upper() validate_course(course_code) validate_deadline(deadline) validate_priority(priority) @@ -210,3 +214,19 @@ def update_task_status( raise ValueError( f"No task exists with ID {task_id}." ) + + +def remove_task(task_id: int) -> None: + with connect() as connection: + cursor = connection.execute( + """ + DELETE FROM tasks + WHERE id = ? + """, + (task_id,), + ) + + if cursor.rowcount == 0: + raise ValueError( + f"No task exists with ID {task_id}." + ) \ No newline at end of file ===== DIFF: CLI / SETUP ===== diff --git a/studyos/calendar.py b/studyos/calendar.py index 30d32d7..310aa1d 100644 --- a/studyos/calendar.py +++ b/studyos/calendar.py @@ -1,4 +1,5 @@ import argparse +import getpass import os import re @@ -352,187 +353,131 @@ def synchronize_database() -> None: initialize_database() -def main() -> None: +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( + prog="studyos calendar", + formatter_class=argparse.RawDescriptionHelpFormatter, description=( - "Manage StudyOS calendar configuration." - ) - ) - - subparsers = parser.add_subparsers( - dest="command", - required=True, + "Manage imported calendar sources.\n\n" + "Calendar definitions live in config.toml. Private ICS URLs are " + "stored in .env by StudyOS rather than in public configuration." + ), + epilog=( + "Examples:\n" + " studyos calendar list\n" + " studyos calendar add work \"Work calendar\"\n" + " studyos calendar disable work\n" + " studyos calendar blocking work off\n\n" + "Run 'studyos calendar COMMAND --help' for command-specific help." + ), ) + subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") - subparsers.add_parser( - "list", - help="List configured calendars.", + list_parser = subparsers.add_parser( + "list", help="List configured calendars.", + description="List calendars configured in config.toml.", ) + list_parser.set_defaults(command="list") add_parser = subparsers.add_parser( - "add", - help="Add an ICS calendar.", - ) - - add_parser.add_argument( - "id", - help="Unique calendar ID.", + "add", help="Add an ICS calendar.", + description="Add a new ICS calendar definition to config.toml.", + epilog=( + "Example:\n" + " studyos calendar add work \"Work calendar\"\n\n" + "StudyOS prompts for the private URL without echoing it. " + "Use --url for non-interactive use." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) - + add_parser.add_argument("id", help="Unique calendar ID, for example 'work'.") + add_parser.add_argument("name", help="Human-readable calendar name.") add_parser.add_argument( - "name", - help="Display name.", + "--url", + metavar="URL", + help="Private ICS URL. If omitted, StudyOS prompts securely.", ) - add_parser.add_argument( "--url-env", - required=True, - help=( - "Environment variable containing " - "the private ICS URL." - ), + metavar="VARIABLE", + help="Advanced: environment-variable name used to store the URL.", ) - add_parser.add_argument( - "--disabled", - action="store_true", - help="Add the calendar disabled.", + "--disabled", action="store_true", + help="Create the calendar in a disabled state.", ) - add_parser.add_argument( - "--non-blocking", - action="store_true", - help=( - "Do not let events from this calendar " - "block study time." - ), - ) - - enable_parser = subparsers.add_parser( - "enable", - help="Enable a calendar.", + "--non-blocking", action="store_true", + help="Do not let events from this calendar block study scheduling.", ) - enable_parser.add_argument("id") - - disable_parser = subparsers.add_parser( - "disable", - help="Disable a calendar.", - ) - - disable_parser.add_argument("id") + for command, action in (("enable", "Enable"), ("disable", "Disable")): + sub = subparsers.add_parser(command, help=f"{action} a calendar.", description=f"{action} an existing calendar source.") + sub.add_argument("id", help="Calendar ID.") blocking_parser = subparsers.add_parser( - "blocking", - help=( - "Control whether a calendar blocks " - "study scheduling." - ), - ) - - blocking_parser.add_argument("id") - - blocking_parser.add_argument( - "value", - type=parse_bool, - help="on or off", + "blocking", help="Control whether a calendar blocks study scheduling.", + description="Choose whether events from a calendar reserve time in the StudyOS planner.", ) + blocking_parser.add_argument("id", help="Calendar ID.") + blocking_parser.add_argument("value", type=parse_bool, metavar="on|off", help="Turn study-time blocking on or off.") remove_parser = subparsers.add_parser( - "remove", - help="Remove a calendar from config.toml.", + "remove", help="Remove a calendar from config.toml.", + description="Remove a calendar definition from config.toml.", ) + remove_parser.add_argument("id", help="Calendar ID.") + return parser - remove_parser.add_argument("id") +def main() -> None: + parser = build_parser() args = parser.parse_args() - try: if args.command == "list": list_configured_calendars() return - if args.command == "add": - add_calendar( - calendar_id=args.id, - name=args.name, - url_env=args.url_env, - enabled=not args.disabled, - read_only=True, - blocks_study_time=( - not args.non_blocking - ), - ) - - print( - f"Added calendar '{args.id}'." - ) + from .setup import set_env_value - elif args.command == "enable": - replace_calendar_value( - args.id, - "enabled", - "true", + url_env = args.url_env or ( + "STUDYOS_CALENDAR_" + + re.sub(r"[^A-Za-z0-9]+", "_", args.id).strip("_").upper() + + "_URL" ) + url = args.url + if url is None: + url = getpass.getpass("Private ICS URL: ").strip() + if not url: + raise ValueError("A private ICS URL is required.") + if not url.startswith(("https://", "http://")): + raise ValueError("Calendar URL must use http:// or https://.") - print( - f"Enabled calendar '{args.id}'." + add_calendar( + args.id, args.name, url_env, not args.disabled, + True, not args.non_blocking, ) - + set_env_value(url_env, url) + print(f"Added calendar '{args.id}'.") + elif args.command == "enable": + replace_calendar_value(args.id, "enabled", "true") + print(f"Enabled calendar '{args.id}'.") elif args.command == "disable": - replace_calendar_value( - args.id, - "enabled", - "false", - ) - - print( - f"Disabled calendar '{args.id}'." - ) - + replace_calendar_value(args.id, "enabled", "false") + print(f"Disabled calendar '{args.id}'.") elif args.command == "blocking": - replace_calendar_value( - args.id, - "blocks_study_time", - toml_bool(args.value), - ) - - state = ( - "blocking" - if args.value - else "non-blocking" - ) - - print( - f"Calendar '{args.id}' is now " - f"{state}." - ) - + replace_calendar_value(args.id, "blocks_study_time", toml_bool(args.value)) + state = "blocking" if args.value else "non-blocking" + print(f"Calendar '{args.id}' is now {state}.") elif args.command == "remove": - remove_calendar( - args.id - ) - - print( - f"Removed calendar '{args.id}'." - ) - + remove_calendar(args.id) + print(f"Removed calendar '{args.id}'.") print() - print( - "Configuration updated in " - f"{CONFIG_PATH}" - ) - - print( - "Run the next StudyOS command to " - "synchronize the database." - ) - + print(f"Configuration updated in {CONFIG_PATH}") + print("Run the next StudyOS command to synchronize the database.") except ValueError as error: - raise SystemExit( - f"Error: {error}" - ) + raise SystemExit(f"Error: {error}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/studyos/main.py b/studyos/main.py index 0bb417c..a39759f 100644 --- a/studyos/main.py +++ b/studyos/main.py @@ -1,138 +1,228 @@ import argparse +import importlib.metadata import sys -from . import calendar as calendar_cli -from . import ics as ics_cli -from . import schedule as schedule_cli -from . import setup as setup_cli -from . import task as task_cli -from . import update as update_cli -from .database import initialize_database - COMMANDS = { - "setup": ( - setup_cli.main, - "Initialize and validate StudyOS.", - ), - "calendar": ( - calendar_cli.main, - "Manage imported calendars.", - ), - "task": ( - task_cli.main, - "Manage study tasks.", - ), - "schedule": ( - schedule_cli.main, - "Generate and display a study schedule.", - ), - "export": ( - ics_cli.main, - "Export the study schedule as ICS.", - ), - "update": ( - update_cli.main, - "Refresh calendars and regenerate ICS exports.", - ), + "setup": "Initialize and configure StudyOS.", + "calendar": "Add, remove, enable, disable, and inspect calendars.", + "course": "Add, rename, remove, and inspect courses.", + "task": "Create, list, and update study tasks.", + "schedule": "Generate and display a study schedule.", + "export": "Generate configured ICS calendar exports.", + "update": "Refresh calendars and regenerate ICS exports.", + "doctor": "Run read-only installation and configuration diagnostics.", + "man": "Open the StudyOS manual page, or print it as text.", } +def version() -> str: + try: + return importlib.metadata.version("studyos") + except importlib.metadata.PackageNotFoundError: + return "development" + + +class StudyOSFormatter( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter, +): + pass + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="studyos", + formatter_class=StudyOSFormatter, description=( - "StudyOS — calendar-aware study planning." + "StudyOS — calendar-aware study planning.\n\n" + "Import real calendar commitments, manage study tasks, " + "find usable free time, allocate work before deadlines, " + "and publish the result as ICS calendars." + ), + epilog=( + "Getting started:\n" + " studyos setup\n" + " studyos course list\n" + " studyos task list\n" + " studyos update --days 14\n\n" + "Help:\n" + " studyos COMMAND --help detailed help for a command\n" + " studyos help COMMAND same as COMMAND --help\n" + " studyos man open the full manual\n" + " studyos doctor diagnose common problems" ), ) parser.add_argument( - "command", - nargs="?", - choices=COMMANDS, - help="Command to run.", + "--version", + action="version", + version=f"%(prog)s {version()}", ) return parser def print_help() -> None: - print() - print("StudyOS") - print("=" * 72) - print( - "Calendar-aware study planning and scheduling." - ) + parser = build_parser() + parser.print_help() + print() print("Commands:") - width = max( - len(command) - for command in COMMANDS - ) + width = max(len(command) for command in COMMANDS) - for command, (_, description) in COMMANDS.items(): - print( - f" {command:<{width}} {description}" - ) + for command, description in COMMANDS.items(): + print(f" {command:<{width}} {description}") print() - print("Examples:") - print( - " studyos setup" - ) - print( - " studyos calendar list" - ) - print( - " studyos task list" - ) - print( - " studyos schedule" - ) - print( - " studyos export " - "--start 2026-09-17 --end 2026-09-30" - ) - print( - " studyos update --days 14" - ) - print() + print("Run 'studyos COMMAND --help' for detailed command help.") -def main() -> None: - parser = build_parser() +def dispatch(command: str, arguments: list[str]) -> None: + if command == "setup": + from . import setup as module - if len(sys.argv) == 1: - print_help() - return + elif command == "calendar": + from . import calendar as module + + elif command == "course": + from . import course as module + + elif command == "task": + from . import task as module + + elif command == "schedule": + from . import schedule as module - args, remaining = parser.parse_known_args() + elif command == "export": + from . import ics as module - if args.command is None: - parser.print_help() + elif command == "update": + from . import update as module + + elif command == "doctor": + from . import doctor as module + + elif command == "man": + from .manual import main as manual_main + + manual_main(arguments) return - if args.command != "setup": - initialize_database() + else: + raise SystemExit( + f"Unknown command: {command}\n" + "Run 'studyos --help' to list available commands." + ) + + # Setup and doctor perform their own initialization and diagnostics. + # All normal operational commands require the StudyOS database. + if ( + command not in {"setup", "doctor"} + and not any(arg in {"-h", "--help"} for arg in arguments) + ): + from .database import initialize_database - command_main, _ = COMMANDS[ - args.command - ] + initialize_database() original_argv = sys.argv try: - sys.argv = [ - f"studyos {args.command}", - *remaining, - ] - - command_main() - + # From this point onward, the selected command owns every argument. + # + # Example: + # + # studyos task add CS101 "Review chapter 4" --hours 3 + # + # becomes: + # + # studyos task + # add CS101 "Review chapter 4" --hours 3 + # + # This is important because --help must be handled by the command's + # own ArgumentParser rather than by the root StudyOS parser. + sys.argv = [f"studyos {command}", *arguments] + module.main() finally: sys.argv = original_argv +def main() -> None: + arguments = sys.argv[1:] + + # No arguments: + # + # studyos + # + # Display the main help page. + if not arguments: + print_help() + return + + first = arguments[0] + + # Global help: + # + # studyos --help + # studyos -h + if first in {"-h", "--help"}: + print_help() + return + + # Global version: + # + # studyos --version + if first == "--version": + print(f"studyos {version()}") + return + + # Friendly help aliases: + # + # studyos help + # studyos help task + # studyos help calendar + # + # "studyos help task" is equivalent to: + # + # studyos task --help + if first == "help": + if len(arguments) == 1: + print_help() + return + + command = arguments[1] + + if command not in COMMANDS: + raise SystemExit( + f"Unknown command: {command}\n" + "Run 'studyos --help' to list available commands." + ) + + dispatch(command, [*arguments[2:], "--help"]) + return + + # Everything after the command name belongs to that command. + # + # This deliberately avoids running command arguments through the root + # ArgumentParser. Therefore: + # + # studyos task --help + # + # reaches the task parser, while: + # + # studyos setup --help + # + # reaches the setup parser. + if first in COMMANDS: + dispatch(first, arguments[1:]) + return + + raise SystemExit( + f"Unknown command: {first}\n" + "Run 'studyos --help' to list available commands." + ) + + if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/studyos/setup.py b/studyos/setup.py index d4eca4b..2921aae 100644 --- a/studyos/setup.py +++ b/studyos/setup.py @@ -1,6 +1,13 @@ +import argparse +import getpass import os import shutil +import sys import tomllib +from pathlib import Path +from urllib.request import Request, urlopen + +from dotenv import load_dotenv from .config import ( CONFIG_PATH, @@ -15,32 +22,64 @@ from .database import initialize_database ENV_EXAMPLE_PATH = PROJECT_ROOT / ".env.example" -def create_env_file() -> bool: - """ - Create .env from .env.example if .env does not already exist. +class SetupFormatter( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter, +): + pass + + + +def create_config_file() -> bool: + """Create a minimal, generic config.toml for a first-time installation.""" + if CONFIG_PATH.exists(): + return False + + CONFIG_PATH.write_text( + """[studyos] +timezone = "Europe/Stockholm" +data_dir = "data" +study_day_start = 8 +study_day_end = 20 +max_study_hours_per_day = 6 +preferred_study_block_minutes = 120 +min_study_block_minutes = 45 +min_free_gap_minutes = 30 +study_weekends = false + +[[exports]] +id = "study" +name = "StudyOS Schedule" +enabled = true +path = "data/exports/studyos_schedule.ics" +include = ["study_tasks"] +""", + encoding="utf-8", + ) + return True - Existing private configuration is never overwritten. - """ - if ENV_PATH.exists(): +def create_env_file(force: bool = False) -> bool: + """Create .env from .env.example without destroying secrets by default.""" + if ENV_PATH.exists() and not force: return False if not ENV_EXAMPLE_PATH.exists(): - raise FileNotFoundError( - f"Missing template: {ENV_EXAMPLE_PATH}" - ) + if not ENV_PATH.exists(): + ENV_PATH.write_text( + "# Private StudyOS values. Never commit this file.\n", + encoding="utf-8", + ) + return True + return False - shutil.copyfile( - ENV_EXAMPLE_PATH, - ENV_PATH, - ) + if force or not ENV_PATH.exists(): + shutil.copyfile(ENV_EXAMPLE_PATH, ENV_PATH) + return True - return True + return False def validate_config() -> dict: - """ - Parse config.toml and perform basic structural validation. - """ if not CONFIG_PATH.exists(): raise FileNotFoundError( f"Missing configuration file: {CONFIG_PATH}" @@ -50,144 +89,224 @@ def validate_config() -> dict: config = tomllib.load(file) if "studyos" not in config: - raise ValueError( - "config.toml is missing [studyos]." - ) + raise ValueError("config.toml is missing [studyos].") - calendars = config.get( - "calendars", - [], - ) - - exports = config.get( - "exports", - [], - ) + calendars = config.get("calendars", []) + exports = config.get("exports", []) calendar_ids = set() - for calendar in calendars: calendar_id = calendar.get("id") - if not calendar_id: - raise ValueError( - "A calendar is missing its id." - ) - + raise ValueError("A calendar is missing its id.") if calendar_id in calendar_ids: - raise ValueError( - f"Duplicate calendar ID: {calendar_id}" - ) - + raise ValueError(f"Duplicate calendar ID: {calendar_id}") calendar_ids.add(calendar_id) if not calendar.get("name"): raise ValueError( - f"Calendar '{calendar_id}' " - "is missing its name." + f"Calendar '{calendar_id}' is missing its name." ) - calendar_type = calendar.get( - "type", - "ics_url", - ) - + calendar_type = calendar.get("type", "ics_url") if calendar_type != "ics_url": raise ValueError( - f"Calendar '{calendar_id}' uses " - f"unsupported type '{calendar_type}'." + f"Calendar '{calendar_id}' uses unsupported " + f"type '{calendar_type}'." ) if not calendar.get("url_env"): raise ValueError( - f"Calendar '{calendar_id}' " - "is missing url_env." + f"Calendar '{calendar_id}' is missing url_env." ) export_ids = set() - for export in exports: export_id = export.get("id") - if not export_id: - raise ValueError( - "An export is missing its id." - ) - + raise ValueError("An export is missing its id.") if export_id in export_ids: - raise ValueError( - f"Duplicate export ID: {export_id}" - ) - + raise ValueError(f"Duplicate export ID: {export_id}") export_ids.add(export_id) if not export.get("path"): raise ValueError( - f"Export '{export_id}' " - "is missing its path." + f"Export '{export_id}' is missing its path." ) return config +def read_env_values() -> dict[str, str]: + values: dict[str, str] = {} + + if not ENV_PATH.exists(): + return values + + for raw_line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + + return values + + +def set_env_value(key: str, value: str) -> None: + """Update one .env value while preserving unrelated lines/comments.""" + lines = ( + ENV_PATH.read_text(encoding="utf-8").splitlines() + if ENV_PATH.exists() + else [] + ) + + prefix = f"{key}=" + replaced = False + output: list[str] = [] + + for line in lines: + if line.strip().startswith(prefix): + output.append(f"{key}={value}") + replaced = True + else: + output.append(line) + + if not replaced: + if output and output[-1].strip(): + output.append("") + output.append(f"{key}={value}") + + ENV_PATH.write_text( + "\n".join(output).rstrip() + "\n", + encoding="utf-8", + ) + + os.environ[key] = value + + def configured_environment_variables( config: dict, -) -> list[tuple[str, bool, bool]]: - """ - Return: - (environment variable, calendar enabled, variable configured) - """ - variables = [] - - for calendar in config.get( - "calendars", - [], - ): - variable = calendar.get( - "url_env" - ) +) -> list[tuple[str, str, bool, bool]]: + values = read_env_values() + result = [] + for calendar in config.get("calendars", []): + variable = calendar.get("url_env") if not variable: continue - enabled = calendar.get( - "enabled", - True, - ) - + enabled = calendar.get("enabled", True) configured = bool( - os.environ.get( - variable, - "", - ).strip() + os.environ.get(variable, "").strip() + or values.get(variable, "").strip() ) - - variables.append( + result.append( ( + calendar.get("name", calendar.get("id", "?")), variable, enabled, configured, ) ) - return variables + return result + + +def test_ics_url(url: str) -> tuple[bool, str]: + try: + request = Request( + url, + headers={"User-Agent": "StudyOS/setup"}, + ) + with urlopen(request, timeout=15) as response: + data = response.read(4096) + except Exception as error: + return False, str(error) + + upper = data.upper() + if b"BEGIN:VCALENDAR" not in upper: + return False, "The response does not look like an ICS calendar." + + return True, "Calendar feed is reachable." + + +def configure_missing_calendars( + config: dict, + *, + test_urls: bool = True, +) -> None: + values = read_env_values() + + for calendar in config.get("calendars", []): + if not calendar.get("enabled", True): + continue + + variable = calendar.get("url_env") + if not variable: + continue + + existing = ( + os.environ.get(variable, "").strip() + or values.get(variable, "").strip() + ) + if existing: + continue + + name = calendar.get("name", calendar.get("id", "calendar")) + + print() + print(f"Calendar: {name}") + print(f"Credential: {variable}") + print( + "Paste the private ICS subscription URL below. " + "Input is hidden so the URL is not displayed on screen." + ) + + while True: + url = getpass.getpass("ICS URL (Enter to skip): ").strip() + + if not url: + print("Skipped.") + break + + if not ( + url.startswith("https://") + or url.startswith("http://") + ): + print("That does not look like an HTTP(S) URL. Try again.") + continue + + if test_urls: + print("Checking calendar feed...", end=" ", flush=True) + ok, message = test_ics_url(url) + print("OK" if ok else "FAILED") + if not ok: + print(f" {message}") + answer = input( + "Save this URL anyway? [y/N]: " + ).strip().lower() + if answer not in {"y", "yes"}: + continue + + set_env_value(variable, url) + values[variable] = url + print(f"Saved {variable} to .env.") + break def print_environment_status( - variables: list[tuple[str, bool, bool]], + variables: list[tuple[str, str, bool, bool]], ) -> None: print() print("CALENDAR CREDENTIALS") - print("=" * 72) + print("=" * 78) if not variables: - print( - "No calendar URL environment " - "variables configured." - ) + print("No calendar URL credentials are configured.") return - for variable, enabled, configured in variables: + for name, variable, enabled, configured in variables: if configured: status = "configured" elif enabled: @@ -195,176 +314,260 @@ def print_environment_status( else: status = "not configured" - calendar_state = ( - "enabled" - if enabled - else "disabled" - ) - + state = "enabled" if enabled else "disabled" print( - f"{variable:<28} " - f"{status:<16} " - f"({calendar_state})" + f"{name:<24} {variable:<28} {status:<14} ({state})" ) -def print_config_summary( - config: dict, -) -> None: - calendars = config.get( - "calendars", - [], - ) - - exports = config.get( - "exports", - [], - ) +def print_config_summary(config: dict) -> None: + calendars = config.get("calendars", []) + exports = config.get("exports", []) enabled_calendars = sum( - 1 - for calendar in calendars - if calendar.get( - "enabled", - True, - ) + 1 for calendar in calendars + if calendar.get("enabled", True) ) - enabled_exports = sum( - 1 - for export in exports - if export.get( - "enabled", - True, - ) + 1 for export in exports + if export.get("enabled", True) ) print() print("CONFIGURATION") - print("=" * 72) - print( - f"Config: {CONFIG_PATH}" - ) + print("=" * 78) + print(f"Project: {PROJECT_ROOT}") + print(f"Config: {CONFIG_PATH}") + print(f"Secrets: {ENV_PATH}") + print(f"Data: {DATA_DIR}") + print(f"Database: {DATABASE_PATH}") print( - f"Calendars: " - f"{enabled_calendars} enabled / " + f"Calendars: {enabled_calendars} enabled / " f"{len(calendars)} configured" ) print( - f"Exports: " - f"{enabled_exports} enabled / " + f"Exports: {enabled_exports} enabled / " f"{len(exports)} configured" ) -def run_setup() -> bool: - """ - Initialize and validate the current StudyOS installation. - - Returns True when all enabled calendar credentials are configured. - """ +def run_checks( + *, + test_calendars: bool = False, +) -> bool: print() - print("StudyOS Setup") - print("=" * 72) + print("StudyOS diagnostics") + print("=" * 78) - env_created = create_env_file() + checks: list[tuple[str, bool, str]] = [] - if env_created: - print( - f"Created: {ENV_PATH}" + checks.append( + ( + "Python", + sys.version_info >= (3, 11), + sys.version.split()[0], ) - else: - print( - f"Exists: {ENV_PATH}" + ) + checks.append( + ("config.toml", CONFIG_PATH.exists(), str(CONFIG_PATH)) + ) + checks.append( + (".env", ENV_PATH.exists(), str(ENV_PATH)) + ) + + try: + config = validate_config() + checks.append(("Configuration", True, "valid")) + except Exception as error: + config = None + checks.append(("Configuration", False, str(error))) + + if config is not None: + variables = configured_environment_variables(config) + missing = [ + variable + for _, variable, enabled, configured in variables + if enabled and not configured + ] + checks.append( + ( + "Calendar credentials", + not missing, + ( + "all enabled calendars configured" + if not missing + else "missing: " + ", ".join(missing) + ), + ) ) - DATA_DIR.mkdir( - parents=True, - exist_ok=True, - ) + if test_calendars and not missing: + values = read_env_values() + for calendar in config.get("calendars", []): + if not calendar.get("enabled", True): + continue + variable = calendar["url_env"] + url = ( + os.environ.get(variable, "").strip() + or values.get(variable, "").strip() + ) + ok, message = test_ics_url(url) + checks.append( + ( + f"Feed: {calendar['id']}", + ok, + message, + ) + ) + + width = max(len(name) for name, _, _ in checks) + print() + for name, ok, detail in checks: + marker = "PASS" if ok else "FAIL" + print(f"[{marker}] {name:<{width}} {detail}") - print( - f"Data: {DATA_DIR}" - ) + success = all(ok for _, ok, _ in checks) + print() + print("StudyOS is ready." if success else "Problems were found.") + return success - config = validate_config() +def run_setup( + *, + interactive: bool = True, + test_urls: bool = True, + force_env: bool = False, +) -> bool: + print() + print("StudyOS Setup") + print("=" * 78) + + config_created = create_config_file() print( - "Config: valid" + f"{'Created' if config_created else 'Using'}: {CONFIG_PATH}" ) - initialize_database() - + env_created = create_env_file(force=force_env) print( - f"Database: {DATABASE_PATH}" + f"{'Created' if env_created else 'Using'}: {ENV_PATH}" ) - print_config_summary( - config - ) + # Reload after .env may have been created/changed. + load_dotenv(ENV_PATH, override=False) - variables = configured_environment_variables( - config - ) + DATA_DIR.mkdir(parents=True, exist_ok=True) + print(f"Data: {DATA_DIR}") - print_environment_status( - variables - ) + config = validate_config() + print("Config: valid") + + if interactive: + configure_missing_calendars( + config, + test_urls=test_urls, + ) + load_dotenv(ENV_PATH, override=True) + + initialize_database() + print(f"Database: {DATABASE_PATH}") + + print_config_summary(config) + + variables = configured_environment_variables(config) + print_environment_status(variables) missing_enabled = [ variable - for variable, enabled, configured - in variables + for _, variable, enabled, configured in variables if enabled and not configured ] print() - print("=" * 72) + print("=" * 78) if missing_enabled: - print( - "SETUP INCOMPLETE" - ) + print("SETUP INCOMPLETE") print() - print( - "The following enabled calendar " - "variables are missing:" - ) - + print("Missing credentials for enabled calendars:") for variable in missing_enabled: + print(f" {variable}") + print() + if interactive: + print( + "Run 'studyos setup' again to configure them, " + "or disable calendars you do not use." + ) + else: print( - f" {variable}" + "Run 'studyos setup' interactively, or provide the " + "required environment variables." ) + return False - print() - print( - f"Add them to {ENV_PATH}" - ) + print("SETUP COMPLETE") + print("StudyOS is ready to use.") + print() + print("Next:") + print(" studyos task list") + print(" studyos update --days 14") + return True - return False - print( - "SETUP COMPLETE" - ) - print( - "StudyOS is ready to use." +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="studyos setup", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Initialize and configure StudyOS.\n\n" + "Setup creates required local files and directories, validates " + "config.toml, initializes the database, and interactively asks " + "for missing private calendar subscription URLs." + ), + epilog=( + "Examples:\n" + " studyos setup\n" + " studyos setup --non-interactive\n" + " studyos setup --no-test-urls\n\n" + "Diagnostics belong to 'studyos doctor'.\n" + "Private calendar URLs are stored in .env. Never commit or share it." + ), ) - return True + parser.add_argument( + "--non-interactive", + action="store_true", + help=( + "Initialize and validate without prompting for missing " + "calendar URLs." + ), + ) + parser.add_argument( + "--no-test-urls", + action="store_true", + help=( + "When entering calendar URLs interactively, save them without " + "testing the remote feed first." + ), + ) + return parser def main() -> None: - try: - success = run_setup() + parser = build_parser() + args = parser.parse_args() + try: + success = run_setup( + interactive=not args.non_interactive, + test_urls=not args.no_test_urls, + force_env=False, + ) except ( FileNotFoundError, ValueError, tomllib.TOMLDecodeError, ) as error: - raise SystemExit( - f"Setup error: {error}" - ) + raise SystemExit(f"Setup error: {error}") if not success: raise SystemExit(1) ===== DIFF: SCHEDULE / TASK / UPDATE ===== diff --git a/studyos/ics.py b/studyos/ics.py index 21b911e..3239d51 100644 --- a/studyos/ics.py +++ b/studyos/ics.py @@ -271,41 +271,32 @@ def export_ics( return output_path -def main() -> None: +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( + prog="studyos export", + formatter_class=argparse.RawDescriptionHelpFormatter, description=( - "Export StudyOS schedules as " - "configured ICS calendars." - ) - ) - - parser.add_argument( - "--start", - type=date.fromisoformat, - required=True, - help="Start date: YYYY-MM-DD", + "Generate configured ICS calendar exports.\n\n" + "The schedule is allocated once for the requested period and " + "written to every enabled export configured in config.toml." + ), + epilog=( + "Example:\n" + " studyos export --start 2026-10-01 --end 2026-10-14" + ), ) + parser.add_argument("--start", type=date.fromisoformat, required=True, metavar="YYYY-MM-DD", help="First date to export.") + parser.add_argument("--end", type=date.fromisoformat, required=True, metavar="YYYY-MM-DD", help="Last date to export.") + return parser - parser.add_argument( - "--end", - type=date.fromisoformat, - required=True, - help="End date: YYYY-MM-DD", - ) +def main() -> None: + parser = build_parser() args = parser.parse_args() - if args.end < args.start: - raise SystemExit( - "--end must be on or after --start" - ) - + parser.error("--end must be on or after --start") initialize_database() - - export_all_ics( - args.start, - args.end, - ) + export_all_ics(args.start, args.end) if __name__ == "__main__": diff --git a/studyos/schedule.py b/studyos/schedule.py index 1917f24..29d0ebf 100644 --- a/studyos/schedule.py +++ b/studyos/schedule.py @@ -5,43 +5,36 @@ from .allocator import print_allocation from .database import initialize_database -def main() -> None: - - initialize_database() - +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Generate an actual StudyOS task schedule." - ) - - parser.add_argument( - "--start", - type=date.fromisoformat, - help="Start date: YYYY-MM-DD", - ) - - parser.add_argument( - "--end", - type=date.fromisoformat, - help="End date: YYYY-MM-DD", + prog="studyos schedule", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Generate and print a task schedule.\n\n" + "StudyOS combines imported calendar commitments, planner " + "constraints, task deadlines, estimated work, and priority to " + "allocate study blocks." + ), + epilog=( + "Examples:\n" + " studyos schedule\n" + " studyos schedule --start 2026-10-01\n" + " studyos schedule --start 2026-10-01 --end 2026-10-14" + ), ) + parser.add_argument("--start", type=date.fromisoformat, metavar="YYYY-MM-DD", help="First day to schedule (default: today).") + parser.add_argument("--end", type=date.fromisoformat, metavar="YYYY-MM-DD", help="Last day to schedule (default: six days after --start).") + return parser - args = parser.parse_args() +def main() -> None: + args = build_parser().parse_args() start = args.start or date.today() - - end = args.end or ( - start + timedelta(days=6) - ) - + end = args.end or (start + timedelta(days=6)) if end < start: - raise SystemExit( - "--end must be on or after --start" - ) - - print_allocation( - start, - end, - ) + raise SystemExit("--end must be on or after --start") + initialize_database() + print_allocation(start, end) if __name__ == "__main__": diff --git a/studyos/task.py b/studyos/task.py index 8c8ce9a..10ad28e 100644 --- a/studyos/task.py +++ b/studyos/task.py @@ -4,6 +4,7 @@ from .database import initialize_database from .tasks import ( add_task, list_tasks, + remove_task, update_task_status, ) @@ -72,58 +73,74 @@ def print_tasks(include_done: bool) -> None: print() -def main() -> None: - - initialize_database() - +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="StudyOS task manager." + prog="studyos task", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Create and manage study tasks.\n\n" + "Tasks are allocated into available study blocks by the scheduler." + ), + epilog=( + "Examples:\n" + " studyos task list\n" + " studyos task add COURSE101 \"Review chapter 4\" " + "--deadline 2026-10-01 --hours 3\n" + " studyos task progress 4\n" + " studyos task done 4\n" + " studyos task remove 4\n\n" + "Run 'studyos task COMMAND --help' for command-specific help." + ), ) subparsers = parser.add_subparsers( dest="command", required=True, + metavar="COMMAND", ) - # ------------------------------------------------------------------ - # add - # ------------------------------------------------------------------ - add_parser = subparsers.add_parser( "add", help="Add a study task.", + description=( + "Create a new study task for an existing configured course." + ), + epilog=( + "Example:\n" + " studyos task add COURSE101 \"Review chapter 4\" " + "--deadline 2026-10-01 --hours 3" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) - add_parser.add_argument( "course", - choices=[ - "HF1005", - "HF1006", - "HI1024", - ], + help="Course code configured in StudyOS.", ) - add_parser.add_argument( "title", help="Task title.", ) - add_parser.add_argument( "--deadline", required=True, - help="Deadline: YYYY-MM-DD", + metavar="YYYY-MM-DD", + help="Task deadline.", ) - add_parser.add_argument( + duration = add_parser.add_mutually_exclusive_group( + required=True + ) + duration.add_argument( "--hours", type=float, - help="Estimated hours.", + metavar="HOURS", + help="Estimated work in hours; decimals are allowed.", ) - - add_parser.add_argument( + duration.add_argument( "--minutes", type=int, - help="Estimated minutes.", + metavar="MINUTES", + help="Estimated work in minutes.", ) add_parser.add_argument( @@ -131,80 +148,86 @@ def main() -> None: type=int, default=3, choices=[1, 2, 3, 4, 5], - help="Priority 1-5. 1 is highest.", + metavar="1-5", + help=( + "Priority; 1 is highest and 5 is lowest " + "(default: 3)." + ), ) - add_parser.add_argument( "--description", default=None, + metavar="TEXT", help="Optional task description.", ) - # ------------------------------------------------------------------ - # list - # ------------------------------------------------------------------ - list_parser = subparsers.add_parser( "list", help="List tasks.", + description=( + "List active StudyOS tasks ordered by deadline and priority." + ), ) - list_parser.add_argument( "--all", action="store_true", help="Include completed tasks.", ) - # ------------------------------------------------------------------ - # done - # ------------------------------------------------------------------ - done_parser = subparsers.add_parser( "done", help="Mark a task as completed.", + description="Mark an existing task as completed.", ) - done_parser.add_argument( "id", type=int, + help="Numeric task ID shown by 'studyos task list'.", ) - # ------------------------------------------------------------------ - # progress - # ------------------------------------------------------------------ - progress_parser = subparsers.add_parser( "progress", help="Mark a task as in progress.", + description="Mark an existing task as in progress.", ) - progress_parser.add_argument( "id", type=int, + help="Numeric task ID shown by 'studyos task list'.", ) - args = parser.parse_args() + remove_parser = subparsers.add_parser( + "remove", + help="Permanently remove a task.", + description="Permanently remove an existing study task.", + epilog=( + "Example:\n" + " studyos task remove 4" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + remove_parser.add_argument( + "id", + type=int, + help="Numeric task ID shown by 'studyos task list'.", + ) - try: + return parser - if args.command == "add": - if args.hours is not None and args.minutes is not None: - parser.error( - "Use either --hours or --minutes, not both." - ) +def main() -> None: + parser = build_parser() + args = parser.parse_args() - if args.hours is None and args.minutes is None: - parser.error( - "Specify --hours or --minutes." - ) + initialize_database() - if args.hours is not None: - estimated_minutes = round( - args.hours * 60 - ) - else: - estimated_minutes = args.minutes + try: + if args.command == "add": + estimated_minutes = ( + round(args.hours * 60) + if args.hours is not None + else args.minutes + ) task_id = add_task( course_code=args.course, @@ -221,33 +244,36 @@ def main() -> None: ) elif args.command == "list": - print_tasks( include_done=args.all ) elif args.command == "done": - update_task_status( args.id, "done", ) - print( f"Task #{args.id} marked as done." ) elif args.command == "progress": - update_task_status( args.id, "in_progress", ) - print( f"Task #{args.id} marked as in progress." ) + elif args.command == "remove": + remove_task( + args.id + ) + print( + f"Removed task #{args.id}." + ) + except ValueError as error: raise SystemExit( f"Error: {error}" @@ -255,4 +281,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/studyos/update.py b/studyos/update.py index b06f885..12fdfb6 100644 --- a/studyos/update.py +++ b/studyos/update.py @@ -6,68 +6,48 @@ from .ical_import import import_all_calendars from .ics import export_all_ics -def main() -> None: +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( + prog="studyos update", + formatter_class=argparse.RawDescriptionHelpFormatter, description=( - "Refresh configured calendars and regenerate " - "the StudyOS ICS exports." - ) - ) - - parser.add_argument( - "--days", - type=int, - default=14, - help=( - "Number of days to generate, starting today " - "(default: 14)." + "Refresh calendars and regenerate StudyOS exports.\n\n" + "Update imports every enabled remote calendar first, then creates " + "a fresh task schedule and writes every enabled ICS export." + ), + epilog=( + "Examples:\n" + " studyos update\n" + " studyos update --days 30" ), ) + parser.add_argument("--days", type=int, default=14, metavar="DAYS", help="Number of days to generate starting today (default: 14).") + return parser - args = parser.parse_args() +def main() -> None: + parser = build_parser() + args = parser.parse_args() if args.days < 1: - raise SystemExit( - "--days must be at least 1." - ) - + parser.error("--days must be at least 1") initialize_database() - - # Refresh all enabled calendar sources first. imported = import_all_calendars() - start = date.today() - end = start + timedelta( - days=args.days - 1 - ) - + end = start + timedelta(days=args.days - 1) print() print("=" * 80) print("STUDYOS UPDATE") print("=" * 80) - print( - f"Imported/updated calendar events: " - f"{imported}" - ) - print( - f"Generating schedule: " - f"{start} → {end}" - ) + print(f"Imported/updated calendar events: {imported}") + print(f"Generating schedule: {start} → {end}") print() - - output_paths = export_all_ics( - start, - end, - ) - + output_paths = export_all_ics(start, end) print() print("=" * 80) print("UPDATE COMPLETE") print("=" * 80) - for output_path in output_paths: print(f"ICS: {output_path}") - print() ===== DIFF: README ===== diff --git a/README.md b/README.md index 7692b78..aa81cec 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ The current settings are interpreted as follows: | `preferred_study_block_minutes` | Target maximum length for a normal candidate study session. | | `min_study_block_minutes` | Minimum block size used by the planner when generating candidate sessions. | | `min_free_gap_minutes` | Minimum free interval retained during free-time detection. | -| `study_weekends` | Intended switch for weekend candidate generation in the planner. | +| `study_weekends` | Enable or disable weekend study scheduling. | Some scheduling constants are currently hard-coded rather than configurable through TOML: @@ -231,9 +231,9 @@ Lunch interval: 12:00-13:00 The configuration module also currently defines sleep constants, but the shown scheduling pipeline does not use them. -## Weekend implementation detail +## Weekend scheduling -The planner's free-time functions consult `study_weekends`, but the allocator itself currently iterates only over weekdays. As a result, task allocation does not currently schedule weekend work even if `study_weekends = true`. This should be treated as a current implementation limitation rather than a fully supported weekend feature. +Both the planner and allocator respect `study_weekends`. When it is `false`, weekends are skipped; when it is `true`, weekend days can receive study blocks subject to the normal scheduling constraints. --- @@ -460,18 +460,17 @@ Only tasks whose status is not `done` are loaded for allocation. ## Add a task -The current CLI accepts three hard-coded course codes: +Tasks belong to courses managed by StudyOS. Configure courses first: -```text -HF1005 -HF1006 -HI1024 +```bash +studyos course add CS101 "Introduction to Computer Science" +studyos course list ``` Add a task using hours: ```bash -studyos task add HF1006 "Study vector spaces" \ +studyos task add "Study vector spaces" \ --deadline 2026-09-23 \ --hours 3 \ --priority 2 @@ -480,7 +479,7 @@ studyos task add HF1006 "Study vector spaces" \ Or use minutes: ```bash -studyos task add HI1024 "Complete programming exercises" \ +studyos task add "Complete programming exercises" \ --deadline 2026-09-25 \ --minutes 150 \ --priority 3 @@ -491,7 +490,7 @@ studyos task add HI1024 "Complete programming exercises" \ An optional description can be supplied: ```bash -studyos task add HF1006 "Review matrix methods" \ +studyos task add "Review matrix methods" \ --deadline 2026-09-23 \ --hours 2 \ --description "Review the relevant lecture material and exercises." @@ -654,14 +653,14 @@ STUDYOS TASK ALLOCATION THURSDAY 2026-09-17 ---------------------------------------------------------------------------------------- -08:00-10:00 HF1006 - Study vector spaces (2h 00m) -10:30-12:00 HF1006 - Review matrix methods (1h 30m) -13:00-15:00 HI1024 - Complete programming exercises (2h 00m) +08:00-10:00 CS101 - Study vector spaces (2h 00m) +10:30-12:00 CS101 - Review matrix methods (1h 30m) +13:00-15:00 CS102 - Complete programming exercises (2h 00m) REMAINING TASK WORK ---------------------------------------------------------------------------------------- -#1 HF1006 - Study vector spaces: COMPLETE -#2 HI1024 - Complete programming exercises: 0h 30m remaining +#1 CS101 - Study vector spaces: COMPLETE +#2 CS102 - Complete programming exercises: 0h 30m remaining ``` The exact schedule depends on current tasks, imported events, configuration, and the requested range. @@ -986,7 +985,7 @@ studyos update --days 14 When adding work: ```bash -studyos task add HF1006 "Review vector spaces" \ +studyos task add "Review vector spaces" \ --deadline 2026-09-23 \ --hours 3 \ --priority 2 @@ -1003,19 +1002,12 @@ The generated ICS files can then be consumed by a calendar application using wha The following limitations are present in the current implementation rather than merely hypothetical future features: -- `config.toml` must already exist before StudyOS can import successfully; setup cannot yet create it from nothing. -- Calendar subscription secrets are resolved through environment variables; the current calendar CLI does not itself store a newly supplied private URL. - Only `ics_url` calendar sources are supported. - All-day imported events are ignored. -- Calendar removal does not currently clean stale calendar metadata/events from SQLite. - Calendar configuration is loaded at import time, so changes made by a calendar-management command take full effect on the next StudyOS process. -- The current task CLI supports only the three predefined course codes. - Tasks cannot currently be edited or deleted through the CLI. - Generated study time is not persisted as completed task progress. -- The allocator does not currently allocate tasks whose deadlines have already passed, despite containing an overdue scoring branch. - Candidate blocks are atomic; unused time after a short task is not reassigned within the same block. -- The allocator contains a hard-coded 45-minute minimum fragment rule in addition to the configurable planner minimum. -- The allocator currently skips weekends independently of the planner's `study_weekends` setting. - Lunch and short-break times are hard-coded. - The current export `include` mechanism supports only `study_tasks`. - Stable UIDs depend on per-task block ordering. ===== GIT STATUS ===== M README.md M pyproject.toml M studyos/allocator.py M studyos/calendar.py M studyos/calendars.py M studyos/config.py M studyos/database.py M studyos/ics.py M studyos/main.py M studyos/planner.py M studyos/schedule.py M studyos/setup.py M studyos/task.py M studyos/tasks.py M studyos/update.py ?? docs/ ?? studyos/course.py ?? studyos/doctor.py ?? studyos/manual.py ?? studyos/studyos.1 ===== NEW FILE: studyos/course.py ===== import argparse from .database import connect, initialize_database def add_course(code: str, name: str) -> None: code = code.strip().upper() name = name.strip() if not code: raise ValueError("Course code cannot be empty.") if not name: raise ValueError("Course name cannot be empty.") with connect() as connection: try: connection.execute( "INSERT INTO courses (code, name) VALUES (?, ?)", (code, name), ) except Exception as error: if "UNIQUE constraint failed" in str(error): raise ValueError(f"Course '{code}' already exists.") from error raise def remove_course(code: str) -> None: code = code.strip().upper() with connect() as connection: task_count = connection.execute( "SELECT COUNT(*) FROM tasks WHERE course_code = ?", (code,) ).fetchone()[0] if task_count: raise ValueError( f"Cannot remove course '{code}': {task_count} task(s) still reference it." ) cursor = connection.execute("DELETE FROM courses WHERE code = ?", (code,)) if cursor.rowcount == 0: raise ValueError(f"Unknown course '{code}'.") def rename_course(code: str, name: str) -> None: code = code.strip().upper() name = name.strip() if not name: raise ValueError("Course name cannot be empty.") with connect() as connection: cursor = connection.execute( "UPDATE courses SET name = ? WHERE code = ?", (name, code) ) if cursor.rowcount == 0: raise ValueError(f"Unknown course '{code}'.") def print_courses() -> None: with connect() as connection: rows = connection.execute( "SELECT code, name FROM courses ORDER BY code" ).fetchall() if not rows: print("No courses configured. Add one with 'studyos course add'.") return width = max(6, max(len(row["code"]) for row in rows)) print(f"{'CODE':<{width}} NAME") print(f"{'-' * width} {'-' * 40}") for row in rows: print(f"{row['code']:<{width}} {row['name']}") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="studyos course", description="Manage courses used by StudyOS tasks.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " studyos course list\n" ' studyos course add CS101 "Introduction to Computer Science"\n' ' studyos course rename CS101 "Computer Science I"\n' " studyos course remove CS101" ), ) sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") sub.add_parser("list", help="List configured courses.") add = sub.add_parser("add", help="Add a course.") add.add_argument("code", help="Unique course code.") add.add_argument("name", help="Human-readable course name.") rename = sub.add_parser("rename", help="Rename a course.") rename.add_argument("code", help="Course code.") rename.add_argument("name", help="New course name.") remove = sub.add_parser("remove", help="Remove an unused course.") remove.add_argument("code", help="Course code.") return parser def main() -> None: args = build_parser().parse_args() initialize_database() try: if args.command == "list": print_courses() elif args.command == "add": add_course(args.code, args.name) print(f"Added course '{args.code.strip().upper()}'.") elif args.command == "rename": rename_course(args.code, args.name) print(f"Renamed course '{args.code.strip().upper()}'.") elif args.command == "remove": remove_course(args.code) print(f"Removed course '{args.code.strip().upper()}'.") except ValueError as error: raise SystemExit(f"Error: {error}") from error if __name__ == "__main__": main() ===== NEW FILE: studyos/doctor.py ===== import argparse from .setup import run_checks def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="studyos doctor", formatter_class=argparse.RawDescriptionHelpFormatter, description=( "Run read-only diagnostics for the local StudyOS installation.\n\n" "Doctor checks the Python version, configuration, private " "environment file, and calendar credentials without modifying " "the installation." ), epilog=( "Examples:\n" " studyos doctor\n" " studyos doctor --test-calendars\n\n" "Use 'studyos setup' to initialize or configure StudyOS." ), ) parser.add_argument( "--test-calendars", action="store_true", help="Make network requests to verify enabled remote calendar feeds.", ) return parser def main() -> None: args = build_parser().parse_args() if not run_checks(test_calendars=args.test_calendars): raise SystemExit(1) if __name__ == "__main__": main() ===== NEW FILE: studyos/manual.py ===== import argparse import os import shutil import subprocess from pathlib import Path def manual_path() -> Path: return Path(__file__).with_name("studyos.1") def plain_manual() -> str: return """StudyOS manual USAGE studyos [--version] COMMAND [OPTIONS] studyos help [COMMAND] studyos man COMMANDS setup Initialize and configure StudyOS. doctor Run read-only installation diagnostics. calendar Manage imported calendar sources. task Create and manage study tasks. schedule Generate and display a task schedule. export Generate configured ICS exports. update Refresh calendars and regenerate exports. man Open or print this manual. HELP Every command and nested command has its own help page: studyos --help studyos setup --help studyos doctor --help studyos calendar --help studyos calendar add --help studyos task --help studyos task add --help studyos schedule --help studyos export --help studyos update --help SETUP AND DIAGNOSTICS studyos setup Initialize local files, validate configuration, initialize the database, and request missing calendar credentials interactively. studyos setup --non-interactive Initialize and validate without prompting for credentials. studyos doctor Run read-only local diagnostics. studyos doctor --test-calendars Also contact enabled remote calendar feeds and validate them. CALENDARS studyos calendar list studyos calendar add ID NAME --url-env VARIABLE studyos calendar enable ID studyos calendar disable ID studyos calendar blocking ID on|off studyos calendar remove ID TASKS studyos task list studyos task add TITLE --deadline YYYY-MM-DD --hours HOURS studyos task progress ID studyos task done ID FILES config.toml Non-secret StudyOS configuration. .env Private local values such as ICS subscription URLs. Never commit it. data/studyos.sqlite3 Local StudyOS database. """ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="studyos man", description="Open the StudyOS manual page or print a plain-text version.", ) parser.add_argument( "--print", action="store_true", dest="print_only", help="Print plain text instead of opening the system man viewer.", ) return parser def main(argv: list[str] | None = None) -> None: args = build_parser().parse_args(argv) path = manual_path() if not args.print_only and os.name != "nt" and shutil.which("man") and path.exists(): raise SystemExit(subprocess.call(["man", "-l", str(path)])) print(plain_manual()) if __name__ == "__main__": main() ===== NEW FILE: docs/studyos.1 ===== .TH STUDYOS 1 "September 2026" "StudyOS 0.1.0" "User Commands" .SH NAME studyos \- calendar-aware study planning and scheduling .SH SYNOPSIS .B studyos .RI [ --help ] .br .B studyos .I COMMAND .RI [ OPTIONS ] .SH DESCRIPTION StudyOS imports calendar commitments, manages courses and study tasks, allocates work into available time, and exports generated schedules as ICS calendars. .SH COMMANDS .TP .B setup Initialize and configure StudyOS. On a first run it can create a generic config.toml, .env, data directory, and database. .TP .B doctor Run read-only installation and configuration diagnostics. Use --test-calendars to verify enabled remote feeds. .TP .B calendar Manage imported ICS calendar sources. Subcommands: list, add, enable, disable, blocking, remove. .TP .B course Manage courses. Subcommands: list, add, rename, remove. .TP .B task Manage study tasks. Subcommands: list, add, progress, done. .TP .B schedule Generate and print a study schedule. .TP .B export Generate configured ICS exports for an explicit date range. .TP .B update Refresh enabled calendars and regenerate exports. .TP .B man Open this manual, or print plain text with --print. .SH CALENDARS The normal interactive form is: .PP .nf studyos calendar add work "Work calendar" .fi .PP StudyOS securely prompts for the private ICS URL and stores it in .env. For non-interactive use, pass --url URL. --url-env VARIABLE overrides the generated environment-variable name. .SH COURSES Courses are not hard-coded. Add them before creating tasks: .PP .nf studyos course add CS101 "Introduction to Computer Science" studyos course list .fi .PP A course cannot be removed while tasks still reference it. .SH TASKS Example: .PP .nf studyos task add CS101 "Review chapter 4" --deadline 2026-10-01 --hours 3 .fi .PP Use either --hours or --minutes. Priority ranges from 1 (highest) to 5 (lowest). .SH CONFIGURATION The main configuration is config.toml. Private calendar URLs are stored in .env and should never be committed. The study_weekends and min_study_block_minutes settings are respected by both planning and task allocation. .SH EXAMPLES .nf studyos setup studyos doctor studyos calendar list studyos course list studyos task list studyos schedule studyos update --days 14 .fi .SH FILES .TP .I config.toml Public StudyOS configuration. .TP .I .env Private calendar credentials. Do not commit or share this file. .TP .I data/studyos.sqlite3 Local SQLite database by default. .SH SEE ALSO Use "studyos COMMAND --help" for command-specific help. ===== NEW FILE: studyos/studyos.1 ===== .TH STUDYOS 1 "September 2026" "StudyOS 0.1.0" "User Commands" .SH NAME studyos \- calendar-aware study planning and scheduling .SH SYNOPSIS .B studyos .RI [ --help ] .br .B studyos .I COMMAND .RI [ OPTIONS ] .SH DESCRIPTION StudyOS imports calendar commitments, manages courses and study tasks, allocates work into available time, and exports generated schedules as ICS calendars. .SH COMMANDS .TP .B setup Initialize and configure StudyOS. On a first run it can create a generic config.toml, .env, data directory, and database. .TP .B doctor Run read-only installation and configuration diagnostics. Use --test-calendars to verify enabled remote feeds. .TP .B calendar Manage imported ICS calendar sources. Subcommands: list, add, enable, disable, blocking, remove. .TP .B course Manage courses. Subcommands: list, add, rename, remove. .TP .B task Manage study tasks. Subcommands: list, add, progress, done. .TP .B schedule Generate and print a study schedule. .TP .B export Generate configured ICS exports for an explicit date range. .TP .B update Refresh enabled calendars and regenerate exports. .TP .B man Open this manual, or print plain text with --print. .SH CALENDARS The normal interactive form is: .PP .nf studyos calendar add work "Work calendar" .fi .PP StudyOS securely prompts for the private ICS URL and stores it in .env. For non-interactive use, pass --url URL. --url-env VARIABLE overrides the generated environment-variable name. .SH COURSES Courses are not hard-coded. Add them before creating tasks: .PP .nf studyos course add CS101 "Introduction to Computer Science" studyos course list .fi .PP A course cannot be removed while tasks still reference it. .SH TASKS Example: .PP .nf studyos task add CS101 "Review chapter 4" --deadline 2026-10-01 --hours 3 .fi .PP Use either --hours or --minutes. Priority ranges from 1 (highest) to 5 (lowest). .SH CONFIGURATION The main configuration is config.toml. Private calendar URLs are stored in .env and should never be committed. The study_weekends and min_study_block_minutes settings are respected by both planning and task allocation. .SH EXAMPLES .nf studyos setup studyos doctor studyos calendar list studyos course list studyos task list studyos schedule studyos update --days 14 .fi .SH FILES .TP .I config.toml Public StudyOS configuration. .TP .I .env Private calendar credentials. Do not commit or share this file. .TP .I data/studyos.sqlite3 Local SQLite database by default. .SH SEE ALSO Use "studyos COMMAND --help" for command-specific help.