diff --git a/README.md b/README.md index eb4d52f..aace9b0 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,25 @@ You can run it without production configuration locally, but it is generally not You will need a `.env` file. This file should contain all of the variables in the `.env.example` file at the root of the project correctly filled out. +## Environment Variables + +Required environment variables: +- `GITHUB_TOKEN` - GitHub personal access token for accessing the GitHub API +- `DATABASE_URL` - PostgreSQL connection string (automatically set in Docker Compose) +- `REDIS_URL` - Redis connection string (defaults to `redis://localhost:6379`, automatically set in Docker Compose) +- `GITHUB_ORG` - GitHub organization name (defaults to `hacksu`, optional) + +The `REDIS_URL` is automatically configured in Docker Compose to connect to the Redis service. For local development outside Docker, you may need to set it manually. + To make changes on the admin side, go to the `/admin` route, and login with discord. If you have acceptable roles, this will authenticate you. -Run the containers with `docker compose -d --build` -This should setup persistent postgres volume and expose `localhost:3000` with the site +Run the containers with `docker compose up -d --build` +This should setup persistent postgres and redis volumes and expose `localhost:3002` with the site. + +The setup includes: +- **PostgreSQL** - Main database (port 5434) +- **Redis** - Cache for lesson repositories (port 6379) +- **Lessons Service** - Python service that fetches and caches GitHub lesson repos +- **SvelteKit App** - Main web application (port 3002) diff --git a/bun.lock b/bun.lock index 3f1434e..367d1e1 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "lucia": "^3.2.2", "marked": "^17.0.1", "postgres": "^3.4.7", + "redis": "^5.10.0", }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -215,6 +216,16 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="], + + "@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="], + + "@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="], + + "@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="], + + "@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="], "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], @@ -389,6 +400,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -645,6 +658,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], diff --git a/compose.yaml b/compose.yaml index 53422b1..9023548 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,39 @@ services: networks: - app-network + redis: + image: redis:7-alpine + restart: on-failure:5 + ports: + - 6379:6379 + volumes: + - redisdata:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + + lessons-service: + build: + context: ./lessons-service + dockerfile: Dockerfile + restart: on-failure:5 + env_file: + - .env + environment: + REDIS_URL: redis://redis:6379 + GITHUB_TOKEN: ${GITHUB_TOKEN} + GITHUB_ORG: ${GITHUB_ORG:-hacksu} + depends_on: + redis: + condition: service_healthy + networks: + - app-network + app: build: . restart: on-failure:5 @@ -23,18 +56,22 @@ services: - .env environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/hacksu + REDIS_URL: redis://redis:6379 NODE_ENV: production volumes: - uploads:/app/static/uploads depends_on: db: condition: service_healthy + redis: + condition: service_healthy networks: - app-network volumes: pgdata: uploads: + redisdata: networks: app-network: diff --git a/drizzle/0014_spotty_misty_knight.sql b/drizzle/0014_spotty_misty_knight.sql new file mode 100644 index 0000000..65b9728 --- /dev/null +++ b/drizzle/0014_spotty_misty_knight.sql @@ -0,0 +1,6 @@ +CREATE TABLE "lesson_icons" ( + "category_name" text PRIMARY KEY NOT NULL, + "iconify_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..1bba6d5 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,477 @@ +{ + "id": "a51df9aa-1b3f-44da-bc7a-43defebd6c27", + "prevId": "b610820f-36a9-492b-8b29-02e9369b616b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_sessions": { + "name": "admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.information": { + "name": "information", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_index": { + "name": "sort_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership": { + "name": "leadership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grad_year": { + "name": "grad_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grad_term": { + "name": "grad_term", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "titles": { + "name": "titles", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_icons": { + "name": "lesson_icons", + "schema": "", + "columns": { + "category_name": { + "name": "category_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "iconify_id": { + "name": "iconify_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'current'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_room": { + "name": "building_room", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_selector": { + "name": "building_selector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_url": { + "name": "building_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "presenter": { + "name": "presenter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_md": { + "name": "description_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.redirects": { + "name": "redirects", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "clicks": { + "name": "clicks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d1b9e73..2ff8df6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1764870114999, "tag": "0013_cynical_nebula", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1765208300249, + "tag": "0014_spotty_misty_knight", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lessons-service/.dockerignore b/lessons-service/.dockerignore new file mode 100644 index 0000000..5033033 --- /dev/null +++ b/lessons-service/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.env +.venv +venv/ +ENV/ +env/ + diff --git a/lessons-service/.python-version b/lessons-service/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/lessons-service/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/lessons-service/Dockerfile b/lessons-service/Dockerfile new file mode 100644 index 0000000..992d0a7 --- /dev/null +++ b/lessons-service/Dockerfile @@ -0,0 +1,13 @@ +FROM ghcr.io/astral-sh/uv:alpine +WORKDIR /app + +# Copy requirements first for better caching +COPY pyproject.toml uv.lock ./ +RUN uv sync --no-dev + +# Copy application code +COPY . ./ + +# Run the service +CMD ["uv", "run", "main.py"] + diff --git a/lessons-service/README.md b/lessons-service/README.md new file mode 100644 index 0000000..a8a0b0f --- /dev/null +++ b/lessons-service/README.md @@ -0,0 +1,62 @@ +# Lessons Service + +A Python microservice that fetches lesson repositories from GitHub and caches them in Redis. + +## Overview + +This service periodically fetches all repositories with the "lesson" topic from the GitHub organization and caches them in Redis. This offloads the heavy GitHub API work from the main SvelteKit application and provides fast, cached responses. + +## Features + +- **GraphQL API**: Uses GitHub's GraphQL API for efficient batch fetching of repositories and topics +- **Automatic Refresh**: Refreshes the cache every 10 minutes +- **Immediate Startup**: Runs initial cache refresh on startup (doesn't wait 10 minutes) +- **Stale-on-failure**: If GitHub is unreachable, keeps the existing cache warm +- **Health Endpoint**: Provides `/health` endpoint for monitoring +- **Persistent Cache**: Uses Redis with 30-minute TTL + +## Environment Variables + +- `GITHUB_TOKEN` (required): GitHub personal access token +- `GITHUB_ORG` (optional): GitHub organization name (defaults to "hacksu") +- `REDIS_URL` (optional): Redis connection URL (defaults to "redis://localhost:6379") +- `PORT` (optional): Port for health check endpoint (defaults to 8080) + +## Architecture + +``` +GitHub API (GraphQL) + | + V +Python Service (fetches every 10 min) + | + V +Redis Cache (30 min TTL, stale-on-failure) + | + V +SvelteKit App (reads from cache) +``` + +## Health Check + +The service exposes a `/health` endpoint that returns: +- `status`: "healthy", "unhealthy", or "error" +- `running`: Whether the scheduler is running +- `last_refresh_time`: Timestamp of last cache refresh +- `refresh_interval_minutes`: Refresh interval (10) +- `cache_healthy`: Whether Redis connection is healthy + +## Development + +```bash +# Install dependencies +uv sync + +# Run locally +uv run main.py +``` + +## Docker + +The service is built and run as part of the Docker Compose stack. See the main `compose.yaml` for configuration. + diff --git a/lessons-service/cache_manager.py b/lessons-service/cache_manager.py new file mode 100644 index 0000000..af87f4e --- /dev/null +++ b/lessons-service/cache_manager.py @@ -0,0 +1,77 @@ +"""Redis cache manager for lesson repositories and READMEs.""" + +import os +import json +import logging +import redis +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class CacheManager: + """Manages Redis caching for lesson repositories and READMEs.""" + + def __init__(self, redis_url: str = None): + redis_url = redis_url or os.getenv("REDIS_URL", "redis://localhost:6379") + self.redis_client = redis.from_url(redis_url, decode_responses=True) + self.repos_key = "lessons:repos" + self.readme_prefix = "lessons:readme:" + # Use a 30-minute TTL to ride out short upstream outages + self.ttl = 30 * 60 + + def cache_repos(self, repos: List[Dict[str, Any]]) -> bool: + """Cache the list of lesson repositories.""" + try: + data = json.dumps(repos) + self.redis_client.setex(self.repos_key, self.ttl, data) + logger.info(f"Cached {len(repos)} lesson repos with TTL {self.ttl}s") + return True + except Exception as e: + logger.error(f"Error caching repos: {e}") + return False + + def get_repos(self) -> Optional[List[Dict[str, Any]]]: + """Retrieve cached lesson repositories.""" + try: + data = self.redis_client.get(self.repos_key) + if data: + repos = json.loads(data) + logger.debug(f"Retrieved {len(repos)} repos from cache") + return repos + return None + except Exception as e: + logger.error(f"Error retrieving repos from cache: {e}") + return None + + def cache_readme(self, repo_name: str, content: str) -> bool: + """Cache README content for a repository.""" + try: + key = f"{self.readme_prefix}{repo_name}" + self.redis_client.setex(key, self.ttl, content) + logger.debug(f"Cached README for {repo_name}") + return True + except Exception as e: + logger.error(f"Error caching README for {repo_name}: {e}") + return False + + def get_readme(self, repo_name: str) -> Optional[str]: + """Retrieve cached README content for a repository.""" + try: + key = f"{self.readme_prefix}{repo_name}" + content = self.redis_client.get(key) + if content: + logger.debug(f"Retrieved README for {repo_name} from cache") + return content + return None + except Exception as e: + logger.error(f"Error retrieving README for {repo_name} from cache: {e}") + return None + + def health_check(self) -> bool: + """Check if Redis connection is healthy.""" + try: + return self.redis_client.ping() + except Exception as e: + logger.error(f"Redis health check failed: {e}") + return False diff --git a/lessons-service/github_client.py b/lessons-service/github_client.py new file mode 100644 index 0000000..7931dc7 --- /dev/null +++ b/lessons-service/github_client.py @@ -0,0 +1,201 @@ +"""GitHub API client using GraphQL for efficient batch fetching.""" + +import logging +from typing import List, Dict, Any, Optional +import requests +import time + +logger = logging.getLogger(__name__) + + +class GitHubClient: + """Client for fetching lesson repositories from GitHub using GraphQL API.""" + + def __init__(self, token: str, org: str = "hacksu"): + self.token = token + self.org = org + self.base_url = "https://api.github.com/graphql" + self.headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def _make_request( + self, query: str, variables: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Make a GraphQL request to GitHub API.""" + payload = {"query": query} + if variables: + payload["variables"] = variables + + try: + response = requests.post( + self.base_url, + json=payload, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if "errors" in data: + error_messages = [ + err.get("message", "Unknown error") for err in data["errors"] + ] + logger.error(f"GraphQL errors: {error_messages}") + raise Exception(f"GraphQL errors: {', '.join(error_messages)}") + + return data.get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"GitHub API request failed: {e}") + raise + + def fetch_lesson_repos(self) -> List[Dict[str, Any]]: + """ + Fetch all repositories with the 'lesson' topic from the organization. + Uses GraphQL to efficiently batch fetch repos and topics in a single query. + """ + logger.info(f"Fetching lesson repos from GitHub organization: {self.org}") + + all_repos = [] + cursor = None + has_next_page = True + + while has_next_page: + query = """ + query($org: String!, $cursor: String) { + organization(login: $org) { + repositories( + first: 100 + after: $cursor + orderBy: {field: UPDATED_AT, direction: DESC} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + name + description + url + updatedAt + defaultBranchRef { + target { + ... on Commit { + committedDate + } + } + } + primaryLanguage { + name + } + repositoryTopics(first: 20) { + nodes { + topic { + name + } + } + } + } + } + } + } + """ + + variables = {"org": self.org} + if cursor: + variables["cursor"] = cursor + + try: + data = self._make_request(query, variables) + org_data = data.get("organization", {}) + repos_data = org_data.get("repositories", {}) + page_info = repos_data.get("pageInfo", {}) + nodes = repos_data.get("nodes", []) + + # Filter repos that have the "lesson" topic + for repo in nodes: + topics_data = repo.get("repositoryTopics", {}).get("nodes", []) + topics = [ + node["topic"]["name"] + for node in topics_data + if node.get("topic") + ] + + if "lesson" in topics: + category_topics = [ + t for t in topics if t != "lesson" and t != "hacksu" + ] + # Get last commit date from default branch + default_branch = repo.get("defaultBranchRef", {}) + commit_target = default_branch.get("target", {}) if default_branch else {} + last_commit_date = commit_target.get("committedDate") if commit_target else repo["updatedAt"] + + all_repos.append( + { + "id": repo["id"], + "name": repo["name"], + "description": repo.get("description"), + "html_url": repo["url"], + "updated_at": repo["updatedAt"], + "last_commit_date": last_commit_date, + "language": ( + repo.get("primaryLanguage", {}).get("name") + if repo.get("primaryLanguage") + else None + ), + "topics": category_topics, + } + ) + logger.debug( + f"Added lesson repo: {repo['name']} with topics: {category_topics}" + ) + + has_next_page = page_info.get("hasNextPage", False) + cursor = page_info.get("endCursor") + + # Rate limiting: GitHub allows 5000 points per hour for authenticated requests + # Each query costs 1 point, so we can be generous, but let's be safe + if has_next_page: + time.sleep(0.1) # Small delay between pages + + except Exception as e: + logger.error(f"Error fetching repos page: {e}") + raise + + logger.info(f"Fetched {len(all_repos)} lesson repos from {self.org}") + return all_repos + + def fetch_readme(self, repo_name: str) -> Optional[str]: + """ + Fetch README content for a specific repository. + Falls back to REST API since GraphQL doesn't support file content easily. + """ + url = f"https://api.github.com/repos/{self.org}/{repo_name}/readme" + headers = { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {self.token}", + "User-Agent": "HacKSU-Lessons-Service", + } + + try: + response = requests.get(url, headers=headers, timeout=30) + if response.status_code == 404: + logger.debug(f"README not found for repo: {repo_name}") + return None + response.raise_for_status() + data = response.json() + + if data.get("encoding") == "base64": + import base64 + + content = base64.b64decode(data["content"]).decode("utf-8") + logger.debug(f"Fetched README for {repo_name}, length: {len(content)}") + return content + else: + logger.warn(f"Unexpected encoding for README: {data.get('encoding')}") + return data.get("content") + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching README for {repo_name}: {e}") + return None diff --git a/lessons-service/main.py b/lessons-service/main.py new file mode 100644 index 0000000..1cd8be0 --- /dev/null +++ b/lessons-service/main.py @@ -0,0 +1,66 @@ +"""Main entry point for the lessons service.""" + +import os +import logging +from flask import Flask, jsonify +from scheduler import LessonCacheScheduler + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# Initialize Flask app +app = Flask(__name__) + +# Initialize scheduler +scheduler = None + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check endpoint.""" + try: + status = scheduler.get_status() if scheduler else {"running": False} + cache_healthy = status.get("cache_healthy", False) + if cache_healthy: + return jsonify({"status": "healthy", **status}), 200 + else: + return jsonify({"status": "unhealthy", **status}), 503 + except Exception as e: + logger.error(f"Health check failed: {e}") + return jsonify({"status": "error", "error": str(e)}), 500 + + +def main(): + """Main function to start the service.""" + global scheduler + + # Validate required environment variables + github_token = os.getenv("GITHUB_TOKEN") + if not github_token: + logger.error("GITHUB_TOKEN environment variable is required") + raise ValueError("GITHUB_TOKEN environment variable is required") + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379") + logger.info(f"Connecting to Redis at {redis_url}") + + # Initialize and start scheduler + try: + scheduler = LessonCacheScheduler() + scheduler.start() + logger.info("Lessons service started successfully") + except Exception as e: + logger.error(f"Failed to start scheduler: {e}", exc_info=True) + raise + + # Start Flask server for health checks + port = int(os.getenv("PORT", "8080")) + logger.info(f"Starting Flask server on port {port}") + app.run(host="0.0.0.0", port=port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/lessons-service/pyproject.toml b/lessons-service/pyproject.toml new file mode 100644 index 0000000..4671be7 --- /dev/null +++ b/lessons-service/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "lessons-service" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "flask>=3.1.2", + "redis>=7.1.0", + "requests>=2.32.5", + "schedule>=1.2.2", +] diff --git a/lessons-service/scheduler.py b/lessons-service/scheduler.py new file mode 100644 index 0000000..7bbe6a9 --- /dev/null +++ b/lessons-service/scheduler.py @@ -0,0 +1,98 @@ +"""Scheduler for refreshing lesson repository cache.""" + +import os +import logging +import schedule +import time +import threading +from github_client import GitHubClient +from cache_manager import CacheManager + +logger = logging.getLogger(__name__) + + +class LessonCacheScheduler: + """Schedules periodic refresh of lesson repository cache.""" + + def __init__(self): + github_token = os.getenv("GITHUB_TOKEN") + github_org = os.getenv("GITHUB_ORG", "hacksu") + + if not github_token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + self.github_client = GitHubClient(github_token, github_org) + self.cache_manager = CacheManager() + self.last_refresh_time = None + self.refresh_interval = 10 + self._running = False + self._thread = None + + def refresh_cache(self): + """Fetch repos from GitHub and update cache.""" + try: + logger.info("Starting cache refresh...") + repos = self.github_client.fetch_lesson_repos() + success = self.cache_manager.cache_repos(repos) + if success: + self.last_refresh_time = time.time() + logger.info( + f"Cache refresh completed successfully. Cached {len(repos)} repos." + ) + else: + logger.error("Cache refresh failed: could not write to cache") + except Exception as e: + logger.error(f"Cache refresh failed: {e}", exc_info=True) + # Serve stale: if existing cache is present, extend its TTL so we don't go empty + stale = self.cache_manager.get_repos() + if stale: + self.cache_manager.cache_repos(stale) + logger.warning( + "GitHub fetch failed; serving stale lesson cache and extended its TTL", + exc_info=False, + ) + else: + logger.warning("GitHub fetch failed and no stale cache is available") + + def start(self): + """Start the scheduler in a background thread.""" + if self._running: + logger.warn("Scheduler is already running") + return + + # Run immediately on startup + logger.info("Running initial cache refresh...") + self.refresh_cache() + + # Schedule periodic refreshes + schedule.every(self.refresh_interval).minutes.do( + self.refresh_cache + ) # I love this syntax + logger.info(f"Scheduled cache refresh every {self.refresh_interval} minutes") + + self._running = True + + def run_scheduler(): + while self._running: + schedule.run_pending() + time.sleep(1) + + self._thread = threading.Thread(target=run_scheduler, daemon=True) + self._thread.start() + logger.info("Scheduler started") + + def stop(self): + """Stop the scheduler.""" + self._running = False + if self._thread: + self._thread.join(timeout=5) + logger.info("Scheduler stopped") + + def get_status(self) -> dict: + """Get scheduler status.""" + return { + "running": self._running, + "last_refresh_time": self.last_refresh_time, + "refresh_interval_minutes": self.refresh_interval, + "cache_healthy": self.cache_manager.health_check(), + } diff --git a/lessons-service/uv.lock b/lessons-service/uv.lock new file mode 100644 index 0000000..f3967a7 --- /dev/null +++ b/lessons-service/uv.lock @@ -0,0 +1,217 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lessons-service" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "redis" }, + { name = "requests" }, + { name = "schedule" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=3.1.2" }, + { name = "redis", specifier = ">=7.1.0" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "schedule", specifier = ">=1.2.2" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "schedule" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585, upload-time = "2025-12-05T15:08:47.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083, upload-time = "2025-12-05T15:08:45.983Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, +] diff --git a/package.json b/package.json index 95631e5..d5ff8d5 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "jsdom": "^27.2.0", "lucia": "^3.2.2", "marked": "^17.0.1", - "postgres": "^3.4.7" + "postgres": "^3.4.7", + "redis": "^5.10.0" } } diff --git a/src/lib/Footer.svelte b/src/lib/Footer.svelte index cc5ef2e..ed54254 100644 --- a/src/lib/Footer.svelte +++ b/src/lib/Footer.svelte @@ -7,15 +7,19 @@ const socialLinks = [ { name: 'Instagram', - href: 'https://www.instagram.com/hacksu/' + href: 'https://www.instagram.com/hacksu/', + icon: instagramIcon, + }, { name: 'GitHub', - href: 'https://github.com/hacksu' + href: 'https://github.com/hacksu', + icon: githubIcon, }, { name: 'Discord', - href: 'https://discord.gg/rJDdvnt' + href: 'https://discord.gg/rJDdvnt', + icon: discordIcon, } ]; @@ -30,13 +34,7 @@ class="text-white hover:text-hacksu-green transition-colors duration-200" aria-label={link.name} > - {#if link.name === 'Instagram'} - Discord - {:else if link.name === 'GitHub'} - Discord - {:else if link.name === 'Discord'} - Discord - {/if} + {link.name} {/each} diff --git a/src/lib/components/MeetingCard.svelte b/src/lib/components/MeetingCard.svelte index aef37f5..48a7f01 100644 --- a/src/lib/components/MeetingCard.svelte +++ b/src/lib/components/MeetingCard.svelte @@ -1,5 +1,6 @@ + +
+
+
+ {#if iconUrl} + {category} + {:else if initials} + {initials} + {:else} + 📁 + {/if} +
+ +

+ {displayCategory} +

+ +
+ {lessonCount} lesson{lessonCount !== 1 ? 's' : ''} +
+
+
diff --git a/src/lib/components/lessons/LessonBreadcrumbs.svelte b/src/lib/components/lessons/LessonBreadcrumbs.svelte new file mode 100644 index 0000000..6579ef5 --- /dev/null +++ b/src/lib/components/lessons/LessonBreadcrumbs.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/src/lib/components/lessons/LessonCard.svelte b/src/lib/components/lessons/LessonCard.svelte new file mode 100644 index 0000000..25dd1d5 --- /dev/null +++ b/src/lib/components/lessons/LessonCard.svelte @@ -0,0 +1,95 @@ + + +
+
+

+ {displayName} +

+ + {#if lesson.description} +

+ {lesson.description} +

+ {/if} + +
+ Updated {formatDate(lesson.last_commit_date || lesson.updated_at)} +
+
+
diff --git a/src/lib/components/lessons/LessonsSkeleton.svelte b/src/lib/components/lessons/LessonsSkeleton.svelte new file mode 100644 index 0000000..b70eab7 --- /dev/null +++ b/src/lib/components/lessons/LessonsSkeleton.svelte @@ -0,0 +1,36 @@ + + +
+
+

HacKSU Lessons

+

Loading lessons...

+ + +
+
+
+ +
+ {#each Array(5) as _, i} +
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+
+
+ + diff --git a/src/lib/components/lessons/SearchBar.svelte b/src/lib/components/lessons/SearchBar.svelte new file mode 100644 index 0000000..b4198fb --- /dev/null +++ b/src/lib/components/lessons/SearchBar.svelte @@ -0,0 +1,35 @@ + + +
+ + + + + +
+ + diff --git a/src/lib/lessons/iconify.ts b/src/lib/lessons/iconify.ts new file mode 100644 index 0000000..2759fbb --- /dev/null +++ b/src/lib/lessons/iconify.ts @@ -0,0 +1,206 @@ +/** + * Iconify icon mapping and utilities + * Maps technology/framework names to Iconify icon identifiers + */ + +// Cache for custom icons from database +let customIconCache: Record | null = null; +let cachePromise: Promise> | null = null; + +/** + * Fetches custom icons from the database API + */ +async function fetchCustomIcons(): Promise> { + if (customIconCache) { + return customIconCache; + } + + if (cachePromise) { + return cachePromise; + } + + cachePromise = fetch('/api/lesson-icons') + .then((res) => res.json()) + .then((data) => { + customIconCache = data; + return data; + }) + .catch((err) => { + console.warn('Failed to fetch custom icons:', err); + return {}; + }) + .finally(() => { + cachePromise = null; + }); + + return cachePromise; +} + +// Common technology to Iconify icon mappings (fallback) +const iconMap: Record = { + // Python frameworks + flask: 'simple-icons:flask', + django: 'simple-icons:django', + fastapi: 'simple-icons:fastapi', + 'fast-api': 'simple-icons:fastapi', + + // JavaScript frameworks + react: 'simple-icons:react', + vue: 'simple-icons:vuedotjs', + angular: 'simple-icons:angular', + express: 'simple-icons:express', + jquery: 'simple-icons:jquery', + meteor: 'simple-icons:meteor', + + // Languages + python: 'simple-icons:python', + javascript: 'simple-icons:javascript', + typescript: 'simple-icons:typescript', + java: 'simple-icons:java', + rust: 'simple-icons:rust', + go: 'simple-icons:go', + 'c++': 'simple-icons:cplusplus', + c: 'simple-icons:c', + 'c#': 'simple-icons:csharp', + ruby: 'simple-icons:ruby', + php: 'simple-icons:php', + swift: 'simple-icons:swift', + kotlin: 'simple-icons:kotlin', + + // Web technologies + html: 'simple-icons:html5', + css: 'simple-icons:css3', + sass: 'simple-icons:sass', + less: 'simple-icons:less', + + // Databases + mongodb: 'simple-icons:mongodb', + postgresql: 'simple-icons:postgresql', + mysql: 'simple-icons:mysql', + sqlite: 'simple-icons:sqlite', + redis: 'simple-icons:redis', + + // Tools + docker: 'simple-icons:docker', + kubernetes: 'simple-icons:kubernetes', + git: 'simple-icons:git', + github: 'simple-icons:github', + nodejs: 'simple-icons:nodedotjs', + npm: 'simple-icons:npm', + yarn: 'simple-icons:yarn', + + // Static site generators + nextjs: 'simple-icons:nextdotjs', + nuxt: 'simple-icons:nuxtdotjs', + gatsby: 'simple-icons:gatsby', + jekyll: 'simple-icons:jekyll', + hugo: 'simple-icons:hugo', + + // Other + graphql: 'simple-icons:graphql', + apollo: 'simple-icons:apollographql', + redux: 'simple-icons:redux', + webpack: 'simple-icons:webpack', + vite: 'simple-icons:vite', +}; + +/** + * Normalizes a name to match icon map keys + */ +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Gets the Iconify icon identifier for a given technology name + * Checks custom database icons first, then falls back to hardcoded map + */ +export async function getIconifyIconAsync(name: string): Promise { + const normalized = normalizeName(name); + + // Check custom icons from database first + const customIcons = await fetchCustomIcons(); + if (customIcons[normalized]) { + return customIcons[normalized]; + } + + // Fallback to hardcoded map + if (iconMap[normalized]) { + return iconMap[normalized]; + } + + // Try partial matches (e.g., "flask-tutorial" -> "flask") + for (const [key, icon] of Object.entries(iconMap)) { + if (normalized.includes(key) || key.includes(normalized)) { + return icon; + } + } + + return null; +} + +/** + * Gets the Iconify icon identifier for a given technology name (synchronous version) + * Uses cached custom icons if available, otherwise falls back to hardcoded map + */ +export function getIconifyIcon(name: string): string | null { + const normalized = normalizeName(name); + + // Check cached custom icons first + if (customIconCache && customIconCache[normalized]) { + return customIconCache[normalized]; + } + + // Fallback to hardcoded map + if (iconMap[normalized]) { + return iconMap[normalized]; + } + + // Try partial matches + for (const [key, icon] of Object.entries(iconMap)) { + if (normalized.includes(key) || key.includes(normalized)) { + return icon; + } + } + + return null; +} + +/** + * Gets the Iconify SVG URL for an icon identifier + */ +export function getIconifyUrl(iconId: string, color?: string): string { + const [collection, icon] = iconId.split(':'); + const colorParam = color ? `&color=${encodeURIComponent(color)}` : ''; + return `https://api.iconify.design/${collection}/${icon}.svg?width=80&height=80${colorParam}`; +} + +/** + * Gets the icon URL for a technology name, or returns null if not found + * Uses cached custom icons if available, otherwise falls back to hardcoded map + */ +export function getTechnologyIconUrl(name: string, color?: string): string | null { + const iconId = getIconifyIcon(name); + if (!iconId) { + return null; + } + return getIconifyUrl(iconId, color); +} + +/** + * Gets the icon URL for a technology name asynchronously + * Fetches custom icons from database if not cached + */ +export async function getTechnologyIconUrlAsync(name: string, color?: string): Promise { + const iconId = await getIconifyIconAsync(name); + if (!iconId) { + return null; + } + return getIconifyUrl(iconId, color); +} + + diff --git a/src/lib/lessons/utils.ts b/src/lib/lessons/utils.ts new file mode 100644 index 0000000..ba839f9 --- /dev/null +++ b/src/lib/lessons/utils.ts @@ -0,0 +1,356 @@ +import type { LessonRepo } from '../../routes/api/lessons/repos/+server'; + +export interface ParsedTopic { + name: string; + level: number; + chain: string; // branch letter or '_default' +} + +export interface CategoryTree { + [key: string]: CategoryTree | LessonRepo[]; +} + +export interface GroupedItems { + categories: string[]; + lessons: LessonRepo[]; +} + +export interface SubcategoryGroup { + name: string; + lessons: LessonRepo[]; +} + +/** + * Parses a topic tag in the format: {categoryName}-{level}{branch?} + * Examples: "framework-1", "javascript-2a", "express-3a" + */ +export function parseTopic(tag: string): ParsedTopic | null { + const match = tag.match(/^(.+)-(\d+)([a-z])?$/); + if (!match) { + return null; + } + + const [, name, levelStr, branch] = match; + const level = parseInt(levelStr, 10); + const chain = branch || '_default'; + + return { name, level, chain }; +} + +/** + * Builds a nested category tree from lesson repos based on their topics + * Supports ordered, branched topic convention: {categoryName}-{level}{branch?} + * + * Examples: + * - "framework-1", "javascript-2a", "express-3a" → path: ["framework", "javascript", "express"] + * - "framework-1b", "python-2b", "flask-3b" → path: ["framework", "python", "flask"] + * + * Branches (a, b, _default) allow parallel category trees. Topics are grouped by branch, + * sorted by level, and paths are built accordingly. Lessons can appear in multiple branches + * if they have topics from different chains. + */ +export function buildCategoryTree(repos: LessonRepo[]): CategoryTree { + const tree: CategoryTree = {}; + + for (const repo of repos) { + // Use only numbered topics to build hierarchy (e.g., framework-1, javascript-2, angular-3) + const topicsByChain: Record = {}; + for (const topic of repo.topics) { + if (topic === 'lesson') continue; + const parsed = parseTopic(topic); + if (!parsed) continue; + if (!topicsByChain[parsed.chain]) topicsByChain[parsed.chain] = []; + topicsByChain[parsed.chain].push(parsed); + } + + // Skip repos without numbered topics (cannot place in hierarchy) + if (Object.keys(topicsByChain).length === 0) continue; + + for (const topics of Object.values(topicsByChain)) { + // Sort by level to determine path order + topics.sort((a, b) => a.level - b.level); + + const path = topics.map((t) => t.name); + let current = tree; + for (const segment of path) { + if (!current[segment]) current[segment] = {}; + current = current[segment] as CategoryTree; + } + + if (!Array.isArray(current)) { + if (!current['__lessons__']) current['__lessons__'] = []; + const lessons = current['__lessons__'] as LessonRepo[]; + if (!lessons.find((l) => l.id === repo.id)) lessons.push(repo); + } + } + } + + return tree; +} + +/** + * Gets items at a specific path in the category tree + */ +export function getItemsAtPath(tree: CategoryTree, path: string[]): GroupedItems { + let current: CategoryTree | LessonRepo[] = tree; + + // Navigate to the path + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + // Path doesn't exist + return { categories: [], lessons: [] }; + } + } + + // If we're at a lessons array, return it + if (Array.isArray(current)) { + return { categories: [], lessons: current }; + } + + // Extract categories and lessons from current level + const categories: string[] = []; + const lessons: LessonRepo[] = []; + + for (const [key, value] of Object.entries(current)) { + if (key === '__lessons__' && Array.isArray(value)) { + lessons.push(...value); + } else if (typeof value === 'object' && !Array.isArray(value)) { + categories.push(key); + } + } + + return { categories, lessons }; +} + +/** + * Gets items grouped by level-2 categories when at level 1 + * Returns: { [level2Category]: { categories: string[], lessons: LessonRepo[] } } + */ +export function getGroupedItemsAtLevel(repos: LessonRepo[], path: string[]): Record { + const grouped: Record = {}; + const targetLevel = path.length + 1; // The level we're looking at + + for (const repo of repos) { + // Parse topics and find matching path + const topicsByChain: Record = {}; + for (const topic of repo.topics) { + if (topic === 'lesson') continue; + const parsed = parseTopic(topic); + if (!parsed) continue; + if (!topicsByChain[parsed.chain]) topicsByChain[parsed.chain] = []; + topicsByChain[parsed.chain].push(parsed); + } + + // Check each chain to see if it matches the current path + for (const topics of Object.values(topicsByChain)) { + topics.sort((a, b) => a.level - b.level); + const topicPath = topics.map((t) => t.name); + + // Check if this chain matches the current path + const matchesPath = path.every((segment, index) => { + return topicPath[index]?.toLowerCase() === segment.toLowerCase(); + }); + + if (matchesPath && topicPath.length >= targetLevel) { + // Get the level-2 category (or level-3 if we're at level 2, etc.) + const groupKey = topicPath[targetLevel - 1]; + if (!groupKey) continue; + + if (!grouped[groupKey]) { + grouped[groupKey] = { categories: [], lessons: [] }; + } + + // If there's a next level category, add it to categories + if (topicPath.length > targetLevel) { + const nextCategory = topicPath[targetLevel]; + if (!grouped[groupKey].categories.includes(nextCategory)) { + grouped[groupKey].categories.push(nextCategory); + } + } else { + // This is a lesson at this level + if (!grouped[groupKey].lessons.find((l) => l.id === repo.id)) { + grouped[groupKey].lessons.push(repo); + } + } + } + } + } + + // Sort categories within each group + for (const group of Object.values(grouped)) { + group.categories.sort(); + } + + return grouped; +} + +/** + * Gets the subtree at a specific path + */ +export function getSubTreeAtPath(tree: CategoryTree, path: string[]): CategoryTree | null { + let current: CategoryTree | LessonRepo[] = tree; + + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + return null; + } + } + + if (Array.isArray(current)) { + return null; + } + + return current as CategoryTree; +} + +/** + * Gets lessons grouped by their immediate subcategories + * Used when viewing a category to show section headings with lessons grouped underneath + */ +export function getLessonsBySubcategory(tree: CategoryTree, path: string[]): { + directLessons: LessonRepo[]; + subcategoryGroups: SubcategoryGroup[]; +} { + const directLessons: LessonRepo[] = []; + const subcategoryGroups: SubcategoryGroup[] = []; + + // Navigate to the path + let current: CategoryTree | LessonRepo[] = tree; + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + return { directLessons: [], subcategoryGroups: [] }; + } + } + + if (Array.isArray(current)) { + return { directLessons: current, subcategoryGroups: [] }; + } + + // Get direct lessons at this level + if (current['__lessons__'] && Array.isArray(current['__lessons__'])) { + directLessons.push(...current['__lessons__']); + } + + // For each subcategory, collect all lessons from level 3 and below + for (const [categoryName, subTree] of Object.entries(current)) { + if (categoryName === '__lessons__' || Array.isArray(subTree)) { + continue; + } + + const lessons: LessonRepo[] = []; + + // Recursively collect all lessons from this subcategory + function collectLessons(node: CategoryTree | LessonRepo[]): void { + if (Array.isArray(node)) { + lessons.push(...node); + } else { + if (node['__lessons__'] && Array.isArray(node['__lessons__'])) { + lessons.push(...node['__lessons__']); + } + for (const [key, value] of Object.entries(node)) { + if (key !== '__lessons__' && typeof value === 'object' && !Array.isArray(value)) { + collectLessons(value); + } + } + } + } + + collectLessons(subTree); + + // Remove duplicates + const uniqueLessons = Array.from(new Map(lessons.map((l) => [l.id, l])).values()); + + if (uniqueLessons.length > 0) { + subcategoryGroups.push({ + name: categoryName, + lessons: uniqueLessons + }); + } + } + + // Sort subcategory groups alphabetically + subcategoryGroups.sort((a, b) => a.name.localeCompare(b.name)); + + return { directLessons, subcategoryGroups }; +} + +/** + * Counts the total number of lessons in a category (recursively) + */ +export function countLessonsInCategory(tree: CategoryTree | LessonRepo[]): number { + if (Array.isArray(tree)) { + return tree.length; + } + + // Count all lessons in this tree + let count = 0; + if (tree['__lessons__'] && Array.isArray(tree['__lessons__'])) { + count += tree['__lessons__'].length; + } + + for (const [key, value] of Object.entries(tree)) { + if (key !== '__lessons__' && typeof value === 'object' && !Array.isArray(value)) { + count += countLessonsInCategory(value); + } + } + + return count; +} + +/** + * Formats a date as "X time ago" + */ +export function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSecs < 60) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; + if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks !== 1 ? 's' : ''} ago`; + if (diffMonths < 12) return `${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`; + return `${diffYears} year${diffYears !== 1 ? 's' : ''} ago`; +} + +/** + * Gets language color (basic mapping) + */ +export function getLanguageColor(language: string | null): string { + if (!language) return '#6b7280'; + + const colors: Record = { + JavaScript: '#f7df1e', + TypeScript: '#3178c6', + Python: '#3776ab', + Java: '#ed8b00', + 'C++': '#00599c', + C: '#a8b9cc', + Rust: '#000000', + Go: '#00add8', + Ruby: '#cc342d', + PHP: '#777bb4', + Swift: '#fa7343', + Kotlin: '#7f52ff', + HTML: '#e34c26', + CSS: '#1572b6', + Shell: '#89e051', + 'C#': '#239120' + }; + + return colors[language] || '#6b7280'; +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 0e59bc3..f1b2751 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -84,3 +84,11 @@ export const information = pgTable('information', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() }); +// Lesson icon mappings for customizing category icons +export const lessonIcons = pgTable('lesson_icons', { + categoryName: text('category_name').primaryKey(), + iconifyId: text('iconify_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() +}); + diff --git a/src/lib/server/redis.ts b/src/lib/server/redis.ts new file mode 100644 index 0000000..9f44732 --- /dev/null +++ b/src/lib/server/redis.ts @@ -0,0 +1,82 @@ +import { createClient } from 'redis'; +import { env } from '$env/dynamic/private'; +import { logger } from './logger'; + +let redisClient: ReturnType | null = null; +let connectionPromise: Promise | null = null; + +async function ensureConnected(): Promise> { + if (!redisClient) { + const redisUrl = env.REDIS_URL || 'redis://localhost:6379'; + + redisClient = createClient({ + url: redisUrl, + socket: { + reconnectStrategy: (retries) => { + if (retries > 10) { + logger.error('Redis reconnection failed after 10 attempts'); + return new Error('Redis reconnection failed'); + } + return Math.min(retries * 100, 3000); + } + } + }); + + redisClient.on('error', (err) => { + logger.error('Redis client error', err); + }); + + redisClient.on('connect', () => { + logger.info('Redis client connected'); + }); + + redisClient.on('reconnecting', () => { + logger.info('Redis client reconnecting...'); + }); + + connectionPromise = redisClient.connect().catch((err) => { + logger.error('Failed to connect to Redis', err); + connectionPromise = null; + throw err; + }); + } + + // Wait for connection if it's still in progress + if (connectionPromise) { + try { + await connectionPromise; + } catch (err) { + // Connection failed, but we'll still try to use the client + // (it might reconnect automatically) + } + } + + return redisClient; +} + +export async function getCachedRepos(): Promise { + try { + const client = await ensureConnected(); + const data = await client.get('lessons:repos'); + if (data) { + return JSON.parse(data); + } + return null; + } catch (err) { + logger.error('Error retrieving repos from Redis', err); + return null; + } +} + +export async function getCachedReadme(repoName: string): Promise { + try { + const client = await ensureConnected(); + const key = `lessons:readme:${repoName}`; + const content = await client.get(key); + return content || null; + } catch (err) { + logger.error(`Error retrieving README for ${repoName} from Redis`, err); + return null; + } +} + diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index 42245a2..da2112a 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -1,95 +1,95 @@ -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; -import { browser } from '$app/environment'; - -// Shared sanitization config -const SANITIZE_CONFIG = { - ALLOWED_TAGS: [ - 'p', - 'br', - 'strong', - 'em', - 'u', - 's', - 'code', - 'pre', - 'a', - 'ul', - 'ol', - 'li', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'blockquote', - 'hr', - 'img', - 'table', - 'thead', - 'tbody', - 'tr', - 'th', - 'td', - ], - ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, -}; - -// Lazy-loaded jsdom for server-side use -let jsdomModule: typeof import('jsdom') | null = null; - -function getJSDOM(): typeof import('jsdom') { - if (!jsdomModule) { - // Use require for server-side to avoid bundling issues - // eslint-disable-next-line @typescript-eslint/no-require-imports - jsdomModule = require('jsdom') as typeof import('jsdom'); - } - return jsdomModule; -} - -/** - * Renders markdown to sanitized HTML - * @param markdown - The markdown string to render - * @returns Sanitized HTML string safe for use with {@html} directive - */ -export function renderMarkdown(markdown: string): string { - if (!markdown) return ''; - - // Configure marked options - marked.setOptions({ - breaks: true, // Convert line breaks to
- gfm: true, // GitHub Flavored Markdown - }); - - // Parse markdown to HTML - const html = marked.parse(markdown) as string; - - // Sanitize HTML to prevent XSS attacks - // This is critical for user-generated content - let sanitized: string; - - if (browser) { - // Client-side: use DOMPurify directly - sanitized = DOMPurify.sanitize(html, SANITIZE_CONFIG); - } else { - // Server-side: use JSDOM for DOMPurify - // jsdom is externalized in vite.config.ts to avoid bundling - try { - const { JSDOM } = getJSDOM(); - const dom = new JSDOM(''); - const purify = DOMPurify(dom.window as unknown as Window & typeof globalThis); - sanitized = purify.sanitize(html, SANITIZE_CONFIG); - } catch (e) { - // Fallback during build: return unsanitized HTML - // This should only happen during build, not in production SSR - // In production, jsdom will be available as an external dependency - console.warn('JSDOM not available during build, skipping sanitization'); - sanitized = html; - } - } - - return sanitized; -} - +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { browser } from '$app/environment'; + +// Shared sanitization config +const SANITIZE_CONFIG = { + ALLOWED_TAGS: [ + 'p', + 'br', + 'strong', + 'em', + 'u', + 's', + 'code', + 'pre', + 'a', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'hr', + 'img', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + ], + ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, +}; + +// Lazy-loaded jsdom for server-side use +let jsdomModule: typeof import('jsdom') | null = null; + +function getJSDOM(): typeof import('jsdom') { + if (!jsdomModule) { + // Use require for server-side to avoid bundling issues + // eslint-disable-next-line @typescript-eslint/no-require-imports + jsdomModule = require('jsdom') as typeof import('jsdom'); + } + return jsdomModule; +} + +/** + * Renders markdown to sanitized HTML + * @param markdown - The markdown string to render + * @returns Sanitized HTML string safe for use with {@html} directive + */ +export function renderMarkdown(markdown: string): string { + if (!markdown) return ''; + + // Configure marked options + marked.setOptions({ + breaks: true, // Convert line breaks to
+ gfm: true, // GitHub Flavored Markdown + }); + + // Parse markdown to HTML + const html = marked.parse(markdown) as string; + + // Sanitize HTML to prevent XSS attacks + // This is critical for user-generated content + let sanitized: string; + + if (browser) { + // Client-side: use DOMPurify directly + sanitized = DOMPurify.sanitize(html, SANITIZE_CONFIG); + } else { + // Server-side: use JSDOM for DOMPurify + // jsdom is externalized in vite.config.ts to avoid bundling + try { + const { JSDOM } = getJSDOM(); + const dom = new JSDOM(''); + const purify = DOMPurify(dom.window as unknown as Window & typeof globalThis); + sanitized = purify.sanitize(html, SANITIZE_CONFIG); + } catch (e) { + // Fallback during build: return unsanitized HTML + // This should only happen during build, not in production SSR + // In production, jsdom will be available as an external dependency + console.warn('JSDOM not available during build, skipping sanitization'); + sanitized = html; + } + } + + return sanitized; +} + diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 3d08def..ef2e97e 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -31,6 +31,11 @@ title: 'Helpful Info', href: '/admin/information', description: 'Manage blocks shown on the /info page' + }, + { + title: 'Lesson Icons', + href: '/admin/lesson-icons', + description: 'Manage icon mappings for lesson categories' } ]; diff --git a/src/routes/admin/api/dump/json/+server.ts b/src/routes/admin/api/dump/json/+server.ts index f8a8b5f..3b1f960 100644 --- a/src/routes/admin/api/dump/json/+server.ts +++ b/src/routes/admin/api/dump/json/+server.ts @@ -1,14 +1,14 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { adminSessions, location, leadership, meetings, redirects, notes, information } from '$lib/server/db/schema'; +import { adminSessions, location, leadership, meetings, redirects, notes, information, lessonIcons } from '$lib/server/db/schema'; import { requireAdmin } from '$lib/server/admin'; export const GET: RequestHandler = async (event) => { await requireAdmin(event); // Fetch everything we care about for backup/export - const [adminSessionsList, locationList, leadershipList, meetingsList, redirectsList, notesList, informationList] = + const [adminSessionsList, locationList, leadershipList, meetingsList, redirectsList, notesList, informationList, lessonIconsList] = await Promise.all([ // admin sessions (you may want to omit in some contexts, but exporting for now) db.select().from(adminSessions), @@ -23,7 +23,9 @@ export const GET: RequestHandler = async (event) => { // admin notes db.select().from(notes), // helpful information blocks - db.select().from(information) + db.select().from(information), + // lesson icon mappings + db.select().from(lessonIcons) ]); return json({ @@ -35,7 +37,8 @@ export const GET: RequestHandler = async (event) => { meetings: meetingsList, redirects: redirectsList, notes: notesList, - information: informationList + information: informationList, + lessonIcons: lessonIconsList }); }; @@ -52,7 +55,8 @@ export const POST: RequestHandler = async (event) => { meetings: meetingsList = [], redirects: redirectsList = [], notes: notesList = [], - information: informationList = [] + information: informationList = [], + lessonIcons: lessonIconsList = [] } = body ?? {}; // Naive restore strategy: @@ -69,6 +73,7 @@ export const POST: RequestHandler = async (event) => { await db.delete(redirects); await db.delete(notes); await db.delete(information); + await db.delete(lessonIcons); // Helper to parse ISO timestamp fields into Date instances const parseDate = (value: unknown): Date | null => { @@ -117,12 +122,16 @@ export const POST: RequestHandler = async (event) => { await db.insert(redirects).values(rows); } if (Array.isArray(notesList) && notesList.length > 0) { - const rows = notesList.map((row: any) => ({ + const rows = notesList.map((row: any) => { + // Use createdAt as fallback if date is null (for legacy data) + const date = parseDate(row.date) || parseDate(row.createdAt) || new Date(); + return { ...row, - date: parseDate(row.date), + date, createdAt: parseDate(row.createdAt), updatedAt: parseDate(row.updatedAt) - })); + }; + }); await db.insert(notes).values(rows); } if (Array.isArray(informationList) && informationList.length > 0) { @@ -133,6 +142,14 @@ export const POST: RequestHandler = async (event) => { })); await db.insert(information).values(rows); } + if (Array.isArray(lessonIconsList) && lessonIconsList.length > 0) { + const rows = lessonIconsList.map((row: any) => ({ + ...row, + createdAt: parseDate(row.createdAt), + updatedAt: parseDate(row.updatedAt) + })); + await db.insert(lessonIcons).values(rows); + } return json({ ok: true, @@ -144,7 +161,8 @@ export const POST: RequestHandler = async (event) => { meetings: meetingsList.length ?? 0, redirects: redirectsList.length ?? 0, notes: notesList.length ?? 0, - information: informationList.length ?? 0 + information: informationList.length ?? 0, + lessonIcons: lessonIconsList.length ?? 0 } }); }; diff --git a/src/routes/admin/lesson-icons/+page.server.ts b/src/routes/admin/lesson-icons/+page.server.ts new file mode 100644 index 0000000..a3082dd --- /dev/null +++ b/src/routes/admin/lesson-icons/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { asc } from 'drizzle-orm'; + +export const load: PageServerLoad = async (event) => { + await requireAdmin(event); + + const icons = await db.query.lessonIcons.findMany({ + orderBy: [asc(lessonIcons.categoryName)] + }); + + return { + icons + }; +}; + diff --git a/src/routes/admin/lesson-icons/+page.svelte b/src/routes/admin/lesson-icons/+page.svelte new file mode 100644 index 0000000..7fa1376 --- /dev/null +++ b/src/routes/admin/lesson-icons/+page.svelte @@ -0,0 +1,231 @@ + + +
+
+

Lesson Icons Editor

+

Manage icon mappings for lesson categories

+
+ +
+ +
+
+
?
+
+ +
+
+ + +
+ + + + +
+
+ + + {#each icons as icon} + {@const previewUrl = getIconUrl(icon.iconifyId)} +
+
+ {#if previewUrl} + {icon.categoryName} { + // Hide image on error, show placeholder instead + e.currentTarget.style.display = 'none'; + }} + /> + {:else} +
?
+ {/if} +
+ +
+
+ + +
+ + + +
+ +
{ + return ({ update }) => { + if (confirm(`Really delete icon mapping for "${icon.categoryName}"?`)) { + return update(); + } + return () => {}; + }; + }} + class="flex-1" + > + +
+
+
+
+ {/each} +
+
+ diff --git a/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts b/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts new file mode 100644 index 0000000..22f6819 --- /dev/null +++ b/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts @@ -0,0 +1,37 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { eq } from 'drizzle-orm'; + +export const POST: RequestHandler = async (event) => { + await requireAdmin(event); + + const categoryName = event.params.categoryName; + + if (!categoryName) { + throw error(400, { message: 'Category name is required' }); + } + + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (!existing) { + throw error(404, { message: 'Icon mapping not found' }); + } + + try { + await db.delete(lessonIcons).where(eq(lessonIcons.categoryName, categoryName.toLowerCase())); + + throw redirect(303, '/admin/lesson-icons'); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + console.error('Error deleting lesson icon:', err); + throw error(500, { message: 'Failed to delete icon mapping' }); + } +}; + diff --git a/src/routes/admin/lesson-icons/api/+server.ts b/src/routes/admin/lesson-icons/api/+server.ts new file mode 100644 index 0000000..e330882 --- /dev/null +++ b/src/routes/admin/lesson-icons/api/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; + +export const POST: RequestHandler = async (event) => { + await requireAdmin(event); + + const body = await event.request.json(); + const { categoryName, iconifyId } = body; + + if (!categoryName || !iconifyId) { + throw error(400, { message: 'Category name and iconify ID are required' }); + } + + // Validate iconify ID format (should be like "collection:icon") + if (!/^[^:]+:[^:]+$/.test(iconifyId)) { + throw error(400, { message: 'Invalid iconify ID format. Expected format: collection:icon' }); + } + + // Check if icon already exists + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (existing) { + throw error(400, { message: 'Icon mapping for this category already exists' }); + } + + try { + await db.insert(lessonIcons).values({ + categoryName: categoryName.toLowerCase(), + iconifyId + }); + + return json({ success: true }); + } catch (err) { + console.error('Error creating lesson icon:', err); + throw error(500, { message: 'Failed to create icon mapping' }); + } +}; + diff --git a/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts b/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts new file mode 100644 index 0000000..1dc83a9 --- /dev/null +++ b/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts @@ -0,0 +1,44 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { eq } from 'drizzle-orm'; + +export const PUT: RequestHandler = async (event) => { + await requireAdmin(event); + + const categoryName = event.params.categoryName; + const body = await event.request.json(); + const { iconifyId } = body; + + if (!categoryName || !iconifyId) { + throw error(400, { message: 'Category name and iconify ID are required' }); + } + + // Validate iconify ID format + if (!/^[^:]+:[^:]+$/.test(iconifyId)) { + throw error(400, { message: 'Invalid iconify ID format. Expected format: collection:icon' }); + } + + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (!existing) { + throw error(404, { message: 'Icon mapping not found' }); + } + + try { + await db + .update(lessonIcons) + .set({ iconifyId, updatedAt: new Date() }) + .where(eq(lessonIcons.categoryName, categoryName.toLowerCase())); + + return json({ success: true }); + } catch (err) { + console.error('Error updating lesson icon:', err); + throw error(500, { message: 'Failed to update icon mapping' }); + } +}; + diff --git a/src/routes/api/lesson-icons/+server.ts b/src/routes/api/lesson-icons/+server.ts new file mode 100644 index 0000000..eed55df --- /dev/null +++ b/src/routes/api/lesson-icons/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; + +export const GET: RequestHandler = async () => { + const icons = await db.query.lessonIcons.findMany(); + + // Convert to a map for easy lookup + const iconMap: Record = {}; + for (const icon of icons) { + iconMap[icon.categoryName.toLowerCase()] = icon.iconifyId; + } + + return json(iconMap); +}; + diff --git a/src/routes/api/lessons/repos/+server.ts b/src/routes/api/lessons/repos/+server.ts new file mode 100644 index 0000000..397be4e --- /dev/null +++ b/src/routes/api/lessons/repos/+server.ts @@ -0,0 +1,46 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { logger } from '$lib/server/logger'; +import { getCachedRepos } from '$lib/server/redis'; + +export interface LessonRepo { + id: string; + name: string; + description: string | null; + html_url: string; + updated_at: string; + last_commit_date?: string; + language: string | null; + topics: string[]; +} + +export const GET: RequestHandler = async () => { + try { + // Try to get repos from Redis cache (populated by Python service) + const repos = await getCachedRepos(); + + if (repos && Array.isArray(repos) && repos.length > 0) { + logger.info('Returning lesson repos from Redis cache', { + repoCount: repos.length + }); + return json(repos); + } + + // Cache miss or empty - this should rarely happen if Python service is running + logger.warn('No repos found in Redis cache. Python service may not be running or cache is empty.'); + throw error(503, { + message: + 'Lesson repositories are temporarily unavailable. The cache service may be starting up. Please try again in a few moments.' + }); + } catch (err) { + // If it's already a SvelteKit error, re-throw it + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + + logger.error('Error fetching lesson repos from Redis', err); + const message = err instanceof Error ? err.message : 'Unknown error occurred'; + throw error(500, { message }); + } +}; + diff --git a/src/routes/api/lessons/repos/[name]/readme/+server.ts b/src/routes/api/lessons/repos/[name]/readme/+server.ts new file mode 100644 index 0000000..f3d9b39 --- /dev/null +++ b/src/routes/api/lessons/repos/[name]/readme/+server.ts @@ -0,0 +1,111 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { logger } from '$lib/server/logger'; +import { getCachedReadme } from '$lib/server/redis'; +import { env } from '$env/dynamic/private'; + +interface ReadmeResponse { + content: string; + encoding: string; +} + +async function fetchReadmeFromGitHub(repoName: string, org: string): Promise { + const githubToken = env.GITHUB_TOKEN; + if (!githubToken) { + logger.error('GITHUB_TOKEN environment variable is not set'); + return null; + } + + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${githubToken}`, + 'User-Agent': 'HacKSU-Website' + }; + + try { + logger.debug('Fetching README from GitHub API', { repoName, org }); + const response = await fetch(`https://api.github.com/repos/${org}/${repoName}/readme`, { + headers + }); + + if (!response.ok) { + if (response.status === 404) { + logger.debug('README not found for repository', { repoName }); + return null; + } + logger.error('GitHub API request failed for README', undefined, { + repoName, + status: response.status, + statusText: response.statusText + }); + return null; + } + + const data: ReadmeResponse = await response.json(); + + // Decode base64 content + if (data.encoding === 'base64') { + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + logger.info('Successfully fetched and decoded README from GitHub', { + repoName, + contentLength: content.length + }); + return content; + } + + logger.warn('README encoding is not base64', { repoName, encoding: data.encoding }); + return data.content; + } catch (err) { + logger.error('Error fetching README from GitHub', err, { repoName }); + return null; + } +} + +export const GET: RequestHandler = async ({ params }) => { + const repoName = params.name; + const org = 'hacksu'; + + logger.info('Fetching README for lesson repo', { repoName, org }); + + if (!repoName) { + logger.warn('README request missing repo name'); + throw error(400, 'Repository name is required'); + } + + // Security: validate repo name to prevent path traversal + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + logger.warn('Invalid repository name format', { repoName }); + throw error(400, 'Invalid repository name'); + } + + try { + // Try Redis cache first + let content = await getCachedReadme(repoName); + + if (content) { + logger.info('Returning README from Redis cache', { + repoName, + contentLength: content.length + }); + return json({ content }); + } + + // Cache miss - fallback to GitHub API + logger.debug('README not in cache, fetching from GitHub', { repoName }); + content = await fetchReadmeFromGitHub(repoName, org); + + if (content) { + return json({ content }); + } + + // Not found in either cache or GitHub + throw error(404, 'README not found for this repository'); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Re-throw SvelteKit errors + } + logger.error('Error fetching README', err, { repoName }); + throw error(500, 'Failed to fetch README'); + } +}; + diff --git a/src/routes/lessons/+page.server.ts b/src/routes/lessons/+page.server.ts new file mode 100644 index 0000000..2dfef0a --- /dev/null +++ b/src/routes/lessons/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; + +// Return empty data to allow immediate skeleton rendering +// Data will be loaded client-side for better perceived performance +export const load: PageServerLoad = async () => { + return { + repos: [], + error: null + }; +}; + diff --git a/src/routes/lessons/+page.svelte b/src/routes/lessons/+page.svelte new file mode 100644 index 0000000..85a9332 --- /dev/null +++ b/src/routes/lessons/+page.svelte @@ -0,0 +1,263 @@ + + +
+ {#if isLoading} + + {:else if error} +
+

Error Loading Lessons

+

{error}

+

+ Please check that the GITHUB_TOKEN environment variable is set correctly. +

+
+ {:else} +
+ {#if currentPath.length > 0} + + {/if} + +

HacKSU Lessons

+ + {#if !showSearchResults} +

+ {#if currentPath.length === 0} + Explore our collection of lessons organized by topic + {:else} + Lessons in {currentPath.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' / ')} + {/if} +

+ {/if} + + + + {#if showSearchResults} +
+

+ {filteredLessons.length} result{filteredLessons.length !== 1 ? 's' : ''} found +

+ {#if filteredLessons.length === 0} +

No lessons found matching "{searchQuery}"

+ {:else} +
+ {#each filteredLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {:else} + + + {#if currentPath.length === 1 && Object.keys(groupedBySections).length > 0} + + {#each Object.entries(groupedBySections) as [sectionName, sectionItems], i} +
+

+ {sectionName.charAt(0).toUpperCase() + sectionName.slice(1)} +

+ {#if sectionItems.categories.length > 0} +
+ {#each sectionItems.categories as category} + {@const subTreePath = [...currentPath, sectionName, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategoryInSection(category, sectionName)}> + +
+ {/each} +
+ {/if} + {#if sectionItems.lessons.length > 0} +
+ {#each sectionItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {/each} + {:else if groupedItems.categories.length === 0 && groupedItems.lessons.length === 0} +
No lessons found in this category.
+ {:else if groupedItems.categories.length > 0} + {#if currentPath.length === 0 && groupedItems.categories.length === 5} + +
+ +
+ {#each groupedItems.categories.slice(0, 3) as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+ +
+ {#each groupedItems.categories.slice(3, 5) as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+
+ {:else} + +
+ {#each groupedItems.categories as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+ {/if} + {:else} +
+ {#each groupedItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + {/if} +
+ {/if} +
+ diff --git a/src/routes/lessons/[...path]/+page.server.ts b/src/routes/lessons/[...path]/+page.server.ts new file mode 100644 index 0000000..386f9ce --- /dev/null +++ b/src/routes/lessons/[...path]/+page.server.ts @@ -0,0 +1,9 @@ +import type { PageServerLoad } from './$types'; + +// Return empty data to allow immediate skeleton rendering +// Data will be loaded client-side for better perceived performance +export const load: PageServerLoad = async () => { + return {}; +}; + + diff --git a/src/routes/lessons/[...path]/+page.svelte b/src/routes/lessons/[...path]/+page.svelte new file mode 100644 index 0000000..ccda973 --- /dev/null +++ b/src/routes/lessons/[...path]/+page.svelte @@ -0,0 +1,267 @@ + + +
+ {#if isLoading} + + {:else if error} +
+

Error Loading Lessons

+

{error}

+

+ Please check that the GITHUB_TOKEN environment variable is set correctly. +

+
+ {:else} +
+ {#if currentPath.length > 0} + + {/if} + +

HacKSU Lessons

+ + {#if !showSearchResults} +

+ {#if currentPath.length === 0} + Explore our collection of lessons organized by topic + {:else} + Lessons in {currentPath.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' / ')} + {/if} +

+ {/if} + + + + {#if showSearchResults} +
+

+ {filteredLessons.length} result{filteredLessons.length !== 1 ? 's' : ''} found +

+ {#if filteredLessons.length === 0} +

No lessons found matching "{searchQuery}"

+ {:else} +
+ {#each filteredLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {:else} + + + {#if currentPath.length === 1 && Object.keys(groupedBySections).length > 0} + + {#each Object.entries(groupedBySections) as [sectionName, sectionItems]} +
+

+ {sectionName.charAt(0).toUpperCase() + sectionName.slice(1)} +

+ {#if sectionItems.categories.length > 0} +
+ {#each sectionItems.categories as category} + {@const subTreePath = [...currentPath, sectionName, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategoryInSection(category, sectionName)}> + +
+ {/each} +
+ {/if} + {#if sectionItems.lessons.length > 0} +
+ {#each sectionItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {/each} + {:else if groupedItems.categories.length === 0 && groupedItems.lessons.length === 0 && lessonsBySubcategory.directLessons.length === 0 && lessonsBySubcategory.subcategoryGroups.length === 0} +
No lessons found in this category.
+ {:else if lessonsBySubcategory.subcategoryGroups.length > 0 || lessonsBySubcategory.directLessons.length > 0} + + {#if lessonsBySubcategory.directLessons.length > 0} +
+ {#each lessonsBySubcategory.directLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + + {#each lessonsBySubcategory.subcategoryGroups as group} +
+

+ {group.name.charAt(0).toUpperCase() + group.name.slice(1)} +

+
+
+ {#each group.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+
+ {/each} + {:else if groupedItems.categories.length > 0} + +
+ {#each groupedItems.categories as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+ {:else} + +
+ {#each groupedItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + {/if} +
+ {/if} +
+ diff --git a/src/routes/lessons/detail/[name]/+page.server.ts b/src/routes/lessons/detail/[name]/+page.server.ts new file mode 100644 index 0000000..63c43a7 --- /dev/null +++ b/src/routes/lessons/detail/[name]/+page.server.ts @@ -0,0 +1,52 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { logger } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const repoName = params.name; + + logger.info('Loading lesson detail page', { repoName }); + + if (!repoName) { + logger.warn('Lesson detail page missing repo name'); + throw error(400, 'Repository name is required'); + } + + // Security: validate repo name + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + logger.warn('Invalid repository name format in lesson detail', { repoName }); + throw error(400, 'Invalid repository name'); + } + + try { + const response = await fetch(`/api/lessons/repos/${repoName}/readme`); + if (!response.ok) { + logger.error('Failed to fetch README from API', undefined, { + repoName, + status: response.status, + statusText: response.statusText + }); + if (response.status === 404) { + throw error(404, 'README not found for this lesson'); + } + throw error(response.status, 'Failed to fetch README'); + } + const data = await response.json(); + logger.info('Successfully loaded lesson detail page', { + repoName, + readmeLength: data.content?.length || 0 + }); + return { readme: data.content, repoName, error: null }; + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Re-throw SvelteKit errors + } + logger.error('Error loading lesson detail page', err, { repoName }); + return { + readme: null, + repoName, + error: err instanceof Error ? err.message : 'Failed to load README' + }; + } +}; + diff --git a/src/routes/lessons/detail/[name]/+page.svelte b/src/routes/lessons/detail/[name]/+page.svelte new file mode 100644 index 0000000..cdc79f0 --- /dev/null +++ b/src/routes/lessons/detail/[name]/+page.svelte @@ -0,0 +1,68 @@ + + +
+
+ + + {#if error} +
+

Error Loading Lesson

+

{error}

+ +
+ {:else if !readme} +
+
+

Loading lesson content...

+
+ {:else} +
+
+

{repoName}

+ + View on GitHub → + +
+
+ {@html readmeHtml} +
+
+ {/if} +
+
+