diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ee6cc..792c2e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,22 @@ on: jobs: backend: runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/cheatsheet_test + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: cheatsheet_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres -d cheatsheet_test" + --health-interval=10s + --health-timeout=5s + --health-retries=5 defaults: run: working-directory: ./backend diff --git a/.gitignore b/.gitignore index 122ac54..79405ac 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ session-*.md .env.* frontend/.env backend/.env +!backend/.env.example # Ignore all SQLite DBs, not just the default *.sqlite3 @@ -65,4 +66,3 @@ backend/coverage/ # OpenCode/Sisyphus .sisyphus/ - diff --git a/README.md b/README.md index d0d404e..092908c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele ### Database Features - **Templates**: Save and manage reusable LaTeX templates - **Cheat Sheets**: Save and load your cheat sheet projects -- **Practice Problems**: Add practice problems to your sheets +- **Practice Problems**: Add compiler-backed practice-problem blocks with live validation and compiled previews ## Tech Stack @@ -35,7 +35,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele | Frontend | React 18 + Vite | | Backend | Django 6 + Django REST Framework | | LaTeX Engine | Tectonic | -| Database | SQLite (dev) / PostgreSQL (Docker/prod) | +| Database | PostgreSQL 15 | | Container | Docker Compose | ## Project Structure @@ -90,6 +90,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele | GET/PUT/PATCH/DELETE | `/api/cheatsheets/{id}/` | Retrieve/update/delete cheat sheet | | GET/POST | `/api/problems/` | List/create practice problems | | GET/PUT/PATCH/DELETE | `/api/problems/{id}/` | Retrieve/update/delete problem | +| POST | `/api/problems/preview/` | Validate and preview a `simple_v1` practice-problem block | ### Available Formula Classes @@ -110,19 +111,30 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele - Python 3.13+ - Node.js 20+ +- PostgreSQL 15+ (or Docker) - Tectonic (for PDF compilation) ### Backend Setup +Start PostgreSQL before running Django locally. The quickest option is: + +```bash +docker compose up -d db +``` + ```bash cd backend python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate pip install -r requirements.txt +cp .env.example .env python manage.py migrate python manage.py runserver ``` +`backend/.env.example` is preconfigured for a PostgreSQL instance on `localhost:5432`. +If you want to run Postgres in Docker while keeping the backend local, start it with `docker compose up -d db`. + The API will be available at `http://localhost:8000/api/`. ### Frontend Setup @@ -142,12 +154,14 @@ docker compose up --build ``` This builds and starts the Django backend, React frontend, and PostgreSQL database. +The database is also exposed on `localhost:5432` for local backend/test runs. ## Running Tests ### Backend (pytest) ```bash +docker compose up -d db cd backend pytest # Run all tests pytest -v # Run with verbose output @@ -155,6 +169,8 @@ pytest -k "test_name" # Run tests matching pattern pytest api/tests.py # Run specific test file ``` +Tests now run against PostgreSQL using the connection configured in `backend/.env`. + ### Frontend (ESLint) ```bash diff --git a/backend/.env.docker b/backend/.env.docker index 9c87c95..276a6da 100644 --- a/backend/.env.docker +++ b/backend/.env.docker @@ -5,4 +5,9 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,backend CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 # PostgreSQL connection (matches the db service in docker-compose.yml) -DATABASE_URL=postgres://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db +DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db +POSTGRES_HOST=db +POSTGRES_PORT=5432 +POSTGRES_DB=cheatsheet_db +POSTGRES_USER=cheatsheet_user +POSTGRES_PASSWORD=cheatsheet_pass diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fef0bd3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +DJANGO_DEBUG=True +DJANGO_SECRET_KEY=django-insecure-dev-secret-key-change-me +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=cheatsheet_db +POSTGRES_USER=cheatsheet_user +POSTGRES_PASSWORD=cheatsheet_pass +DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@localhost:5432/cheatsheet_db diff --git a/backend/Dockerfile b/backend/Dockerfile index 603f00f..d371cc6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -35,4 +35,4 @@ COPY . . EXPOSE 8000 -CMD ["sh", "-c", "until pg_isready -h db -U cheatsheet_user; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] +CMD ["sh", "-c", "export PGPASSWORD=${POSTGRES_PASSWORD:-cheatsheet_pass}; until pg_isready -h ${POSTGRES_HOST:-db} -p ${POSTGRES_PORT:-5432} -U ${POSTGRES_USER:-cheatsheet_user} -d ${POSTGRES_DB:-cheatsheet_db}; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] diff --git a/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py b/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py new file mode 100644 index 0000000..600bf5c --- /dev/null +++ b/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.4 on 2026-04-20 04:00 + +from django.db import migrations, models + + +def backfill_legacy_source_format(apps, schema_editor): + PracticeProblem = apps.get_model('api', 'PracticeProblem') + PracticeProblem.objects.filter( + source_text='', + compiled_latex='', + ).exclude( + question_latex='', + answer_latex='', + ).update(source_format='latex_legacy') + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_cheatsheet_selected_formulas'), + ] + + operations = [ + migrations.AddField( + model_name='practiceproblem', + name='compiled_latex', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='practiceproblem', + name='label', + field=models.CharField(blank=True, default='', max_length=120), + ), + migrations.AddField( + model_name='practiceproblem', + name='source_format', + field=models.CharField(default='simple_v1', max_length=20), + ), + migrations.AddField( + model_name='practiceproblem', + name='source_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='practiceproblem', + name='question_latex', + field=models.TextField(blank=True, default=''), + ), + migrations.RunPython(backfill_legacy_source_format, migrations.RunPython.noop), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 500dcf2..4d92403 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -39,12 +39,10 @@ def _build_practice_problems_section(self): section_lines = ["\\section*{Practice Problems}"] for problem in problems: - section_lines.append( - f"\\textbf{{Problem {problem.order}:}} {problem.question_latex}" - ) - if problem.answer_latex: - section_lines.append(f"\\textbf{{Answer:}} {problem.answer_latex}") - section_lines.append("") + rendered_latex = problem.get_rendered_latex() + if rendered_latex: + section_lines.append(rendered_latex) + section_lines.append("") return "\n".join(section_lines).rstrip() @@ -132,7 +130,11 @@ class PracticeProblem(models.Model): cheat_sheet = models.ForeignKey( CheatSheet, on_delete=models.CASCADE, related_name="problems" ) - question_latex = models.TextField() + label = models.CharField(max_length=120, blank=True, default="") + source_format = models.CharField(max_length=20, default="simple_v1") + source_text = models.TextField(blank=True, default="") + compiled_latex = models.TextField(blank=True, default="") + question_latex = models.TextField(blank=True, default="") answer_latex = models.TextField(blank=True, default="") order = models.IntegerField(default=0) @@ -141,3 +143,12 @@ class Meta: def __str__(self): return f"Problem {self.order} - {self.cheat_sheet.title}" + + def get_rendered_latex(self): + if self.compiled_latex: + return self.compiled_latex.strip() + + lines = [f"\\textbf{{Problem {self.order}:}} {self.question_latex}"] + if self.answer_latex: + lines.append(f"\\textbf{{Answer:}} {self.answer_latex}") + return "\n".join(lines).rstrip() diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 7d8b0f5..21a357f 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,6 +1,7 @@ -# DRF serializers for the backend API will be added here. from rest_framework import serializers + from .models import Template, CheatSheet, PracticeProblem +from .services.practice_problem_compiler import compile_source class TemplateSerializer(serializers.ModelSerializer): @@ -26,11 +27,91 @@ class Meta: fields = [ "id", "cheat_sheet", + "label", + "source_format", + "source_text", + "compiled_latex", "question_latex", "answer_latex", "order", ] - read_only_fields = ["id"] + read_only_fields = ["id", "compiled_latex"] + extra_kwargs = { + "question_latex": {"required": False, "allow_blank": True}, + "answer_latex": {"required": False, "allow_blank": True}, + "source_text": {"required": False, "allow_blank": True}, + "label": {"required": False, "allow_blank": True}, + "source_format": {"required": False}, + } + + def validate(self, attrs): + attrs = super().validate(attrs) + + instance = getattr(self, "instance", None) + submitted_legacy_fields = any( + field in attrs for field in ["question_latex", "answer_latex"] + ) + label = attrs.get("label", getattr(instance, "label", "")) + source_format = attrs.get( + "source_format", + getattr(instance, "source_format", "simple_v1"), + ) + source_text = attrs.get("source_text", getattr(instance, "source_text", "")) + + if source_format not in {"simple_v1", "latex_legacy"}: + raise serializers.ValidationError( + {"source_format": ["Unsupported source format. Use 'simple_v1' or 'latex_legacy'."]} + ) + + if submitted_legacy_fields: + raise serializers.ValidationError( + { + "question_latex": [ + "Direct LaTeX problem fields are read-only. Use source_text with simple_v1 instead." + ] + } + ) + + if source_text: + if source_format != "simple_v1": + raise serializers.ValidationError( + {"source_format": ["source_text currently supports only 'simple_v1'."]} + ) + + result = compile_source(source_text, label=label) + if not result.is_valid: + raise serializers.ValidationError( + { + "source_text": [ + f"Line {error.line}: {error.message}" for error in result.errors + ] + } + ) + + attrs["source_format"] = "simple_v1" + attrs["compiled_latex"] = result.compiled_latex + return attrs + + if source_format == "simple_v1": + raise serializers.ValidationError( + {"source_text": ["Provide source_text for simple_v1 problems."]} + ) + + return attrs + + +class PracticeProblemPreviewSerializer(serializers.Serializer): + label = serializers.CharField(required=False, allow_blank=True, default="") + source_format = serializers.CharField(required=False, default="simple_v1") + source_text = serializers.CharField() + + def validate(self, attrs): + if attrs.get("source_format") != "simple_v1": + raise serializers.ValidationError( + {"source_format": ["Preview currently supports only 'simple_v1'."]} + ) + + return attrs class CheatSheetSerializer(serializers.ModelSerializer): @@ -66,9 +147,9 @@ class CompileRequestSerializer(serializers.Serializer): content = serializers.CharField(required=False, default="") cheat_sheet_id = serializers.IntegerField(required=False, default=None) - def validate(self, data): - if not data.get("content") and not data.get("cheat_sheet_id"): + def validate(self, attrs): + if not attrs.get("content") and not attrs.get("cheat_sheet_id"): raise serializers.ValidationError( "Provide either 'content' or 'cheat_sheet_id'." ) - return data + return attrs diff --git a/backend/api/services/__init__.py b/backend/api/services/__init__.py new file mode 100644 index 0000000..916f1a0 --- /dev/null +++ b/backend/api/services/__init__.py @@ -0,0 +1,5 @@ +from .practice_problem_compiler import CompilationResult as CompilationResult +from .practice_problem_compiler import CompilerError as CompilerError +from .practice_problem_compiler import compile_source as compile_source + +__all__ = ["CompilationResult", "CompilerError", "compile_source"] diff --git a/backend/api/services/practice_problem_compiler.py b/backend/api/services/practice_problem_compiler.py new file mode 100644 index 0000000..0eb0a10 --- /dev/null +++ b/backend/api/services/practice_problem_compiler.py @@ -0,0 +1,370 @@ +from dataclasses import dataclass, field +import re + + +ALLOWED_TOP_LEVEL_KEYS = {"problem", "steps"} +ALLOWED_CHILD_KEYS = {"text", "math"} +ALLOWED_MATH_CHARACTERS = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-*/^=().,!<>_[]" +) + + +@dataclass +class CompilerError: + line: int + column: int + message: str + + +@dataclass +class ProblemItem: + kind: str + value: str + line: int + + +@dataclass +class ParsedProblemBlock: + problem_items: list[ProblemItem] = field(default_factory=list) + step_items: list[ProblemItem] = field(default_factory=list) + + +@dataclass +class CompilationResult: + compiled_latex: str = "" + errors: list[CompilerError] = field(default_factory=list) + + @property + def is_valid(self): + return not self.errors + + +def compile_source(source_text: str, label: str = "") -> CompilationResult: + parsed_block, errors = parse_source(source_text) + if errors: + return CompilationResult(errors=errors) + + compiled_lines = [] + escaped_label = _escape_latex_text(label) + compilation_errors = [] + + if escaped_label: + compiled_lines.append(f"\\subsection*{{{escaped_label}}}") + + compiled_lines.append("\\textbf{Problem.}") + compiled_lines.append("") + + for item in parsed_block.problem_items: + try: + compiled_lines.extend(_compile_problem_item(item)) + except ValueError as error: + compilation_errors.append( + CompilerError(line=item.line, column=1, message=str(error)) + ) + + compiled_lines.append("") + compiled_lines.append("\\textbf{Steps.}") + compiled_lines.append("\\begin{enumerate}") + for item in parsed_block.step_items: + try: + compiled_lines.append(f" \\item {_compile_step_item(item)}") + except ValueError as error: + compilation_errors.append( + CompilerError(line=item.line, column=1, message=str(error)) + ) + compiled_lines.append("\\end{enumerate}") + + if compilation_errors: + return CompilationResult(errors=compilation_errors) + + return CompilationResult(compiled_latex="\n".join(compiled_lines).strip()) + + +def parse_source(source_text: str) -> tuple[ParsedProblemBlock, list[CompilerError]]: + parsed = ParsedProblemBlock() + errors = [] + + normalized_source = (source_text or "").replace("\r\n", "\n").replace("\r", "\n") + if not normalized_source.strip(): + return parsed, [CompilerError(line=1, column=1, message="Source text cannot be empty.")] + + lines = normalized_source.split("\n") + indent_style = _detect_indent_style(lines) + if indent_style == "mixed": + return parsed, [ + CompilerError( + line=_find_first_mixed_indent_line(lines), + column=1, + message="Mixed tabs and spaces are not allowed in the same block.", + ) + ] + + current_section = None + child_indent = {} + + for line_number, raw_line in enumerate(lines, start=1): + if not raw_line.strip(): + continue + + indent = _leading_whitespace(raw_line) + stripped_line = raw_line.lstrip(" \t") + + if not indent: + if not stripped_line.endswith(":"): + errors.append( + CompilerError( + line=line_number, + column=len(raw_line) - len(stripped_line) + 1, + message="Top-level lines must end with ':' and declare 'problem:' or 'steps:'.", + ) + ) + continue + + section_name = stripped_line[:-1].strip() + if section_name not in ALLOWED_TOP_LEVEL_KEYS: + errors.append( + CompilerError( + line=line_number, + column=1, + message=f"Unknown top-level key '{section_name}'. Expected 'problem:' or 'steps:'.", + ) + ) + current_section = None + continue + + if child_indent.get(section_name) is not None: + errors.append( + CompilerError( + line=line_number, + column=1, + message=f"Duplicate top-level section '{section_name}:'.", + ) + ) + current_section = None + continue + + current_section = section_name + child_indent[section_name] = "" + continue + + if current_section is None: + errors.append( + CompilerError( + line=line_number, + column=1, + message="Indented lines must appear under 'problem:' or 'steps:'.", + ) + ) + continue + + if not child_indent[current_section]: + child_indent[current_section] = indent + elif indent != child_indent[current_section]: + errors.append( + CompilerError( + line=line_number, + column=1, + message=f"All lines inside '{current_section}:' must use the same indentation level.", + ) + ) + continue + + if ":" not in stripped_line: + errors.append( + CompilerError( + line=line_number, + column=len(indent) + 1, + message="Child lines must start with 'text:' or 'math:'.", + ) + ) + continue + + item_kind, item_value = stripped_line.split(":", 1) + item_kind = item_kind.strip() + item_value = item_value.strip() + + if item_kind not in ALLOWED_CHILD_KEYS: + errors.append( + CompilerError( + line=line_number, + column=len(indent) + 1, + message=( + f"Unknown key '{item_kind}'. Expected 'text:' or 'math:' inside {current_section}." + ), + ) + ) + continue + + if not item_value: + errors.append( + CompilerError( + line=line_number, + column=len(indent) + len(item_kind) + 2, + message=f"{item_kind.title()} lines cannot be empty.", + ) + ) + continue + + target_items = parsed.problem_items if current_section == "problem" else parsed.step_items + target_items.append(ProblemItem(kind=item_kind, value=item_value, line=line_number)) + + if errors: + return parsed, errors + + if not parsed.problem_items: + errors.append( + CompilerError( + line=1, + column=1, + message="A 'problem:' section with at least one child line is required.", + ) + ) + + if not parsed.step_items: + errors.append( + CompilerError( + line=1, + column=1, + message="A 'steps:' section with at least one child line is required.", + ) + ) + + return parsed, errors + + +def _compile_problem_item(item: ProblemItem) -> list[str]: + if item.kind == "math": + return ["\\[", compile_math_expression(item.value), "\\]", ""] + + return [_escape_latex_text(item.value), ""] + + +def _compile_step_item(item: ProblemItem) -> str: + if item.kind == "math": + return f"\\({compile_math_expression(item.value)}\\)" + + return _escape_latex_text(item.value) + + +def compile_math_expression(expression: str) -> str: + value = (expression or "").strip() + if not value: + raise ValueError("Math expressions cannot be empty.") + + invalid_characters = sorted({character for character in value if character not in ALLOWED_MATH_CHARACTERS}) + if invalid_characters: + invalid_display = "".join(invalid_characters) + raise ValueError(f"Unsupported character(s) in math expression: {invalid_display}") + + compiled = _replace_sqrt_calls(value) + compiled = compiled.replace("<=", r" \le ") + compiled = compiled.replace(">=", r" \ge ") + compiled = compiled.replace("!=", r" \ne ") + compiled = re.sub(r"(?!=])=(?!=)", " = ", compiled) + compiled = re.sub(r"\s*\*\s*", r" \\cdot ", compiled) + compiled = re.sub(r"\s+", " ", compiled).strip() + return compiled + + +def _replace_sqrt_calls(value: str) -> str: + result = [] + index = 0 + + while index < len(value): + if value.startswith("sqrt(", index): + expression, next_index = _extract_parenthesized_expression(value, index + 4) + result.append(rf"\sqrt{{{compile_math_expression(expression)}}}") + index = next_index + continue + + result.append(value[index]) + index += 1 + + return "".join(result) + + +def _extract_parenthesized_expression(value: str, start_index: int) -> tuple[str, int]: + if start_index >= len(value) or value[start_index] != "(": + raise ValueError("sqrt must be followed by parentheses, like sqrt(x + 1).") + + depth = 1 + index = start_index + 1 + collected = [] + + while index < len(value): + character = value[index] + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0: + return "".join(collected).strip(), index + 1 + + if depth > 0: + collected.append(character) + index += 1 + + raise ValueError("Unclosed parentheses in sqrt expression.") + + +def _escape_latex_text(value: str) -> str: + escaped = value.replace("\\", r"\textbackslash{}") + replacements = { + "&": r"\&", + "%": r"\%", + "$": r"\$", + "#": r"\#", + "_": r"\_", + "{": r"\{", + "}": r"\}", + "~": r"\textasciitilde{}", + "^": r"\textasciicircum{}", + } + for original, replacement in replacements.items(): + escaped = escaped.replace(original, replacement) + return escaped + + +def _detect_indent_style(lines: list[str]) -> str: + saw_tabs = False + saw_spaces = False + + for line in lines: + indent = _leading_whitespace(line) + if not indent: + continue + + if "\t" in indent: + saw_tabs = True + if " " in indent: + saw_spaces = True + + if saw_tabs and saw_spaces: + return "mixed" + if saw_tabs: + return "tabs" + return "spaces" + + +def _find_first_mixed_indent_line(lines: list[str]) -> int: + saw_tabs = False + saw_spaces = False + + for line_number, line in enumerate(lines, start=1): + indent = _leading_whitespace(line) + if not indent: + continue + + if "\t" in indent: + saw_tabs = True + if " " in indent: + saw_spaces = True + + if saw_tabs and saw_spaces: + return line_number + + return 1 + + +def _leading_whitespace(value: str) -> str: + return value[: len(value) - len(value.lstrip(" \t"))] diff --git a/backend/api/test_practice_problem_compiler.py b/backend/api/test_practice_problem_compiler.py new file mode 100644 index 0000000..969a887 --- /dev/null +++ b/backend/api/test_practice_problem_compiler.py @@ -0,0 +1,90 @@ +from api.services.practice_problem_compiler import compile_source + + +VALID_SOURCE = """problem: + text: Solve for x + math: x^2 - 5x + 6 = 0 + +steps: + text: Factor the trinomial + math: x^2 - 5x + 6 = (x - 2)(x - 3) + text: Therefore x = 2 or x = 3 +""" + + +def test_compile_source_builds_latex_from_simple_v1_block(): + result = compile_source(VALID_SOURCE, label="Quadratic factoring") + + assert result.is_valid + assert "\\subsection*{Quadratic factoring}" in result.compiled_latex + assert "\\textbf{Problem.}" in result.compiled_latex + assert "\\textbf{Steps.}" in result.compiled_latex + assert "\\[" in result.compiled_latex + assert "Therefore x = 2 or x = 3" in result.compiled_latex + assert "\\begin{enumerate}" in result.compiled_latex + + +def test_compile_source_treats_text_lines_as_literal_text(): + result = compile_source( + """problem: + text: Area of a circle + +steps: + text: A factor is x - 2 +""" + ) + + assert result.is_valid + assert "Area of a circle" in result.compiled_latex + assert "A factor is x - 2" in result.compiled_latex + assert "\\(" not in result.compiled_latex + + +def test_compile_source_rejects_unknown_top_level_key(): + result = compile_source( + """problem: + text: Solve for x + +answer: + math: x = 2 +""" + ) + + assert not result.is_valid + assert result.errors[0].line == 4 + assert "Unknown top-level key 'answer'" in result.errors[0].message + + +def test_compile_source_rejects_mixed_indentation(): + result = compile_source( + "problem:\n\ttext: Solve for x\n math: x^2 - 5x + 6 = 0\n\nsteps:\n\tmath: x = 2" + ) + + assert not result.is_valid + assert result.errors[0].message == "Mixed tabs and spaces are not allowed in the same block." + + +def test_compile_source_requires_problem_and_steps_sections(): + result = compile_source( + """problem: + text: Solve for x +""" + ) + + assert not result.is_valid + assert any("'steps:' section" in error.message for error in result.errors) + + +def test_compile_source_rejects_unsupported_math_characters(): + result = compile_source( + """problem: + math: x + y + +steps: + math: x @ y +""" + ) + + assert not result.is_valid + assert result.errors[0].line == 5 + assert "Unsupported character(s) in math expression: @" in result.errors[0].message diff --git a/backend/api/test_practice_problem_rendering.py b/backend/api/test_practice_problem_rendering.py new file mode 100644 index 0000000..8c2c452 --- /dev/null +++ b/backend/api/test_practice_problem_rendering.py @@ -0,0 +1,26 @@ +import pytest + +from api.models import CheatSheet, PracticeProblem + + +@pytest.mark.django_db +def test_build_full_latex_prefers_compiled_problem_blocks(): + sheet = CheatSheet.objects.create( + title="With Compiled Blocks", + latex_content="Content", + ) + PracticeProblem.objects.create( + cheat_sheet=sheet, + label="Quadratic factoring", + source_format="simple_v1", + source_text="problem:\n text: Solve for x\n math: x^2 - 5x + 6 = 0\n\nsteps:\n math: x = 2", + compiled_latex="\\subsection*{Quadratic factoring}\nCompiled block content", + question_latex="Legacy question", + answer_latex="Legacy answer", + order=1, + ) + + full = sheet.build_full_latex() + + assert "Compiled block content" in full + assert "Legacy question" not in full diff --git a/backend/api/tests.py b/backend/api/tests.py index ecaad92..d9f4eab 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -42,6 +42,7 @@ def sample_sheet(db, sample_template): def sample_problem(db, sample_sheet): return PracticeProblem.objects.create( cheat_sheet=sample_sheet, + source_format="latex_legacy", question_latex="What is $2 + 2$?", answer_latex="$4$", order=1, @@ -274,7 +275,7 @@ def test_create_from_template_missing_id(self, api_client): @pytest.mark.django_db class TestPracticeProblemAPI: - def test_create_problem(self, api_client, sample_sheet): + def test_create_problem_rejects_legacy_latex_fields(self, api_client, sample_sheet): resp = api_client.post( "/api/problems/", { @@ -285,13 +286,129 @@ def test_create_problem(self, api_client, sample_sheet): }, format="json", ) + assert resp.status_code == 400 + assert "question_latex" in resp.json() + + def test_update_problem_rejects_legacy_latex_fields(self, api_client, sample_problem): + resp = api_client.patch( + f"/api/problems/{sample_problem.id}/", + {"question_latex": "Changed raw latex"}, + format="json", + ) + + assert resp.status_code == 400 + assert "question_latex" in resp.json() + + def test_create_simple_v1_problem_compiles_source(self, api_client, sample_sheet): + resp = api_client.post( + "/api/problems/", + { + "cheat_sheet": sample_sheet.id, + "label": "Quadratic factoring", + "source_format": "simple_v1", + "source_text": ( + "problem:\n" + " text: Solve for x\n" + " math: x^2 - 5x + 6 = 0\n\n" + "steps:\n" + " text: Factor the trinomial\n" + " math: x^2 - 5x + 6 = (x - 2)(x - 3)" + ), + "order": 1, + }, + format="json", + ) + assert resp.status_code == 201 + data = resp.json() + assert data["source_format"] == "simple_v1" + assert "\\subsection*{Quadratic factoring}" in data["compiled_latex"] + assert data["question_latex"] == "" + assert data["answer_latex"] == "" + + def test_create_simple_v1_problem_returns_line_aware_validation_errors(self, api_client, sample_sheet): + resp = api_client.post( + "/api/problems/", + { + "cheat_sheet": sample_sheet.id, + "label": "Broken block", + "source_format": "simple_v1", + "source_text": "problem:\n text: Solve for x\n\nanswer:\n math: x = 2", + "order": 1, + }, + format="json", + ) + + assert resp.status_code == 400 + assert "source_text" in resp.json() + assert "Line 4" in resp.json()["source_text"][0] + + def test_preview_simple_v1_problem_compiles_source(self, api_client): + resp = api_client.post( + "/api/problems/preview/", + { + "label": "Quadratic factoring", + "source_format": "simple_v1", + "source_text": ( + "problem:\n" + " text: Solve for x\n" + " math: x^2 - 5x + 6 = 0\n\n" + "steps:\n" + " text: Factor the trinomial\n" + " math: x^2 - 5x + 6 = (x - 2)(x - 3)" + ), + }, + format="json", + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["is_valid"] is True + assert data["errors"] == [] + assert "\\subsection*{Quadratic factoring}" in data["compiled_latex"] + + def test_preview_simple_v1_problem_returns_line_aware_errors(self, api_client): + resp = api_client.post( + "/api/problems/preview/", + { + "label": "Broken block", + "source_format": "simple_v1", + "source_text": "problem:\n text: Solve for x\n\nanswer:\n math: x = 2", + }, + format="json", + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["is_valid"] is False + assert data["compiled_latex"] == "" + assert data["errors"][0]["line"] == 4 + assert "Unknown top-level key 'answer'" in data["errors"][0]["message"] + + def test_preview_rejects_unsupported_source_format(self, api_client): + resp = api_client.post( + "/api/problems/preview/", + { + "source_format": "latex_legacy", + "source_text": "problem:\n text: Preview me", + }, + format="json", + ) + + assert resp.status_code == 400 + assert "source_format" in resp.json() def test_filter_problems_by_sheet(self, api_client, sample_problem, sample_sheet): resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}") assert resp.status_code == 200 assert len(resp.json()) >= 1 + def test_list_problem_preserves_legacy_source_format(self, api_client, sample_problem, sample_sheet): + resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}") + + assert resp.status_code == 200 + assert resp.json()[0]["source_format"] == "latex_legacy" + @pytest.mark.django_db class TestGenerateSheetEndpoint: diff --git a/backend/api/views.py b/backend/api/views.py index 23bd3be..8d5b6ee 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,14 +1,24 @@ -from rest_framework.decorators import api_view, action -from rest_framework.response import Response -from rest_framework import status, viewsets -from django.http import FileResponse -from django.shortcuts import get_object_or_404 +from typing import cast + +import os import subprocess import tempfile -import os + +from django.http import FileResponse +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import api_view, action +from rest_framework import status, viewsets +from rest_framework.response import Response + +from api.services.practice_problem_compiler import compile_source from .models import Template, CheatSheet, PracticeProblem -from .serializers import TemplateSerializer, CheatSheetSerializer, PracticeProblemSerializer +from .serializers import ( + TemplateSerializer, + CheatSheetSerializer, + PracticeProblemSerializer, + PracticeProblemPreviewSerializer, +) from .formula_data import get_formula_data, get_classes_with_details, get_special_class_formula, is_special_class from .latex_utils import build_latex_for_formulas, LATEX_HEADER, LATEX_FOOTER @@ -237,3 +247,30 @@ def get_queryset(self): if cheat_sheet_id: queryset = queryset.filter(cheat_sheet=cheat_sheet_id) return queryset + + @action(detail=False, methods=['post'], url_path='preview') + def preview(self, request): + serializer = PracticeProblemPreviewSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated_data = cast(dict[str, str], serializer.validated_data) + label = validated_data.get("label", "") + source_format = validated_data["source_format"] + source_text = validated_data["source_text"] + result = compile_source(source_text, label=label) + + return Response( + { + "source_format": source_format, + "compiled_latex": result.compiled_latex, + "errors": [ + { + "line": error.line, + "column": error.column, + "message": error.message, + } + for error in result.errors + ], + "is_valid": result.is_valid, + } + ) diff --git a/backend/cheat_sheet/settings.py b/backend/cheat_sheet/settings.py index 2e90c30..1471d9e 100644 --- a/backend/cheat_sheet/settings.py +++ b/backend/cheat_sheet/settings.py @@ -1,16 +1,23 @@ -""" -Django settings for cheat_sheet project. -""" +"""Django settings for cheat_sheet project.""" -from pathlib import Path import os -from dotenv import load_dotenv -from django.core.exceptions import ImproperlyConfigured +from pathlib import Path + import dj_database_url +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / ".env") +DEFAULT_DATABASE_URL = ( + f"postgresql://{os.getenv('POSTGRES_USER', 'cheatsheet_user')}:" + f"{os.getenv('POSTGRES_PASSWORD', 'cheatsheet_pass')}@" + f"{os.getenv('POSTGRES_HOST', 'localhost')}:" + f"{os.getenv('POSTGRES_PORT', '5432')}/" + f"{os.getenv('POSTGRES_DB', 'cheatsheet_db')}" +) + DEBUG = os.getenv("DJANGO_DEBUG", "True") == "True" SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") @@ -80,10 +87,10 @@ WSGI_APPLICATION = "cheat_sheet.wsgi.application" -# Database — uses DATABASE_URL env var, falls back to SQLite for local dev +# Database — uses DATABASE_URL when provided and defaults to local PostgreSQL DATABASES = { "default": dj_database_url.config( - default="sqlite:///" + str(BASE_DIR / "db.sqlite3"), + default=DEFAULT_DATABASE_URL, conn_max_age=600, ) } diff --git a/docker-compose.yml b/docker-compose.yml index f6725ff..7c8482d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,26 +2,28 @@ services: db: image: postgres:15 restart: unless-stopped - environment: - POSTGRES_DB: cheatsheet_db - POSTGRES_USER: cheatsheet_user - POSTGRES_PASSWORD: cheatsheet_pass - # ports: comment out for now we'e not using atm - # - "5432:5432" + env_file: + - ./backend/.env.docker + ports: + - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 10 backend: build: ./backend ports: - "8000:8000" env_file: - ./backend/.env.docker - environment: - - DATABASE_URL=postgres://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db volumes: - ./backend:/app depends_on: - - db + db: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/health/"] interval: 10s diff --git a/docs/practice-problem-compiler-mvp.md b/docs/practice-problem-compiler-mvp.md new file mode 100644 index 0000000..64a3bc9 --- /dev/null +++ b/docs/practice-problem-compiler-mvp.md @@ -0,0 +1,377 @@ +# Practice Problem Compiler MVP + +## Goal + +Let users author practice problems in a simple indented syntax, keep the original source, compile it into pure LaTeX, and reorder named problem blocks in the UI. + +## Product shape + +- One draggable **block** in the UI maps to one `PracticeProblem` record. +- Each block has: + - a user-facing label/name + - source text in a simple syntax + - compiled LaTeX +- The cheat sheet document still assembles a final PDF by stitching together stored LaTeX blocks. + +## MVP decisions + +- **Block unit:** reuse `PracticeProblem` as the block model. +- **Compiler format name:** `simple_v1` +- **Storage strategy:** compile on create/update and store both source and compiled LaTeX. +- **UI strategy:** add a block list with drag reorder, label input, and source editor. +- **Legacy compatibility:** existing `question_latex` / `answer_latex` rows still render until migrated. + +## Non-goals + +- Full math parser +- Arbitrary raw LaTeX passthrough in `simple_v1` +- Nested block trees +- Substeps, hints, grading metadata, or answer checking +- Multi-block source files inside a single textarea + +## User workflow + +1. User creates a practice-problem block. +2. User enters a block label, such as `Quadratic factoring`. +3. User writes source text in the simple syntax. +4. Frontend can request a live preview while the user edits. +5. Frontend saves the block through `/api/problems/`. +6. Backend validates and compiles the block into LaTeX. +7. Drag reorder changes block `order`. +8. Cheat sheet PDF uses compiled block LaTeX in that saved order. + +## Data model changes + +Extend `backend/api/models.py` `PracticeProblem` with: + +- `label = models.CharField(max_length=120, blank=True, default="")` +- `source_format = models.CharField(max_length=20, default="simple_v1")` +- `source_text = models.TextField(blank=True, default="")` +- `compiled_latex = models.TextField(blank=True, default="")` + +Keep existing fields during MVP rollout: + +- `question_latex` +- `answer_latex` +- `order` +- `cheat_sheet` + +### Compatibility rule + +- New compiler-backed blocks use `label`, `source_format`, `source_text`, and `compiled_latex`. +- Legacy rows without compiler data continue rendering from `question_latex` / `answer_latex`. + +## Compiler boundary + +Add a pure service module: + +- `backend/api/services/practice_problem_compiler.py` + +Recommended functions: + +```python +def compile_source(source_text: str, label: str = "") -> CompilationResult: + ... + +def parse_source(source_text: str) -> ParsedProblemBlock: + ... + +def compile_math_expression(expression: str) -> str: + ... +``` + +`CompilationResult` should include: + +- `compiled_latex: str` +- `errors: list[CompilerError]` + +`CompilerError` should include: + +- `line` +- `column` +- `message` + +### Why this boundary + +- Keeps parser/compiler logic out of models and views +- Makes unit testing cheap +- Lets serializers return line-aware validation errors +- Keeps `CheatSheet.build_full_latex()` focused on document assembly only + +## Serializer behavior + +`PracticeProblemSerializer` should: + +- accept `label`, `source_format`, `source_text`, `order`, `cheat_sheet` +- validate `source_text` when `source_format == "simple_v1"` +- call the compiler service during create/update +- store `compiled_latex` +- expose read-only `compiled_latex` + +The original MVP did not require a separate parse-preview endpoint. + +The current implementation now includes `POST /api/problems/preview/` so the frontend can: + +- run backend-authoritative validation while the user edits +- render a compiled block preview before save +- reuse the same line-aware compiler errors shown during create/update + +## Rendering behavior + +`CheatSheet._build_practice_problems_section()` should render blocks in this order: + +1. `compiled_latex` if present +2. legacy `question_latex` / `answer_latex` fallback + +This keeps final document generation deterministic and avoids recompiling every block during PDF generation. + +## Syntax overview + +### Top-level structure + +`simple_v1` supports exactly two top-level keys: + +- `problem:` +- `steps:` + +Indentation is significant. + +### Allowed child item types + +- `text:` +- `math:` + +### Valid example + +```txt +problem: + text: Solve for x + math: x^2 - 5x + 6 = 0 + +steps: + text: Factor the trinomial + math: x^2 - 5x + 6 = (x - 2)(x - 3) + math: x - 2 = 0 + math: x - 3 = 0 + text: Therefore x = 2 or x = 3 +``` + +Equivalent spaces-based indentation is also valid. + +### Invalid examples + +Unknown key: + +```txt +problem: + text: Solve for x +answer: + math: x = 2 +``` + +Bad indentation: + +```txt +problem: + text: Solve for x + math: x^2 - 5x + 6 = 0 +``` + +Missing top-level section: + +```txt +steps: + math: x = 2 +``` + +## Syntax rules + +### `problem:` + +- required +- must contain at least one child line +- may contain one or more `text:` and `math:` lines +- preserves author order + +### `steps:` + +- required for MVP +- must contain at least one child line +- each child becomes one displayed step +- may contain `text:` and `math:` lines + +### Indentation rules + +- tabs or spaces are allowed +- mixed indentation styles in the same block are invalid +- child lines must be indented deeper than their parent key + +## Math compilation rules + +`math:` lines accept calculator-style keyboard input and apply a narrow rewrite set. + +### Supported rewrites in MVP + +- `^` for superscripts +- `sqrt(x)` to `\sqrt{x}` +- `<=` to `\le` +- `>=` to `\ge` +- `!=` to `\ne` +- optional `*` to `\cdot` + +### Not supported in MVP + +- implicit multiplication normalization +- pretty fraction inference from `/` +- arbitrary function parsing +- symbolic simplification +- custom macros + +Unsupported constructs should return a readable validation error instead of guessing. + +## Example compiled output + +For label `Quadratic factoring`, this source: + +```txt +problem: + text: Solve for x + math: x^2 - 5x + 6 = 0 + +steps: + text: Factor the trinomial + math: x^2 - 5x + 6 = (x - 2)(x - 3) + math: x - 2 = 0 + math: x - 3 = 0 + text: Therefore x = 2 or x = 3 +``` + +compiles to: + +```latex +\subsection*{Quadratic factoring} +\textbf{Problem.} + +Solve for \(x\) + +\[ +x^2 - 5x + 6 = 0 +\] + +\textbf{Steps.} +\begin{enumerate} + \item Factor the trinomial + \item \(x^2 - 5x + 6 = (x - 2)(x - 3)\) + \item \(x - 2 = 0\) + \item \(x - 3 = 0\) + \item Therefore \(x = 2\) or \(x = 3\) +\end{enumerate} +``` + +## Frontend MVP + +Add a practice-problem block editor to `frontend/src/components/CreateCheatSheet.jsx` using the existing drag-and-drop pattern. + +Each block should support: + +- label input +- source textarea +- compile/save status +- drag handle +- delete button +- reorder persistence through `order` + +Suggested initial UX: + +- left side: list of problem blocks +- block card header: label + drag handle + remove action +- block body: syntax textarea +- optional future enhancement: compiled preview panel per block + +## API shape for MVP + +### Create/update payload + +```json +{ + "cheat_sheet": 1, + "label": "Quadratic factoring", + "source_format": "simple_v1", + "source_text": "problem:\n\ttext: Solve for x\n\tmath: x^2 - 5x + 6 = 0\n\nsteps:\n\ttext: Factor the trinomial\n\tmath: x^2 - 5x + 6 = (x - 2)(x - 3)", + "order": 1 +} +``` + +### Response fields + +```json +{ + "id": 10, + "cheat_sheet": 1, + "label": "Quadratic factoring", + "source_format": "simple_v1", + "source_text": "...", + "compiled_latex": "...", + "order": 1 +} +``` + +### Validation errors + +Should be line-aware when possible, for example: + +```json +{ + "source_text": [ + "Line 5: unknown key 'answer'. Expected 'text:' or 'math:' inside steps." + ] +} +``` + +## Testing plan + +### Compiler unit tests + +- valid `problem + steps` source compiles successfully +- unknown top-level key fails +- mixed indentation fails +- missing `problem:` fails +- missing `steps:` fails +- unsupported math token fails with readable error + +### Serializer/API tests + +- creating compiler-backed problem stores `compiled_latex` +- updating `source_text` recompiles and replaces stored LaTeX +- invalid source returns `400` with line-aware errors +- legacy rows still serialize and render + +### Integration tests + +- cheat sheet `full_latex` includes compiled block LaTeX +- compile endpoint still produces PDF with compiler-backed blocks +- ordering of blocks is preserved + +## Risks to avoid + +1. Turning MVP into a full programming language +2. Designing one giant source field containing many named blocks +3. Recompiling on every document build instead of storing stable output +4. Adding nested models for steps before the syntax proves it is needed +5. Using heuristics to guess whether a free-form line is text or math + +## Suggested implementation order + +1. Add model fields and migration +2. Add compiler service and pure unit tests +3. Update serializer and API tests +4. Update cheat sheet LaTeX assembly for compiler-backed blocks +5. Add frontend block editor and reorder UI +6. Verify full save → retrieve → compile flow + +## Open assumptions for MVP + +- Block label is a separate UI/API field, not part of the source grammar. +- Each block represents one problem with ordered steps. +- `steps:` is required in MVP even if later versions may relax that. +- `simple_v1` favors explicit `text:` / `math:` lines over heuristic parsing. diff --git a/frontend/src/App.css b/frontend/src/App.css index 96fd5b6..9f723d3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -692,6 +692,190 @@ label { padding: var(--space-md) 0; } +.practice-problem-section { + margin-top: var(--space-lg); + padding-top: var(--space-lg); + border-top: 1px solid var(--border); +} + +.practice-problem-section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.practice-problem-section-label { + display: block; + font-weight: 700; + margin-bottom: var(--space-xs); +} + +.practice-problem-section-copy { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; +} + +.practice-problem-section-status { + margin: var(--space-xs) 0 0 0; + color: var(--primary); + font-size: 0.8125rem; + font-weight: 600; +} + +.practice-problem-help { + margin-bottom: var(--space-md); + background: var(--box-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; +} + +.practice-problem-help summary { + cursor: pointer; + font-weight: 600; +} + +.practice-problem-help pre { + margin: var(--space-sm) 0 0 0; + white-space: pre-wrap; + color: var(--text-muted); + font-size: 0.8125rem; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} + +.practice-problem-empty { + padding: 1rem; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + color: var(--text-muted); + background: var(--box-bg); +} + +.practice-problem-list { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.practice-problem-card { + background: var(--box-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; +} + +.practice-problem-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.practice-problem-card-title { + display: flex; + align-items: flex-start; + gap: var(--space-sm); +} + +.practice-problem-card-subtitle { + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 0.125rem; +} + +.practice-problem-fields { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.practice-problem-field label { + display: block; + margin-bottom: var(--space-xs); +} + +.practice-problem-input { + margin-bottom: 0; +} + +.practice-problem-textarea { + width: 100%; + resize: vertical; + min-height: 180px; + padding: 0.875rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--input-border); + background: var(--input-bg); + color: var(--input-text); + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.875rem; + line-height: 1.55; +} + +.practice-problem-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.practice-problem-errors { + margin-top: var(--space-sm); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border: 1px solid rgba(248, 113, 113, 0.24); + background: var(--code-error-bg); + color: var(--code-error); + font-size: 0.8125rem; +} + +.practice-problem-preview-card { + margin-top: var(--space-md); + padding: 0.875rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: rgba(59, 130, 246, 0.05); +} + +.practice-problem-preview-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + font-size: 0.8125rem; + font-weight: 600; +} + +.practice-problem-preview-status { + color: var(--text-muted); + font-weight: 500; +} + +.practice-problem-preview-status.ready { + color: #22c55e; +} + +.practice-problem-preview-status.loading { + color: var(--primary); +} + +.practice-problem-preview-status.error { + color: var(--code-error); +} + +.practice-problem-preview { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-muted); + font-size: 0.8125rem; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} + /* ========================================================================== LAYOUT OPTIONS ========================================================================== */ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6061728..d2a3424 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ const DEFAULT_SHEET = { spacing: 'large', margins: '0.25in', selectedFormulas: [], + practiceProblems: [], }; function App() { @@ -59,17 +60,19 @@ function App() { } }, []); - const handleSave = async (data, showFeedback = true) => { + const handleSave = async (data, options = {}) => { + const { showFeedback = true, persistOnly = false } = options; const nextSheet = { ...cheatSheet, ...data, selectedFormulas: data.selectedFormulas ?? cheatSheet.selectedFormulas ?? [], + practiceProblems: data.practiceProblems ?? cheatSheet.practiceProblems ?? [], }; setCheatSheet(nextSheet); localStorage.setItem('currentCheatSheet', JSON.stringify(nextSheet)); - if (!showFeedback) { + if (persistOnly) { return nextSheet; } @@ -102,15 +105,20 @@ function App() { content: savedSheet.latex_content ?? nextSheet.content, fontSize: savedSheet.font_size ?? nextSheet.fontSize, selectedFormulas: savedSheet.selected_formulas ?? nextSheet.selectedFormulas, + practiceProblems: savedSheet.problems ?? nextSheet.practiceProblems, }; setCheatSheet(persistedSheet); localStorage.setItem('currentCheatSheet', JSON.stringify(persistedSheet)); - alert('Progress saved!'); + if (showFeedback) { + alert('Progress saved!'); + } return persistedSheet; } catch (error) { console.error('Failed to save cheat sheet', error); - alert(`Failed to save progress: ${error.message}`); + if (showFeedback) { + alert(`Failed to save progress: ${error.message}`); + } throw error; } finally { setIsSaving(false); diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index 87fb007..f1ff950 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -3,6 +3,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useFormulas } from '../hooks/formulas'; +import { usePracticeProblems } from '../hooks/practiceProblems'; import { useLatex } from '../hooks/latex'; import { Document, Page, pdfjs } from 'react-pdf'; @@ -313,6 +314,184 @@ const FormulaSelection = ({ ); +function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearPreview, disabled = false }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ + id: problem.clientId, + data: { type: 'problem' }, + }); + + useEffect(() => { + if (disabled) { + return undefined; + } + + if (!problem.sourceText.trim()) { + if (!problem.errors?.length) { + onClearPreview(problem.clientId); + } + return undefined; + } + + const timer = window.setTimeout(() => { + onPreview(problem.clientId); + }, 500); + + return () => { + window.clearTimeout(timer); + }; + }, [problem.clientId, problem.label, problem.sourceText, problem.errors?.length, onPreview, onClearPreview, disabled]); + + const style = { + transform: CSS.Transform.toString(transform), + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1000 : 'auto', + position: 'relative', + boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.15)' : 'none', + }; + + return ( +
+
+
+ ⋮⋮ +
+ {problem.label?.trim() || 'Untitled problem block'} +
Drag to set block order in the saved PDF
+
+
+ +
+ +
+
+ + onChange(problem.clientId, 'label', event.target.value)} + placeholder="Quadratic factoring" + className="input-field practice-problem-input" + disabled={disabled} + /> +
+ +
+ +