| ===== 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 <COURSE> "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 <COURSE> "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 <COURSE> "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 <COURSE> "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 <COURSE> 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.
|