diff --git a/app.py b/app.py
index 3b697d0..a0aa277 100644
--- a/app.py
+++ b/app.py
@@ -42,6 +42,38 @@ def inject_supabase_config():
supabase_key=SUPABASE_KEY # important to pass the key and not service_role_key
)
+
+
+def rebuild_requirements_from_details(details):
+ buckets = {}
+ for code, course in (details or {}).items():
+ bucket_id = course.get("requirement_bucket")
+ if not bucket_id:
+ continue
+ bucket = buckets.setdefault(bucket_id, {
+ "id": bucket_id,
+ "title": course.get("requirement_bucket_title") or bucket_id,
+ "category": course.get("category", "CORE"),
+ "min_credits": course.get("requirement_min_credits", 0),
+ "max_credits": course.get("requirement_max_credits"),
+ "courses": [],
+ })
+ bucket["courses"].append(code)
+ return {"buckets": list(buckets.values())}
+
+
+def merge_program_requirements(current, incoming):
+ merged = dict(current or {})
+ existing_buckets = {bucket.get("id"): bucket for bucket in merged.get("buckets", [])}
+ for bucket in (incoming or {}).get("buckets", []):
+ bucket_id = bucket.get("id")
+ if not bucket_id or bucket_id in existing_buckets:
+ continue
+ existing_buckets[bucket_id] = bucket
+ merged["buckets"] = list(existing_buckets.values())
+ merged["course_to_bucket"] = {**(current or {}).get("course_to_bucket", {}), **(incoming or {}).get("course_to_bucket", {})}
+ return merged
+
courses_info = {}
with open('static/json/courses_info.json', 'r', encoding='utf-8') as f:
courses_info = json.load(f)
@@ -89,6 +121,7 @@ def scrape_form():
#Saving to session
session['prereqs_data'] = prereqs_data
session['details_data'] = details_data
+ session['program_requirements'] = rebuild_requirements_from_details(details_data)
session['graph_data_available'] = True
return redirect("graph")
@@ -97,11 +130,23 @@ def scrape_form():
major = request.args.get("programSearch")
if action == "Visualize Program":
- courses_prereqs_data, processed_details_data = get_information_for_major.process_program_data(url, major) #scrape the major's site and grab all info
+ program_data = get_information_for_major.process_program_data(url, major)
+ if not isinstance(program_data, tuple):
+ return render_template('scrape_form.html', error="Unable to scrape that program. Please try again later.")
+
+ if len(program_data) == 2:
+ courses_prereqs_data, processed_details_data = program_data
+ requirements_data = {}
+ else:
+ courses_prereqs_data, processed_details_data, requirements_data = program_data #scrape the major's site and grab all info
+ if courses_prereqs_data is None or processed_details_data is None:
+ return render_template('scrape_form.html', error="Unable to scrape that program. Please try again later.")
#Saving the variable to session
session['prereqs_data'] = courses_prereqs_data
session['details_data'] = processed_details_data
+ session['program_requirements'] = requirements_data or {}
+ session['credit_requirements'] = (requirements_data or {}).get('credit_requirements', {'core': 0, 'comp': 0, 'elec': 0})
session['graph_data_available'] = True
return redirect("graph")
@@ -113,6 +158,7 @@ def scrape_form():
session.pop('prereqs_data', None)
session.pop('details_data', None)
session.pop('graph_data_available', None)
+ session.pop('program_requirements', None)
# This is an initial GET request to the form
return render_template('scrape_form.html')
else:
@@ -127,7 +173,8 @@ def graph():
prereqs = session.get('prereqs_data', {})
details = session.get('details_data', {})
logging.log_entry(request, "displaying graph")
- return render_template('graph.html', prereqs=prereqs, details=details)
+ requirements = session.get('program_requirements') or rebuild_requirements_from_details(details)
+ return render_template('graph.html', prereqs=prereqs, details=details, requirements=requirements)
else:
# If no data, redirect back to home page
return redirect(url_for('scrape_form'))
@@ -151,7 +198,14 @@ def add_program_to_graph():
logging.log_entry(request, f"adding program {new_program_name} (url: {new_program_url}) to graph")
# Fetch and process data for the new program
- new_prereqs, new_details = get_information_for_major.process_program_data(new_program_url, new_program_name)
+ new_program_data = get_information_for_major.process_program_data(new_program_url, new_program_name)
+ if len(new_program_data) == 2:
+ new_prereqs, new_details = new_program_data
+ new_requirements = {}
+ else:
+ new_prereqs, new_details, new_requirements = new_program_data
+ if new_prereqs is None or new_details is None:
+ raise ValueError("Unable to scrape that program.")
# Retrieve current graph data from session
current_prereqs = session.get('prereqs_data', {})
@@ -174,6 +228,7 @@ def add_program_to_graph():
session['details_data'].update(new_details)
session['prereqs_data'].update(new_prereqs)
+ session['program_requirements'] = merge_program_requirements(session.get('program_requirements', {}), new_requirements or {})
session['graph_data_available'] = True
# NEW: If JavaScript asked for this, send the raw JSON data back!
@@ -181,7 +236,8 @@ def add_program_to_graph():
return jsonify({
"status": "success",
"new_details": new_details,
- "new_prereqs": new_prereqs
+ "new_prereqs": new_prereqs,
+ "new_requirements": new_requirements or {}
})
return redirect(url_for('graph'))
@@ -394,6 +450,7 @@ def load_graph():
# Load the data into the Flask session
session['prereqs_data'] = graph.get('prereqs_data', {})
session['details_data'] = graph.get('details_data', {})
+ session['program_requirements'] = rebuild_requirements_from_details(session['details_data'])
session['schedule_id'] = graph.get('id')
session['graph_data_available'] = True
diff --git a/scripts/Getting_Info_For_Major/get_courses_of_major.py b/scripts/Getting_Info_For_Major/get_courses_of_major.py
index 3b9339f..0351a16 100644
--- a/scripts/Getting_Info_For_Major/get_courses_of_major.py
+++ b/scripts/Getting_Info_For_Major/get_courses_of_major.py
@@ -1,24 +1,85 @@
+import re
import requests
from bs4 import BeautifulSoup
-#Given url of the major, it scrapes the site and gets all the courses of the major.
-#Eventually this could be ran on all programs and be hard-coded instead of running on every query
+# Given url of the major, it scrapes the site and gets all the courses of the major.
+# Eventually this could be run on all programs and be hard-coded instead of running on every query.
-def get_program_codes(url):
+HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+}
+
+COURSE_CODE_RE = re.compile(r"\b[A-Z]{3,5}\s?\d{3}[A-Z0-9]?\b")
+
+
+def get_program_soup(url):
+ # Passing a header to mimic a real user instead.
+ res = requests.get(url, headers=HEADERS)
+ res.raise_for_status()
+ return BeautifulSoup(res.text, "html.parser")
- #Passing a header to mimic a real user instead
- headers = {
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
- }
- res = requests.get(url, headers=headers)
- soup = BeautifulSoup(res.text, 'html.parser')
+def get_program_codes(url, soup=None):
+ soup = soup or get_program_soup(url)
- #finding all required courses:
+ # On McGill pages, program course rows use
COMP 250 | .
course_codes = soup.find_all("td", class_="codecol")
- course_codes = [code.get_text(strip=True) for code in course_codes]
-
- return course_codes
+ return [code.get_text(strip=True) for code in course_codes]
+
+
+def get_course_metadata_from_program_page(code, soup):
+ """Best-effort extraction of title/credits/terms from a program page table row.
+
+ This is only a fallback for courses missing from static/json/courses_info.json.
+ It does NOT replace the Gemini prerequisite pipeline.
+ """
+ # Locate the correct code cell
+ code_cell = soup.find("td", class_="codecol", string=lambda txt: txt and code in txt)
+ if not code_cell:
+ for candidate in soup.find_all("td", class_="codecol"):
+ if candidate.get_text(strip=True) == code:
+ code_cell = candidate
+ break
-
-
\ No newline at end of file
+ if not code_cell:
+ return None
+
+ # Get the parent row of the code cell
+ row = code_cell.find_parent("tr")
+ if not row:
+ return None
+
+ title = ""
+ credits = "N/A"
+ semesters_offered = "Unknown"
+
+ # Extract Title
+ title_cell = code_cell.find_next_sibling("td")
+ if title_cell:
+ title = title_cell.get_text(strip=True).rstrip(".")
+
+ # Extract Credits
+ if title_cell:
+ hours_cell = title_cell.find_next_sibling("td")
+ if hours_cell:
+ hours_text = hours_cell.get_text(strip=True)
+ credit_match = re.search(r"(\d+(?:\.\d+)?)", hours_text)
+ if credit_match:
+ credits = credit_match.group(1)
+
+ # Extract Semesters Offered from the adjacent bubbledrawer row
+ # find_next_sibling find the next tag directly below the current course row
+ drawer_row = row.find_next_sibling("tr", class_="bubbledrawer")
+ if drawer_row:
+ terms_paragraph = drawer_row.find("p", class_="bubbledrawer__terms")
+ if terms_paragraph:
+ # Extract text and strip out the "Terms offered:" prefix label
+ raw_terms = terms_paragraph.get_text(" ", strip=True)
+ semesters_offered = raw_terms.replace("Terms offered:", "").strip().strip('"')
+
+ return {
+ "code": code,
+ "title": title or f"{code} (Details not found)",
+ "credits": credits,
+ "semesters_offered": semesters_offered,
+ }
diff --git a/scripts/Getting_Info_For_Major/get_information_for_major.py b/scripts/Getting_Info_For_Major/get_information_for_major.py
index 7ef8cfa..05a1428 100644
--- a/scripts/Getting_Info_For_Major/get_information_for_major.py
+++ b/scripts/Getting_Info_For_Major/get_information_for_major.py
@@ -1,6 +1,7 @@
-from scripts.Getting_Info_For_Major import get_courses_of_major, get_prereqs
+from scripts.Getting_Info_For_Major import get_courses_of_major, get_prereqs, program_requirements
from scripts import utils
import json
+import re
#Given the link from provided from the programs.json file, it grabs all the courses of the major, and compiles all the info for each course.
@@ -15,10 +16,25 @@
def process_program_data(program_url, major):
try:
- course_codes = get_courses_of_major.get_program_codes(program_url)
+ program_soup = get_courses_of_major.get_program_soup(program_url)
+ course_codes = get_courses_of_major.get_program_codes(program_url, soup=program_soup)
+ requirements_data = program_requirements.extract_program_requirements(program_soup)
+ major_slug = re.sub(r"[^a-z0-9]+", "-", (major or "program").lower()).strip("-") or "program"
+ for idx, bucket in enumerate(requirements_data.get("buckets", []), start=1):
+ bucket["program_name"] = major
+ bucket_id = bucket.get("id") or f"bucket-{idx}"
+ if not str(bucket_id).startswith(f"{major_slug}-"):
+ bucket["id"] = f"{major_slug}-{bucket_id}"
+
+ requirements_data["course_to_bucket"] = {
+ code: (f"{major_slug}-{bucket_id}" if bucket_id and not str(bucket_id).startswith(f"{major_slug}-") else bucket_id)
+ for code, bucket_id in requirements_data.get("course_to_bucket", {}).items()
+ }
+ rule_texts=[b.get("constraints_text") for b in requirements_data.get("buckets",[]) if b.get("constraints_text")]
+ requirements_data["parsed_rules"] = get_prereqs.parse_requirement_rules(major, rule_texts) if rule_texts else []
if not course_codes:
print(f"No course codes found for URL: {program_url}")
- return None, None
+ return None, None, None
course_details_data = {}
gemini_data = {}
@@ -37,8 +53,8 @@ def process_program_data(program_url, major):
}
else:
print(f"Warning: Course code {code} from program {major} not found in global courses_info.json")
- # Add a placeholder if not found in global courses_info
- course_details_data[code] = {
+ fallback_metadata = get_courses_of_major.get_course_metadata_from_program_page(code, program_soup)
+ course_details_data[code] = fallback_metadata or {
"title": f"{code} (Details not found)",
"credits": "N/A",
"semesters_offered": "Unknown",
@@ -49,6 +65,9 @@ def process_program_data(program_url, major):
courses_prereqs_data = {}
if gemini_data:
courses_prereqs_data = get_prereqs.get_prereq_list(major, gemini_data)
+ if not isinstance(courses_prereqs_data, dict):
+ print(f"Warning: Gemini prereq response was not a dict for program {major}. Got type={type(courses_prereqs_data).__name__}")
+ courses_prereqs_data = {}
else:
print(f"No data prepared for Gemini for program: {major}")
@@ -58,8 +77,19 @@ def process_program_data(program_url, major):
for code, details in course_details_data.items():
default_detail = {"title": "Unknown Title", "credits": "N/A", "semesters_offered": "Unknown"}
actual_details = {**default_detail, **details}
+ bucket_id = requirements_data.get("course_to_bucket", {}).get(code)
+ bucket = next(
+ (bucket for bucket in requirements_data.get("buckets", []) if bucket.get("id") == bucket_id),
+ None,
+ )
course_details_full[code] = {
**actual_details,
+ "category": "CORE" if (bucket and bucket.get("category") == "CORE") else "COMPLEMENTARY",
+ "requirement_bucket": bucket_id,
+ "requirement_bucket_title": bucket.get("title") if bucket else None,
+ "requirement_min_credits": bucket.get("min_credits") if bucket else 0,
+ "requirement_max_credits": bucket.get("max_credits") if bucket else None,
+ "include_in_graph": True if (bucket and bucket.get("category") == "CORE") else False,
"color": utils.parse_semester_to_color(actual_details.get("semesters_offered", ""))
}
@@ -69,7 +99,7 @@ def process_program_data(program_url, major):
if course_code_prereq in courses_info:
course_details_full[course_code_prereq] = {
- "title": f"{courses_info[course_code_prereq]["Title"]} (Prereq)",
+ "title": f"{courses_info[course_code_prereq]['Title']} (Prereq)",
"credits": courses_info[course_code_prereq]["Credits"],
"semesters_offered": courses_info[course_code_prereq]["Terms_Offered"],
"color": "LightSteelBlue"
@@ -90,7 +120,7 @@ def process_program_data(program_url, major):
if prereq_item not in course_details_full:
if prereq_item in courses_info:
course_details_full[prereq_item] = {
- "title": f"{courses_info[prereq_item]["Title"]} (Prereq)",
+ "title": f"{courses_info[prereq_item]['Title']} (Prereq)",
"credits": courses_info[prereq_item]["Credits"],
"semesters_offered": courses_info[prereq_item]["Terms_Offered"],
"color": "LightSteelBlue"
@@ -99,7 +129,7 @@ def process_program_data(program_url, major):
course_details_full[prereq_code] = {"title": "Details Not Found (Prereq)", "credits": "N/A", "semesters_offered": "Unknown", "color": "LightSteelBlue"}
- return courses_prereqs_data, course_details_full
+ return courses_prereqs_data, course_details_full, requirements_data
except Exception as e:
print(f"Error in process_program_data for {program_url}: {e}")
- return None, None
\ No newline at end of file
+ return None, None, None
diff --git a/scripts/Getting_Info_For_Major/get_prereqs.py b/scripts/Getting_Info_For_Major/get_prereqs.py
index b1785ee..2d57aea 100644
--- a/scripts/Getting_Info_For_Major/get_prereqs.py
+++ b/scripts/Getting_Info_For_Major/get_prereqs.py
@@ -30,7 +30,7 @@ def get_prereq_list(major_name, major_courses_data):
contents=prompt
)
except Exception as e:
- return "There was an error with contacting Gemini"
+ return {}
text_response = response.text.strip()
@@ -45,4 +45,26 @@ def get_prereq_list(major_name, major_courses_data):
resolved_prereqs = json.loads(text_response.replace("'", '"'))
return resolved_prereqs
except Exception as e:
- return f"Error: {e}\nResponse from Gemini: {response}"
+ return {}
+
+
+def parse_requirement_rules(major_name, rule_texts):
+ load_dotenv()
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
+ client = genai.Client(api_key=GEMINI_API_KEY)
+ prompt = f"""
+ For McGill program '{major_name}', convert these plain-English degree rules into strict JSON array.
+ Output ONLY JSON. Schema per item: {{"rule_text": str, "allowed_prefixes": [str], "min_level": int|null, "exclude_courses": [str], "must_have_credits_at_level": {{"level": int, "credits": int}}|null}}
+ Rules:
+ {json.dumps(rule_texts)}
+ """
+ try:
+ resp = client.models.generate_content(model='gemini-2.5-flash-lite', contents=prompt)
+ txt = resp.text.strip()
+ if txt.startswith("```json"):
+ txt = txt[len("```json"):].strip()
+ if txt.endswith("```"):
+ txt = txt[:-3].strip()
+ return json.loads(txt)
+ except Exception:
+ return []
diff --git a/scripts/Getting_Info_For_Major/program_requirements.py b/scripts/Getting_Info_For_Major/program_requirements.py
new file mode 100644
index 0000000..872f364
--- /dev/null
+++ b/scripts/Getting_Info_For_Major/program_requirements.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+COURSE_CODE_RE = re.compile(r"\b[A-Z]{3,5}\s?\d{3}[A-Z0-9]?\b")
+CREDIT_RANGE_RE = re.compile(r"(?P\d+(?:\.\d+)?)\s*(?:-|–|to)\s*(?P\d+(?:\.\d+)?)\s+credits?", re.IGNORECASE)
+SINGLE_CREDIT_RE = re.compile(r"(?P\d+(?:\.\d+)?)\s+credits?", re.IGNORECASE)
+
+
+@dataclass
+class RequirementBucket:
+ id: str
+ title: str
+ category: str
+ min_credits: float = 0
+ max_credits: float | None = None
+ courses: list[str] = field(default_factory=list)
+ constraints_text: str | None = None
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "title": self.title,
+ "category": self.category,
+ "min_credits": self.min_credits,
+ "max_credits": self.max_credits,
+ "courses": self.courses,
+ "constraints_text": self.constraints_text,
+ }
+
+
+def _clean_text(text: str) -> str:
+ return re.sub(r"\s+", " ", (text or "")).strip()
+
+
+def _slugify(value: str, fallback: str) -> str:
+ value = _clean_text(value).lower()
+ slug = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
+ return slug or fallback
+
+
+def _category_from_heading(heading: str) -> str:
+ h = heading.lower()
+ if "required" in h:
+ return "CORE"
+ return "COMPLEMENTARY"
+
+
+
+
+def _is_required_heading(text: str) -> bool:
+ t = text.lower()
+ return "required" in t
+
+
+def _is_footer_or_site_text(text: str) -> bool:
+ t = text.lower()
+ blockers = ["copyright", "cookie", "privacy", "contact us", "helpful links", "course catalogue"]
+ return any(b in t for b in blockers)
+
+
+def _looks_like_constraint(text: str) -> bool:
+ t = text.lower()
+ return ("credit" in t) and ("selected from" in t or "excluding" in t or "level or above" in t or "must be at the" in t)
+
+
+def _is_note_text(text: str) -> bool:
+ t = text.lower().strip()
+ return t.startswith("note:") or t.startswith("notes:") or t.startswith("n.b.")
+
+
+def _is_choice_text(text: str) -> bool:
+ t = text.lower().strip()
+ return t in {"one of:", "two of:", "three of:"} or t.endswith(" of:")
+
+def _credit_bounds(text: str) -> tuple[float, float | None]:
+ text = _clean_text(text)
+ range_match = CREDIT_RANGE_RE.search(text)
+ if range_match:
+ return float(range_match.group("min")), float(range_match.group("max"))
+ single_match = SINGLE_CREDIT_RE.search(text)
+ if single_match:
+ c = float(single_match.group("credits"))
+ return c, c
+ return 0, None
+
+
+
+
+def _extract_inline_courses(text: str) -> list[str]:
+ return list(dict.fromkeys([m.group(0) for m in COURSE_CODE_RE.finditer(text)]))
+
+
+def _flush_standalone_text_bucket(text: str, buckets: list[RequirementBucket], current_section_title: str) -> None:
+ min_credits, max_credits = _credit_bounds(text)
+ courses = _extract_inline_courses(text)
+ bucket = RequirementBucket(
+ id=_slugify(f"{current_section_title}-{text}", f"bucket-{len(buckets)+1}"),
+ title=text,
+ category="COMPLEMENTARY",
+ min_credits=min_credits,
+ max_credits=max_credits,
+ courses=courses,
+ constraints_text=text,
+ )
+ buckets.append(bucket)
+
+def extract_program_requirements(soup):
+ content_root = soup.find(id="coursestext") or soup.find("main") or soup
+
+ buckets: list[RequirementBucket] = []
+ current_section_title = "Program Courses"
+ current_section_category = "CORE"
+ pending_bucket_label = ""
+ pending_constraint_text = ""
+
+ for element in content_root.find_all(["h2", "p", "div"], recursive=True):
+ if element.name == "h2":
+ current_section_title = _clean_text(element.get_text(" ", strip=True))
+ current_section_category = _category_from_heading(current_section_title)
+ pending_bucket_label = ""
+ pending_constraint_text = ""
+ continue
+
+ if element.name == "p":
+ text = _clean_text(element.get_text(" ", strip=True))
+ if _is_footer_or_site_text(text):
+ continue
+ if _is_note_text(text):
+ continue
+
+ if "selected from" in text.lower() and "credit" in text.lower():
+ pending_bucket_label = text
+ pending_constraint_text = ""
+ next_div = element.find_next_sibling()
+ while next_div and getattr(next_div, "name", None) is None:
+ next_div = next_div.find_next_sibling()
+ while next_div and next_div.name == "p" and _is_note_text(_clean_text(next_div.get_text(" ", strip=True))):
+ next_div = next_div.find_next_sibling()
+ if not (next_div and next_div.name == "div" and "courselist-wrapper" in (next_div.get("class") or [])):
+ _flush_standalone_text_bucket(text, buckets, current_section_title)
+ pending_bucket_label = ""
+ elif _is_choice_text(text):
+ pending_bucket_label = text
+ pending_constraint_text = ""
+
+ elif _looks_like_constraint(text):
+ pending_constraint_text = text
+ next_div = element.find_next_sibling()
+ while next_div and getattr(next_div, "name", None) is None:
+ next_div = next_div.find_next_sibling()
+ if not (next_div and next_div.name == "div" and "courselist-wrapper" in (next_div.get("class") or [])):
+ _flush_standalone_text_bucket(text, buckets, current_section_title)
+ pending_constraint_text = ""
+ continue
+
+ if element.name == "div" and "courselist-wrapper" in (element.get("class") or []):
+ table = element.find("table", class_="sc_courselist")
+ if not table:
+ continue
+
+ bucket_label = pending_bucket_label or current_section_title
+ if _is_choice_text(bucket_label):
+ choice_num = len([b for b in buckets if b.id.startswith(_slugify(current_section_title, "section"))]) + 1
+ bucket_label = f"{bucket_label[:-1]} (Option Group {choice_num})"
+ pending_bucket_label = ""
+
+ min_credits, max_credits = _credit_bounds(bucket_label)
+ lower_label = bucket_label.lower()
+ has_selection = ("selected from" in lower_label) or ("choose" in lower_label) or (max_credits is not None and max_credits > min_credits)
+ bucket_category = "CORE" if (_is_required_heading(current_section_title) and not has_selection and bucket_label == current_section_title) else "COMPLEMENTARY"
+ bucket = RequirementBucket(
+ id=_slugify(f"{current_section_title}-{bucket_label}", f"bucket-{len(buckets)+1}"),
+ title=bucket_label,
+ category=bucket_category,
+ min_credits=min_credits,
+ max_credits=max_credits,
+ constraints_text=(pending_constraint_text if (pending_constraint_text and _clean_text(pending_constraint_text) != _clean_text(bucket_label)) else None),
+ )
+
+ for row in table.select("tbody > tr"):
+ row_classes = row.get("class") or []
+ if "bubbledrawer" in row_classes:
+ continue
+ code_cell = row.find("td", class_="codecol")
+ if not code_cell:
+ continue
+ match = COURSE_CODE_RE.search(_clean_text(code_cell.get_text(" ", strip=True)))
+ if match:
+ bucket.courses.append(match.group(0))
+
+ if bucket.courses:
+ bucket.courses = list(dict.fromkeys(bucket.courses))
+ buckets.append(bucket)
+ pending_constraint_text = ""
+
+ deduped=[]
+ seen=set()
+ for b in buckets:
+ key=(b.title,b.category,tuple(sorted(b.courses)))
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(b)
+ buckets=deduped
+
+ # deterministic post-pass: any bucket whose title includes "Required" is CORE.
+ for b in buckets:
+ if _is_required_heading(b.title):
+ b.category = "CORE"
+
+ course_to_bucket = {}
+ totals = {"core": 0.0, "comp": 0.0, "elec": 0.0}
+ for b in buckets:
+ for c in b.courses:
+ course_to_bucket.setdefault(c, b.id)
+ if b.category == "CORE":
+ totals["core"] += b.min_credits
+ elif b.category == "COMPLEMENTARY":
+ totals["comp"] += b.min_credits
+ else:
+ totals["elec"] += b.min_credits
+
+ return {
+ "buckets": [b.to_dict() for b in buckets],
+ "course_to_bucket": course_to_bucket,
+ "credit_requirements": {k: int(v) if v.is_integer() else v for k, v in totals.items()},
+ }
diff --git a/static/css/style.css b/static/css/style.css
index ab7405f..f980973 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -270,7 +270,7 @@ button:disabled {
color: var(--mcgill-red);
border-bottom: 2px solid var(--surface-color); /* Erases the line underneath */
- /* The Magic Fix: Uses an inner shadow to draw a perfect red line
+ /* The Magic Fix: Uses an inner shadow to draw a perfect red line
that naturally hugs the curves without looking warped! */
box-shadow: inset 0 3px 0 0 var(--mcgill-red);
}
@@ -1548,3 +1548,240 @@ button#scrapeButton:disabled {
transform: translate(-50%, -50%) rotate(360deg);
}
}
+
+
+.optional-shelf {
+ margin-left: auto;
+ min-width: 300px;
+ max-width: 380px;
+ background: #fafafa;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 10px;
+}
+.optional-count {
+ color: var(--text-muted);
+ font-size: 0.9em;
+ margin-bottom: 8px;
+}
+.optional-course-list {
+ max-height: 220px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.optional-course-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px;
+ background: #fff;
+}
+
+
+.optional-bucket {
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: #fff;
+ padding: 8px;
+ margin-bottom: 8px;
+}
+.optional-bucket h5 {
+ margin: 0 0 4px 0;
+ font-size: 0.95rem;
+}
+.optional-bucket-meta {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+.optional-course-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.88rem;
+ padding: 6px 0;
+ border-top: 1px dashed var(--border-color);
+}
+
+.optional-bucket-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin: 8px 0 10px 0;
+}
+
+.optional-bucket-actions .secondary-btn,
+.optional-bucket-actions .danger-btn {
+ min-width: 40px;
+ padding: 6px 10px;
+ font-size: 1rem;
+}
+.optional-course-row:first-of-type {
+ border-top: none;
+}
+.secondary-btn {
+ background: #1f2937;
+}
+.danger-btn {
+ background: #b91c1c;
+}
+
+.optional-shelf-title { margin: 0 0 6px 0; }
+
+
+.planner-layout {
+ display: grid;
+ grid-template-columns: 1fr 360px;
+ gap: 14px;
+ align-items: start;
+}
+
+#courseNetwork {
+ min-height: 760px;
+}
+
+.optional-shelf {
+ margin-left: 0;
+ width: 100%;
+ max-width: none;
+ position: sticky;
+ top: 90px;
+ max-height: calc(100vh - 140px);
+ overflow: hidden;
+}
+
+.optional-course-list {
+ max-height: calc(100vh - 230px);
+}
+
+@media (max-width: 1200px) {
+ .planner-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .optional-shelf {
+ position: static;
+ max-height: none;
+ }
+
+ .optional-course-list {
+ max-height: 320px;
+ }
+}
+
+.optional-rule-box {
+ margin: 6px 0 8px;
+ padding: 6px 8px;
+ border: 1px solid #d6d3d1;
+ background: #fafaf9;
+ border-radius: 6px;
+ font-size: 0.8rem;
+ color: #44403c;
+}
+
+@media (max-width: 768px) {
+ .page-container.graph-wrapper {
+ width: 98%;
+ margin: 10px auto;
+ padding: 10px;
+ }
+
+ .tabs-header {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 6px;
+ }
+
+ .tabs-header .tab-btn {
+ font-size: 0.85rem;
+ padding: 10px 6px;
+ }
+
+ .global-actions-right {
+ gap: 6px;
+ }
+
+ #saveGraphBtn {
+ font-size: 0.85rem;
+ padding: 8px 10px;
+ }
+
+ .custom-manipulation-buttons {
+ flex-wrap: wrap;
+ }
+
+ .custom-manipulation-buttons > button {
+ flex: 1 1 48%;
+ margin-right: 0;
+ }
+
+ #courseNetwork {
+ min-height: 58vh;
+ height: 58vh;
+ }
+
+ .optional-shelf {
+ border-radius: 10px;
+ padding: 8px;
+ }
+
+ .optional-course-list {
+ max-height: 42vh;
+ }
+
+ .sheet-tables-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .table-card {
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ .styled-table {
+ min-width: 560px;
+ }
+}
+
+.optional-shelf-header {
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:8px;
+}
+.optional-shelf.collapsed .optional-course-list {
+ display:none;
+}
+.optional-shelf.collapsed {
+ max-height: fit-content;
+}
+
+.optional-program-group {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background:#fff;
+ margin-bottom:10px;
+ padding:6px;
+}
+.optional-program-group > summary {
+ font-weight: 700;
+ cursor: pointer;
+ padding: 4px 2px;
+}
+.optional-bucket > summary {
+ font-size: 1.05rem;
+ font-weight: 700;
+ cursor: pointer;
+ list-style: none;
+}
+.optional-bucket > summary::-webkit-details-marker { display:none; }
+.optional-bucket > summary::before { content: "▸ "; color: var(--text-muted);}
+.optional-bucket[open] > summary::before { content: "▾ "; }
diff --git a/static/js/data_init.js b/static/js/data_init.js
index aa0029e..7fe3a8c 100644
--- a/static/js/data_init.js
+++ b/static/js/data_init.js
@@ -104,6 +104,7 @@ export function initializeNodes(detailsData, prereqsData) {
// 2. Build the nodes using the newly calculated X and Y coordinates
Object.keys(detailsData).forEach((nodeId) => {
const course = detailsData[nodeId];
+ if (course.include_in_graph === false) return;
nodesArray.push({
id: nodeId,
diff --git a/static/js/main.js b/static/js/main.js
index 8d78cf1..157a815 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -13,6 +13,7 @@ import { setupSheetViewListeners, updateSheetView } from "./sheet_view.js";
import { setupSidebar } from "./sidebar.js";
import { generateNodeLabel, getStatusColor } from "./node_utils.js";
import { updateGpaTracker } from "./gpa_tracker.js";
+import { setupOptionalCoursesShelf } from "./optional_courses.js";
document.addEventListener("DOMContentLoaded", function () {
if (
@@ -101,6 +102,27 @@ document.addEventListener("DOMContentLoaded", function () {
saveGraphState,
markGraphDirty,
);
+ setupOptionalCoursesShelf(network, nodes, edges, detailsData, prereqsData, markGraphDirty);
+
+ const openOptionalShelfBtn = document.getElementById("openOptionalShelfBtn");
+ const optionalShelf = document.getElementById("optionalCourseShelf");
+ const plannerLayout = document.querySelector(".planner-layout");
+ if (openOptionalShelfBtn && optionalShelf && plannerLayout) {
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "secondary-btn";
+ closeBtn.textContent = "Hide";
+ closeBtn.onclick = () => {
+ plannerLayout.classList.add("shelf-hidden");
+ openOptionalShelfBtn.style.display = "inline-block";
+ };
+ const header = optionalShelf.querySelector(".optional-shelf-header");
+ if (header) header.appendChild(closeBtn);
+
+ openOptionalShelfBtn.addEventListener("click", () => {
+ plannerLayout.classList.remove("shelf-hidden");
+ openOptionalShelfBtn.style.display = "none";
+ });
+ }
// Core Network Events
network.on("click", function (params) {
@@ -122,6 +144,7 @@ document.addEventListener("DOMContentLoaded", function () {
performWithoutHistory(() => nodes.update(updates));
saveGraphState();
markGraphDirty();
+ notifyDataUpdated();
}
});
@@ -131,3 +154,6 @@ document.addEventListener("DOMContentLoaded", function () {
updateGpaTracker(detailsData);
}, 500);
});
+
+
+function notifyDataUpdated(){ window.dispatchEvent(new Event("degreeviz:data-updated")); }
diff --git a/static/js/network_options.js b/static/js/network_options.js
index 873a6c1..c36441f 100644
--- a/static/js/network_options.js
+++ b/static/js/network_options.js
@@ -4,6 +4,68 @@ import { generateNodeLabel, getStatusColor } from "./node_utils.js";
import { updateSheetView } from "./sheet_view.js";
export function getVisNetworkOptions(nodes, edges) {
+ function normalizedCode(value = "") {
+ return String(value).toUpperCase().replace(/\s+/g, " ").trim();
+ }
+ window.coursePoolStore = window.coursePoolStore || {};
+ Object.keys(detailsData || {}).forEach((id) => {
+ const d = detailsData[id] || {};
+ if (d.include_in_graph === false) {
+ window.coursePoolStore[id] = {
+ detail: d,
+ prereqs: Array.isArray(prereqsData[id]) ? [...prereqsData[id]] : [],
+ };
+ }
+ });
+
+ function addNodeFromDetails(courseId, fallbackX = 0, fallbackY = 0, forceFallbackPos = false) {
+ const d = detailsData[courseId];
+ if (!d || nodes.get(courseId)) return;
+ nodes.add({
+ id: courseId,
+ x: forceFallbackPos ? fallbackX : (d.x ?? fallbackX),
+ y: forceFallbackPos ? fallbackY : (d.y ?? fallbackY),
+ label: generateNodeLabel(
+ d.code || courseId,
+ d.title,
+ d.credits,
+ d.semesters_offered,
+ d.category,
+ d.planned_semester,
+ d.status,
+ ),
+ color: getStatusColor(d.status || "Unassigned"),
+ });
+ d.include_in_graph = true;
+ }
+
+ function ensurePrereqNodes(courseId, originX = 0, originY = 0) {
+ const prereqs = Array.isArray(prereqsData[courseId]) ? prereqsData[courseId] : [];
+ prereqs.forEach((preId, idx) => {
+ if (detailsData[preId] && !nodes.get(preId)) {
+ addNodeFromDetails(preId, originX - 220, originY + idx * 120);
+ }
+ });
+ }
+
+ function connectCoursePrereqEdges(courseId) {
+ const newEdges = [];
+ (prereqsData[courseId] || []).forEach((fromNode) => {
+ if (nodes.get(fromNode)) newEdges.push({ from: fromNode, to: courseId, arrows: "to" });
+ });
+ Object.entries(prereqsData).forEach(([toNode, reqs]) => {
+ if (Array.isArray(reqs) && reqs.includes(courseId) && nodes.get(toNode)) {
+ newEdges.push({ from: courseId, to: toNode, arrows: "to" });
+ }
+ });
+ if (newEdges.length) edges.update(newEdges);
+ }
+
+ function addCourseToAdditionalBucket(courseId, category) {
+ if (typeof window.syncCourseBucketAssignment === "function") {
+ window.syncCourseBucketAssignment(courseId, category);
+ }
+ }
return {
locale: "en",
locales: {
@@ -109,22 +171,36 @@ export function getVisNetworkOptions(nodes, edges) {
},
],
onSubmit: (data) => {
- if (!data.title || !data.code) {
+ if (!data.code) {
callback(null);
return false; // Keep modal open if empty
}
+ if (!data.title || !String(data.title).trim()) {
+ data.title = `${data.code.toUpperCase()} (Custom Course)`;
+ }
+ const requestedCode = normalizedCode(data.code);
+ const existingEntry = Object.entries(detailsData).find(([key, course]) => {
+ const existingCode = normalizedCode(course.code || key);
+ return existingCode === requestedCode;
+ });
- // UNIQUE COURSE CHECK: Safely checks older nodes that might lack a .code property
- const codeExists = Object.entries(detailsData).some(
- ([key, course]) => {
- const existingCode = course.code || key; // Fallback to the dictionary key for older nodes
- return existingCode.toUpperCase() === data.code.toUpperCase();
- },
- );
+ if (existingEntry) {
+ const [existingId, existingCourse] = existingEntry;
+ if (existingCourse.include_in_graph === false) {
+ existingCourse.include_in_graph = true;
+ existingCourse.category = data.category || existingCourse.category;
+ addCourseToAdditionalBucket(existingId, existingCourse.category);
+ addNodeFromDetails(existingId, nodeData.x, nodeData.y, true);
+ ensurePrereqNodes(existingId, nodeData.x, nodeData.y);
+ connectCoursePrereqEdges(existingId);
+ markGraphDirty();
+ callback(null);
+ window.dispatchEvent(new Event("degreeviz:data-updated"));
+ return true;
+ }
- if (codeExists) {
alert(
- `Error: The course ${data.code.toUpperCase()} is already on your graph!`,
+ `Error: The course ${requestedCode} is already on your graph!`,
);
callback(null);
return false; // Tells ui_handler to KEEP THE MODAL OPEN
@@ -139,8 +215,9 @@ export function getVisNetworkOptions(nodes, edges) {
data.status,
);
+ const canonicalId = requestedCode;
const newNode = {
- id: nodeData.id,
+ id: canonicalId,
x: nodeData.x,
y: nodeData.y,
code: data.code.toUpperCase(),
@@ -154,8 +231,10 @@ export function getVisNetworkOptions(nodes, edges) {
callback(null);
nodes.add(newNode);
+ ensurePrereqNodes(canonicalId, nodeData.x, nodeData.y);
+ connectCoursePrereqEdges(canonicalId);
- detailsData[newNode.id] = {
+ detailsData[canonicalId] = {
code: newNode.code,
title: data.title,
credits: data.credits,
@@ -166,12 +245,20 @@ export function getVisNetworkOptions(nodes, edges) {
color: newNode.color,
x: newNode.x,
y: newNode.y,
+ include_in_graph: true,
+ };
+ addCourseToAdditionalBucket(canonicalId, data.category);
+ if (!Array.isArray(prereqsData[canonicalId])) {
+ prereqsData[canonicalId] = [];
+ }
+ window.coursePoolStore[canonicalId] = {
+ detail: detailsData[canonicalId],
+ prereqs: [...prereqsData[canonicalId]],
};
- prereqsData[newNode.id] = [];
const queryParams = new URLSearchParams({
request: "add node",
- node_id: newNode.id,
+ node_id: canonicalId,
code: newNode.code,
node_title: data.title,
credits: data.credits,
@@ -184,6 +271,8 @@ export function getVisNetworkOptions(nodes, edges) {
}).then((response) => {
if (response.ok) markGraphDirty();
});
+ markGraphDirty();
+ window.dispatchEvent(new Event("degreeviz:data-updated"));
return true; // Success! Close the modal.
},
diff --git a/static/js/optional_courses.js b/static/js/optional_courses.js
new file mode 100644
index 0000000..bf192c9
--- /dev/null
+++ b/static/js/optional_courses.js
@@ -0,0 +1,408 @@
+import { generateNodeLabel, getStatusColor } from "./node_utils.js";
+import { updateSheetView } from "./sheet_view.js";
+import { openCustomPrompt } from "./ui_handler.js";
+
+function ensureBucketCourseData(requirements, detailsData) {
+ if (!requirements?.buckets) return;
+ requirements.buckets.forEach((bucket) => {
+ bucket.courses = (bucket.courses || []).filter((id) => detailsData[id]);
+ });
+}
+
+function parseProgramName(bucket = {}, index = 0) {
+ const explicitName = bucket.program_name || bucket.program || bucket.major_name;
+ if (explicitName) return String(explicitName);
+ const bucketId = String(bucket.id || "");
+ const normalized = bucketId
+ .replace(/-(required|complementary|elective|program-prerequisites|core|courses).*$/i, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ if (normalized) {
+ return normalized.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+ }
+ return `Program ${index + 1}`;
+}
+
+function getConstraintRegex(constraintText = "") {
+ const text = String(constraintText).toLowerCase();
+ const m = text.match(/(\d{3})\s*level\s*or\s*above/);
+ if (!m) return null;
+ const minLevel = Number(m[1]);
+ return (courseCode = "") => {
+ const codeMatch = String(courseCode).match(/(\d{3})/);
+ return codeMatch ? Number(codeMatch[1]) >= minLevel : false;
+ };
+}
+
+function parseConstraintHints(constraintText = "") {
+ const text = String(constraintText || "").toUpperCase();
+ const prefixes = Array.from(new Set((text.match(/\b[A-Z]{4}\b/g) || [])));
+ const levelRegex = getConstraintRegex(constraintText);
+ const excludedCourses = Array.from(new Set((text.match(/\b[A-Z]{4}\s?\d{3}[A-Z0-9]?\b/g) || []))).map((c) => c.replace(/\s+/, " "));
+ return { prefixes, levelRegex, excludedCourses };
+}
+
+function renderCourse(network, nodes, edges, detailsData, prereqsData, courseId) {
+ if (nodes.get(courseId) || !detailsData[courseId]) return;
+ const d = detailsData[courseId];
+ nodes.add({
+ id: courseId,
+ x: d.x ?? 0,
+ y: d.y ?? 0,
+ label: generateNodeLabel(d.code || courseId, d.title, d.credits, d.semesters_offered, d.category, d.planned_semester, d.status),
+ color: getStatusColor(d.status),
+ });
+ d.include_in_graph = true;
+
+ const newEdges = [];
+ (prereqsData[courseId] || []).forEach((fromNode) => {
+ if (nodes.get(fromNode)) newEdges.push({ from: fromNode, to: courseId, arrows: "to" });
+ });
+ Object.entries(prereqsData).forEach(([toNode, reqs]) => {
+ if (Array.isArray(reqs) && reqs.includes(courseId) && nodes.get(toNode)) {
+ newEdges.push({ from: courseId, to: toNode, arrows: "to" });
+ }
+ });
+ if (newEdges.length) edges.update(newEdges);
+}
+
+function normalizedCode(value = "") {
+ return String(value).toUpperCase().replace(/\s+/g, " ").trim();
+}
+
+export function setupOptionalCoursesShelf(network, nodes, edges, detailsData, prereqsData, markGraphDirty) {
+ const root = document.getElementById("optionalCourseShelf");
+ const list = document.getElementById("optionalBucketList");
+ if (!root || !list) return;
+
+ const requirements = window.programRequirements || { buckets: [] };
+ ensureBucketCourseData(requirements, detailsData);
+
+ window.syncCourseBucketAssignment = (courseId, category) => {
+ if (!requirements?.buckets?.length || !courseId || !category) return;
+ const normalized = String(category).toUpperCase();
+ requirements.buckets.forEach((bucket) => {
+ bucket.additional_courses = (bucket.additional_courses || []).filter((id) => id !== courseId);
+ });
+ const course = detailsData[courseId] || {};
+ const courseCode = normalizedCode(course.code || courseId || "");
+ const isScrapedCourse = requirements.buckets.some((bucket) => (bucket.courses || []).includes(courseId));
+ if (isScrapedCourse) {
+ console.log("[degreeviz][bucket-sync] scraped-course, skip additional assignment", { courseId, courseCode });
+ return;
+ }
+ const coursePrefix = courseCode.split(" ")[0] || "";
+ const matchingBuckets = requirements.buckets.filter((bucket) => String(bucket.category || "").toUpperCase() === normalized);
+ console.log("[degreeviz][bucket-sync] start", {
+ courseId,
+ courseCode,
+ category: normalized,
+ matchingBucketCount: matchingBuckets.length,
+ });
+ const candidateBuckets = matchingBuckets.map((bucket) => {
+ const { prefixes, levelRegex, excludedCourses } = parseConstraintHints(bucket.constraints_text);
+ const hasConstraint = !!bucket.constraints_text;
+ const excluded = excludedCourses.includes(courseCode);
+ const prefixOk = !prefixes.length || prefixes.includes(coursePrefix);
+ const levelOk = !levelRegex || levelRegex(courseCode);
+ const passes = !excluded && prefixOk && levelOk;
+ const score = [hasConstraint ? 1 : 0, prefixes.length ? 1 : 0, levelRegex ? 1 : 0].reduce((a, b) => a + b, 0);
+ console.log("[degreeviz][bucket-sync] evaluate", {
+ bucketId: bucket.id,
+ title: bucket.title,
+ prefixes,
+ excludedCourses,
+ excluded,
+ coursePrefix,
+ prefixOk,
+ hasLevelRule: !!levelRegex,
+ levelOk,
+ passes,
+ score,
+ constraint: bucket.constraints_text,
+ });
+ return { bucket, passes, score, hasConstraint };
+ });
+
+ const passing = candidateBuckets.filter((c) => c.passes);
+ passing.sort((a, b) => b.score - a.score);
+ const targetBucket = (passing[0] && passing[0].bucket)
+ || (candidateBuckets.find((c) => !c.hasConstraint) || {}).bucket
+ || matchingBuckets[0];
+ if (!targetBucket) return;
+ targetBucket.additional_courses = targetBucket.additional_courses || [];
+ if (!targetBucket.additional_courses.includes(courseId) && !(targetBucket.courses || []).includes(courseId)) {
+ targetBucket.additional_courses.push(courseId);
+ }
+ console.log("[degreeviz][bucket-sync] assigned", {
+ courseId,
+ targetBucketId: targetBucket.id,
+ targetBucketTitle: targetBucket.title,
+ additionalCourses: targetBucket.additional_courses,
+ });
+ };
+
+ function syncFlagsFromGraph() {
+ const onGraph = new Set(nodes.getIds());
+ Object.keys(detailsData).forEach((id) => {
+ detailsData[id].include_in_graph = onGraph.has(id);
+ });
+ }
+
+ function render() {
+ syncFlagsFromGraph();
+ list.innerHTML = "";
+ const buckets = requirements.buckets || [];
+ if (!buckets.length) {
+ root.style.display = "none";
+ return;
+ }
+ root.style.display = "block";
+
+ const byProgram = {};
+ buckets.forEach((b, idx) => {
+ const program = parseProgramName(b, idx);
+ byProgram[program] = byProgram[program] || [];
+ byProgram[program].push(b);
+ });
+
+ Object.entries(byProgram).forEach(([program, programBuckets]) => {
+ const group = document.createElement("details");
+ group.className = "optional-program-group";
+ group.open = true;
+ group.innerHTML = `${program}`;
+
+ const programAdditional = [];
+ const ruleAssignedAdditional = new Set();
+ const scrapedCourseIds = new Set();
+ programBuckets.forEach((bucket) => {
+ bucket.additional_courses = bucket.additional_courses || [];
+ (bucket.courses || []).forEach((id) => scrapedCourseIds.add(id));
+ bucket.additional_courses.forEach((id) => {
+ if (!programAdditional.includes(id)) programAdditional.push(id);
+ if (bucket.constraints_text) ruleAssignedAdditional.add(id);
+ });
+
+ const wrapper = document.createElement("details");
+ wrapper.className = "optional-bucket";
+ wrapper.open = true;
+
+ const actions = document.createElement("div");
+ actions.className = "optional-bucket-actions";
+ const editBucketBtn = document.createElement("button");
+ editBucketBtn.className = "secondary-btn";
+ editBucketBtn.title = "Edit bucket";
+ editBucketBtn.textContent = "✏️";
+ editBucketBtn.onclick = () => {
+ openCustomPrompt({
+ title: "Edit Bucket",
+ submitText: "Save Bucket",
+ fields: [
+ { id: "title", label: "Bucket Title", defaultValue: bucket.title || "" },
+ { id: "min_credits", label: "Min Credits", defaultValue: String(bucket.min_credits ?? 0) },
+ { id: "max_credits", label: "Max Credits (optional)", defaultValue: bucket.max_credits == null ? "" : String(bucket.max_credits) },
+ ],
+ onSubmit: (data) => {
+ bucket.title = String(data.title || bucket.title || "").trim();
+ bucket.min_credits = Number.isFinite(Number(data.min_credits)) ? Number(data.min_credits) : 0;
+ bucket.max_credits = String(data.max_credits || "").trim() === "" ? null : Number(data.max_credits);
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ return true;
+ },
+ });
+ };
+ const addToBucketBtn = document.createElement("button");
+ addToBucketBtn.className = "secondary-btn";
+ addToBucketBtn.title = "Add course to bucket";
+ addToBucketBtn.textContent = "➕";
+ addToBucketBtn.onclick = () => {
+ openCustomPrompt({
+ title: "Add Course to Bucket",
+ submitText: "Add Course",
+ fields: [
+ { id: "code", label: "Course Code", type: "course_search", required: true },
+ { id: "title", label: "Course Title (optional)" },
+ { id: "credits", label: "Credits", defaultValue: "3" },
+ ],
+ onSubmit: (data) => {
+ const normalized = normalizedCode(data.code || "");
+ if (!normalized) return false;
+ const existingId = Object.keys(detailsData).find((id) => normalizedCode(detailsData[id]?.code || id) === normalized);
+ const courseId = existingId || normalized;
+ if (!detailsData[courseId]) {
+ detailsData[courseId] = {
+ code: normalized,
+ title: data.title || `${normalized} (User Added)`,
+ credits: data.credits || "3",
+ semesters_offered: "Unknown",
+ category: bucket.category || "COMPLEMENTARY",
+ status: "Unassigned",
+ planned_semester: "Unassigned",
+ include_in_graph: false,
+ };
+ prereqsData[courseId] = prereqsData[courseId] || [];
+ }
+ bucket.additional_courses = bucket.additional_courses || [];
+ if (!bucket.additional_courses.includes(courseId) && !(bucket.courses || []).includes(courseId)) {
+ bucket.additional_courses.push(courseId);
+ }
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ return true;
+ },
+ });
+ };
+ const deleteBucketBtn = document.createElement("button");
+ deleteBucketBtn.className = "danger-btn";
+ deleteBucketBtn.title = "Delete bucket";
+ deleteBucketBtn.textContent = "🗑️";
+ deleteBucketBtn.onclick = () => {
+ openCustomPrompt({
+ title: "Delete Bucket",
+ submitText: "Delete",
+ fields: [
+ { id: "confirm", label: `Type DELETE to remove '${bucket.title}'` },
+ ],
+ onSubmit: (data) => {
+ if (String(data.confirm || "").trim().toUpperCase() !== "DELETE") return false;
+ const idx = requirements.buckets.findIndex((b) => b.id === bucket.id);
+ if (idx >= 0) requirements.buckets.splice(idx, 1);
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ return true;
+ },
+ });
+ };
+ actions.appendChild(editBucketBtn);
+ actions.appendChild(addToBucketBtn);
+ actions.appendChild(deleteBucketBtn);
+
+ const inGraphCredits = (bucket.courses || []).reduce((sum, id) => {
+ const c = detailsData[id];
+ return c && c.include_in_graph ? sum + (parseFloat(c.credits) || 0) : sum;
+ }, 0);
+ const levelCheck = getConstraintRegex(bucket.constraints_text);
+ const additionalInGraphCredits = (bucket.additional_courses || []).reduce((sum, id) => {
+ const c = detailsData[id];
+ if (!c || !c.include_in_graph) return sum;
+ if (levelCheck && !levelCheck(c.code || id)) return sum;
+ return sum + (parseFloat(c.credits) || 0);
+ }, 0);
+ if (bucket.constraints_text) {
+ console.log("[degreeviz][rule-credits]", {
+ bucketId: bucket.id,
+ title: bucket.title,
+ constraint: bucket.constraints_text,
+ additionalCourses: bucket.additional_courses,
+ additionalInGraphCredits,
+ });
+ }
+ const totalAddedCredits = inGraphCredits + additionalInGraphCredits;
+ const requiredText = (Number(bucket.max_credits || 0) && Number(bucket.max_credits) !== Number(bucket.min_credits)) ? `${bucket.min_credits}-${bucket.max_credits}` : `${bucket.min_credits}`;
+ const showRule = false;
+
+ wrapper.innerHTML = `${bucket.title}${bucket.category} • Added ${totalAddedCredits}/${requiredText} credits
${showRule ? `Rule: ${bucket.constraints_text}
` : ""}`;
+ wrapper.appendChild(actions);
+
+ (bucket.courses || []).forEach((courseId) => {
+ const c = detailsData[courseId];
+ if (!c) return;
+ const row = document.createElement("div");
+ row.className = "optional-course-row";
+ const isOnGraph = !!c.include_in_graph;
+ row.innerHTML = `${c.code || courseId} ${c.title} (${c.credits})`;
+ const btn = document.createElement("button");
+ btn.textContent = isOnGraph ? "Remove" : "Add";
+ btn.className = isOnGraph ? "danger-btn" : "secondary-btn";
+ btn.onclick = () => {
+ if (isOnGraph) {
+ c.include_in_graph = false;
+ if (nodes.get(courseId)) nodes.remove(courseId);
+ } else {
+ renderCourse(network, nodes, edges, detailsData, prereqsData, courseId);
+ }
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ };
+ row.appendChild(btn);
+ wrapper.appendChild(row);
+ });
+
+ (bucket.additional_courses || []).forEach((courseId) => {
+ const c = detailsData[courseId];
+ if (!c) return;
+ const row = document.createElement("div");
+ row.className = "optional-course-row";
+ const isOnGraph = !!c.include_in_graph;
+ row.innerHTML = `${c.code || courseId} ${c.title} (${c.credits}) (Added)`;
+ const btn = document.createElement("button");
+ btn.textContent = isOnGraph ? "Remove" : "Add";
+ btn.className = isOnGraph ? "danger-btn" : "secondary-btn";
+ btn.onclick = () => {
+ if (isOnGraph) {
+ c.include_in_graph = false;
+ if (nodes.get(courseId)) nodes.remove(courseId);
+ } else {
+ renderCourse(network, nodes, edges, detailsData, prereqsData, courseId);
+ }
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ };
+ row.appendChild(btn);
+ wrapper.appendChild(row);
+ });
+
+ group.appendChild(wrapper);
+ });
+
+ const extraBucket = document.createElement("details");
+ extraBucket.className = "optional-bucket";
+ extraBucket.open = true;
+ extraBucket.innerHTML = `Additional User Courses`;
+ programAdditional
+ .filter((courseId) => !scrapedCourseIds.has(courseId) && !ruleAssignedAdditional.has(courseId))
+ .forEach((courseId) => {
+ const c = detailsData[courseId];
+ if (!c) return;
+ const row = document.createElement("div");
+ row.className = "optional-course-row";
+ const isOnGraph = !!c.include_in_graph;
+ row.innerHTML = `${c.code || courseId} ${c.title} (${c.credits})`;
+ const btn = document.createElement("button");
+ btn.textContent = isOnGraph ? "Remove" : "Add";
+ btn.className = isOnGraph ? "danger-btn" : "secondary-btn";
+ btn.onclick = () => {
+ if (isOnGraph) {
+ c.include_in_graph = false;
+ if (nodes.get(courseId)) nodes.remove(courseId);
+ } else {
+ renderCourse(network, nodes, edges, detailsData, prereqsData, courseId);
+ }
+ markGraphDirty();
+ updateSheetView(detailsData, requirements);
+ render();
+ };
+ row.appendChild(btn);
+ extraBucket.appendChild(row);
+ });
+ group.appendChild(extraBucket);
+
+
+
+ list.appendChild(group);
+ });
+ }
+
+ network.on("add", render);
+ network.on("remove", render);
+ network.on("update", render);
+ window.addEventListener("degreeviz:data-updated", render);
+
+ render();
+}
diff --git a/static/js/program_handler.js b/static/js/program_handler.js
index 339de37..ba57868 100644
--- a/static/js/program_handler.js
+++ b/static/js/program_handler.js
@@ -62,6 +62,17 @@ export function setupAddProgramButton(
// 2. Update local memory
Object.assign(detailsData, result.new_details);
Object.assign(prereqsData, result.new_prereqs);
+ if (result.new_requirements?.buckets) {
+ window.programRequirements = window.programRequirements || { buckets: [] };
+ const inferredProgramName = data.url_display || data.url || "Program";
+ result.new_requirements.buckets.forEach((bucket) => {
+ if (!bucket.program_name) bucket.program_name = inferredProgramName;
+ });
+ window.programRequirements.buckets = [
+ ...(window.programRequirements.buckets || []),
+ ...result.new_requirements.buckets,
+ ];
+ }
// 3. Grid Placement Algorithm
let maxX = -Infinity;
@@ -83,8 +94,8 @@ export function setupAddProgramButton(
let currentY = startY;
Object.keys(result.new_details).forEach((code, index) => {
- if (!nodes.get(code)) {
- const d = result.new_details[code];
+ const d = result.new_details[code];
+ if (!nodes.get(code) && (d.include_in_graph !== false)) {
const xOffset = currentX + Math.floor(index / 5) * 200;
const yOffset = currentY + (index % 5) * 150;
@@ -158,7 +169,8 @@ export function setupAddProgramButton(
}
markGraphDirty();
- updateSheetView(detailsData);
+ updateSheetView(detailsData, window.programRequirements || {});
+ window.dispatchEvent(new Event("degreeviz:data-updated"));
network.fit();
} else {
alert("Error importing program: " + result.message);
diff --git a/static/js/sheet_view.js b/static/js/sheet_view.js
index 624e6fc..52b51b8 100644
--- a/static/js/sheet_view.js
+++ b/static/js/sheet_view.js
@@ -1,4 +1,4 @@
-export function updateSheetView(detailsData) {
+export function updateSheetView(detailsData, requirementsData = window.programRequirements || {}) {
let stats = {
CORE: { taken: 0 },
COMPLEMENTARY: { taken: 0 },
@@ -65,6 +65,7 @@ export function updateSheetView(detailsData) {
row.innerHTML = `${term} | ${load} | `;
tbody.appendChild(row);
});
+
}
export function setupSheetViewListeners(detailsData, updateCb, markDirtyCb) {
diff --git a/static/js/sidebar.js b/static/js/sidebar.js
index 218d566..1dfb7b0 100644
--- a/static/js/sidebar.js
+++ b/static/js/sidebar.js
@@ -204,6 +204,10 @@ export function setupSidebar(
performWithoutHistory(() => {
nodes.update({ id: currentlySelectedNodeId, label: newLabel });
});
+ if (typeof window.syncCourseBucketAssignment === "function") {
+ window.syncCourseBucketAssignment(currentlySelectedNodeId, nData.category);
+ window.dispatchEvent(new Event("degreeviz:data-updated"));
+ }
triggerDataSync();
}
});
diff --git a/templates/graph.html b/templates/graph.html
index 8b22416..0319675 100644
--- a/templates/graph.html
+++ b/templates/graph.html
@@ -46,7 +46,14 @@
Hold Shift + Drag to multi-select
-
+
+