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 -
+
+
+ +
+
@@ -238,6 +245,8 @@

GPA: 0.00