diff --git a/.github/scripts/auto_extract.py b/.github/scripts/auto_extract.py index 9ce1930..14d9855 100644 --- a/.github/scripts/auto_extract.py +++ b/.github/scripts/auto_extract.py @@ -261,57 +261,11 @@ def extract_with_openai(page_content, additional_notes=""): util.fail(f"OpenAI API error: {str(e)}") -def parse_issue_body(body): - """Parse the issue body to get URL and notes.""" - lines = body.strip().split("\n") - data = {} - - current_field = None - current_value = [] - - for line in lines: - if line.startswith("### "): - if current_field and current_value: - data[current_field] = "\n".join(current_value).strip() - # Convert "Link to Hackathon" -> "link_to_hackathon" - current_field = line[4:].strip().lower().replace(" ", "_").replace("?", "").replace("(", "").replace(")", "") - current_value = [] - elif current_field: - if line.strip() and line.strip() != "_No response_": - current_value.append(line) - - if current_field and current_value: - data[current_field] = "\n".join(current_value).strip() - - return data - - -def parse_state(data): - """Map optional issue status field to listing state.""" - status = (data.get("status", "") or "").strip().lower() - if "soon" in status: - return "opens_soon" - if "open" in status: - return "open" - # Sensible default for auto-extracted listings. - return "open" - - -def parse_deadline(data): - """Parse optional deadline and normalize to ISO YYYY-MM-DD.""" - raw = (data.get("deadline_optional", "") or data.get("deadline", "")).strip() - if not raw: - return None - try: - return util.parse_deadline_date(raw).isoformat() - except ValueError: - util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.") - - def extract_url_from_body(body): """Try multiple methods to extract URL from issue body.""" - # Method 1: Parse structured fields - data = parse_issue_body(body) + # Method 1: Parse structured fields (strip_symbols matches the field keys + # this script looks up, e.g. deadline_optional). + data = util.parse_issue_body(body, strip_symbols=True) # Try various field names url_fields = [ @@ -365,8 +319,8 @@ def main(): print(f"Extracted URL: {url}") notes = data.get("any_additional_context_optional", "") or data.get("notes", "") - state = parse_state(data) - deadline = parse_deadline(data) + state = util.parse_state(data) + deadline = util.parse_deadline(data, "deadline_optional", "deadline") print(f"Fetching content from: {url}") diff --git a/.github/scripts/contribution_approved.py b/.github/scripts/contribution_approved.py index 051e236..bd25039 100644 --- a/.github/scripts/contribution_approved.py +++ b/.github/scripts/contribution_approved.py @@ -26,64 +26,6 @@ def get_first(data, *keys): return "" -def parse_state(data): - """Map issue fields to listing state.""" - status = get_first(data, "status") - status_lc = status.lower() - if "soon" in status_lc: - return "opens_soon" - if "open" in status_lc: - return "open" - - # Backward compatibility with older template field. - active_str = get_first( - data, - "is_this_hackathon_currently_open_for_registration?", - "is_this_hackathon_currently_open_for_registration", - ).lower() - if active_str == "no": - return "opens_soon" - return "open" - - -def parse_deadline(data): - """Parse optional deadline and normalize to ISO YYYY-MM-DD.""" - raw = get_first(data, "deadline", "deadline_(optional)") - if not raw: - return None - try: - return util.parse_deadline_date(raw).isoformat() - except ValueError: - util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.") - - -def parse_issue_body(body, labels): - """Parse the issue body based on issue type.""" - lines = body.strip().split("\n") - data = {} - - current_field = None - current_value = [] - - for line in lines: - # Check for field headers (### Field Name) - if line.startswith("### "): - if current_field and current_value: - data[current_field] = "\n".join(current_value).strip() - current_field = line[4:].strip().lower().replace(" ", "_") - current_value = [] - elif current_field: - # Skip "_No response_" placeholders - if line.strip() != "_No response_": - current_value.append(line) - - # Don't forget the last field - if current_field and current_value: - data[current_field] = "\n".join(current_value).strip() - - return data - - def handle_new_opportunity(data, username, is_quick_add=False): """Handle adding a new hackathon.""" listings = util.get_listings_from_json() @@ -125,8 +67,8 @@ def handle_new_opportunity(data, username, is_quick_add=False): # Get prize (optional) prize = get_first(data, "prize_pool_(optional)", "prize") or "โ€”" # Map user-submitted status/deadline fields into canonical listing fields. - state = parse_state(data) - deadline = parse_deadline(data) + state = util.parse_state(data) + deadline = util.parse_deadline(data, "deadline", "deadline_(optional)") # Create new listing new_listing = { @@ -223,7 +165,7 @@ def main(): username = issue.get("user", {}).get("login", "unknown") # Parse the issue body - data = parse_issue_body(body, labels) + data = util.parse_issue_body(body) # Check if this is a quick add is_quick_add = "quick_add" in labels diff --git a/.github/scripts/generate_gallery.py b/.github/scripts/generate_gallery.py index f812618..111ae83 100644 --- a/.github/scripts/generate_gallery.py +++ b/.github/scripts/generate_gallery.py @@ -51,12 +51,20 @@ def esc(text): def tile(photo): image = esc(photo["image"]) - hackathon = esc(photo.get("hackathon", "")) + # Surface caption + credit so contributor attribution isn't dropped. In a + # justified image collage there's no room for a visible caption line, so the + # metadata rides along in the alt/title (hover tooltip + accessibility). + parts = [p for p in (photo.get("hackathon", ""), photo.get("caption", "")) if p] + credit = photo.get("credit", "") + credit_url = photo.get("credit_url", "") + if credit: + parts.append(f"Photo: {credit}" + (f" ({credit_url})" if credit_url else "")) + label = esc(" ยท ".join(parts)) if parts else esc(photo.get("hackathon", "")) # Fixed height + no width keeps each photo's aspect ratio, so they tile # together like a collage instead of sitting in separate boxes. return ( f'' - f'{hackathon}' + f'{label}' ) diff --git a/.github/scripts/test_scripts.py b/.github/scripts/test_scripts.py index 976b767..ac6ca1c 100644 --- a/.github/scripts/test_scripts.py +++ b/.github/scripts/test_scripts.py @@ -8,7 +8,6 @@ from datetime import date import util -import contribution_approved as ca import auto_extract as ax @@ -55,24 +54,30 @@ def test_parses_field_sections(self): "### Hackathon Name\nHackMIT 2026\n\n" "### Link to Hackathon Page\nhttps://hackmit.org\n" ) - data = ca.parse_issue_body(body, []) + data = util.parse_issue_body(body) self.assertEqual(data["hackathon_name"], "HackMIT 2026") self.assertEqual(data["link_to_hackathon_page"], "https://hackmit.org") def test_skips_no_response_placeholder(self): - data = ca.parse_issue_body("### Prize Pool (optional)\n_No response_\n", []) + data = util.parse_issue_body("### Prize Pool (optional)\n_No response_\n") self.assertFalse(data.get("prize_pool_(optional)")) + def test_strip_symbols_normalizes_keys(self): + data = util.parse_issue_body("### Deadline (optional)\n2026-07-04\n", strip_symbols=True) + self.assertEqual(data.get("deadline_optional"), "2026-07-04") + class ParseStateAndDeadline(unittest.TestCase): def test_state(self): - self.assertEqual(ca.parse_state({"status": "Opens soon"}), "opens_soon") - self.assertEqual(ca.parse_state({"status": "Open now"}), "open") - self.assertEqual(ca.parse_state({}), "open") + self.assertEqual(util.parse_state({"status": "Opens soon"}), "opens_soon") + self.assertEqual(util.parse_state({"status": "Open now"}), "open") + self.assertEqual(util.parse_state({}), "open") def test_deadline(self): - self.assertEqual(ca.parse_deadline({"deadline": "2026-07-04"}), "2026-07-04") - self.assertIsNone(ca.parse_deadline({})) + self.assertEqual( + util.parse_deadline({"deadline": "2026-07-04"}, "deadline"), "2026-07-04" + ) + self.assertIsNone(util.parse_deadline({}, "deadline")) class SsrfGuard(unittest.TestCase): diff --git a/.github/scripts/util.py b/.github/scripts/util.py index 3518177..e4186cf 100644 --- a/.github/scripts/util.py +++ b/.github/scripts/util.py @@ -258,6 +258,80 @@ def generate_uuid(): return str(uuid.uuid4()) +DEFAULT_REPO = "Jose-Gael-Cruz-Lopez/hackhq" + + +def repo_slug(): + """owner/repo โ€” from GITHUB_REPOSITORY (set by Actions) or the default.""" + return os.environ.get("GITHUB_REPOSITORY") or DEFAULT_REPO + + +def repo_url(): + """Base GitHub URL for this repository.""" + return f"https://github.com/{repo_slug()}" + + +def parse_issue_body(body, strip_symbols=False): + """Parse a GitHub issue body's '### Field' sections into a {field: value} dict. + + Field names are lowercased with spaces -> underscores. Set strip_symbols=True + to also drop ? ( ) from keys (used where field lookups omit those chars). + Shared by contribution_approved.py and auto_extract.py. + """ + data = {} + current_field = None + current_value = [] + for line in body.strip().split("\n"): + if line.startswith("### "): + if current_field and current_value: + data[current_field] = "\n".join(current_value).strip() + name = line[4:].strip().lower().replace(" ", "_") + if strip_symbols: + name = name.replace("?", "").replace("(", "").replace(")", "") + current_field = name + current_value = [] + elif current_field: + if line.strip() and line.strip() != "_No response_": + current_value.append(line) + if current_field and current_value: + data[current_field] = "\n".join(current_value).strip() + return data + + +def parse_state(data): + """Map issue status fields to a listing state (open / opens_soon).""" + status = str(data.get("status") or "").strip().lower() + if "soon" in status: + return "opens_soon" + if "open" in status: + return "open" + # Backward compatibility with the older registration-open field. + active = str( + data.get("is_this_hackathon_currently_open_for_registration?") + or data.get("is_this_hackathon_currently_open_for_registration") + or "" + ).strip().lower() + if active == "no": + return "opens_soon" + return "open" + + +def parse_deadline(data, *keys): + """Return an ISO deadline from the first present key, or None (fail on bad format).""" + raw = "" + for key in keys: + value = data.get(key) + if value and str(value).strip(): + raw = str(value).strip() + break + if not raw: + return None + try: + return parse_deadline_date(raw).isoformat() + except ValueError: + fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.") + + def clean_url(url): """Clean and normalize a URL.""" url = url.strip() diff --git a/.github/scripts/weekly_digest.py b/.github/scripts/weekly_digest.py index 95125bf..876349f 100644 --- a/.github/scripts/weekly_digest.py +++ b/.github/scripts/weekly_digest.py @@ -15,6 +15,8 @@ from datetime import datetime, timedelta from zoneinfo import ZoneInfo +import util + PST = ZoneInfo("America/Los_Angeles") README = os.path.join(os.path.dirname(__file__), "..", "..", "README.md") DIGEST = os.path.join(os.path.dirname(__file__), "..", "..", "digest.md") @@ -123,7 +125,7 @@ def main(): out = [] out.append(f"# ๐Ÿ“ฌ Weekly Digest โ€” {today.strftime('%B %d, %Y')}\n") out.append( - f"_Auto-generated. Source: [README.md](https://github.com/Jose-Gael-Cruz-Lopez/hackhq)._\n" + f"_Auto-generated. Source: [README.md]({util.repo_url()})._\n" ) if closing_rows: @@ -142,7 +144,7 @@ def main(): out.append("\nNo new entries or closing-soon flags this week. Stay tuned ๐ŸŒฑ\n") out.append( - f"\n---\n_Want to contribute? [Open an issue](https://github.com/Jose-Gael-Cruz-Lopez/hackhq/issues/new/choose)._" + f"\n---\n_Want to contribute? [Open an issue]({util.repo_url()}/issues/new/choose)._" ) with open(DIGEST, "w") as f: diff --git a/georgetown b/georgetown deleted file mode 100644 index ff97e6d..0000000 --- a/georgetown +++ /dev/null @@ -1 +0,0 @@ -| โœ… [OPEN] | Georgetown University | Hoya Hacks 2027 โ€” Jan 22โ€“24, 2027 | In-Person | Washington, DC | 1 non-cash prize | Register | Jun 27, 2026 | diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx index cc6957d..622c01d 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; import type { Hackathon, HackState } from "@/lib/types-hq"; import { STATE_META, countdown } from "@/lib/types-hq"; -import { useHQ } from "./store"; +import { useSelection, useTracker } from "./store"; type StatusFilter = "all" | HackState; type FormatFilter = "all" | "In-Person" | "Virtual"; @@ -169,7 +169,7 @@ function FilterPill({ } function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) { - const { isTracked, save, remove } = useHQ(); + const { isTracked, save, remove } = useTracker(); const tracked = isTracked(h.id); return (