Skip to content

API Reference

This section contains automatically generated documentation from the qm2 source code.

Core Engine

Question Logic

cleanup_questions_cache()

Remove entries from cache for files that no longer exist.

Source code in src/qm2/core/questions.py
42
43
44
45
46
47
48
49
50
def cleanup_questions_cache() -> None:
    """Remove entries from cache for files that no longer exist."""
    global questions_cache
    to_remove = []
    for path in questions_cache:
        if not os.path.exists(path):
            to_remove.append(path)
    for path in to_remove:
        del questions_cache[path]

delete_question(category_file)

Select a question from the list and delete it from the file.

Source code in src/qm2/core/questions.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def delete_question(category_file: str) -> None:
    """Select a question from the list and delete it from the file."""
    questions = load_json(category_file)
    if not questions:
        console.print("[red]No questions to delete.[/red]")
        return

    # Added numbers
    choices = [f"{i+1}. {q.get('question', '<no text>')[:50]}..." for i, q in enumerate(questions)]

    # Without .ask, we can use our custom pagination function that returns the selected string
    selection = select_with_pagination(
        "Select a question to delete:", 
        choices=choices + ["↩ Back"]
    )

    # Handle user cancellation
    if not selection or selection == "↩ Back":
        return

    # Grab the index from the selected string (e.g., "3. What is...?") and convert to 0-based
    try:
        index = int(selection.split(".")[0])
        # _delete_question_core works with 1-based index, so we can pass it directly
        _delete_question_core(category_file, index)
    except (ValueError, IndexError):
        console.print("[red]⚠️ Error parsing selection.[/red]")

edit_question_by_index(questions, index_number)

Edit a question directly by its ordinal number (1-based).

Source code in src/qm2/core/questions.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
def edit_question_by_index(
    questions: list[dict[str, Any]], index_number: int | str
) -> None:
    """Edit a question directly by its ordinal number (1-based)."""
    if not questions:
        console.print("[yellow]⚠️ No questions available to edit.")
        return
    try:
        index = int(index_number) - 1
    except Exception:
        console.print("[yellow]⚠️ Invalid number.")
        return
    if index < 0 or index >= len(questions):
        console.print(f"[yellow]⚠️ Number out of range. Allowed 1-{len(questions)}.")
        return

    qdata = questions[index]
    qtype = qdata.get("type")
    new_question_text = Prompt.ask("New question", default=qdata.get("question", ""))

    if qtype == "multiple":
        new_correct = Prompt.ask("New correct answer", default=qdata.get("correct", ""))
        current_wrongs = qdata.get("wrong_answers", [])
        default_count = max(3, len(current_wrongs)) if current_wrongs else 3
        try:
            count = int(Prompt.ask("Number of incorrect answers", default=str(default_count)))
            count = max(1, min(10, count))
        except Exception:
            count = default_count
        new_wrongs = []
        for i in range(count):
            default_val = current_wrongs[i] if i < len(current_wrongs) else ""
            new_wrongs.append(Prompt.ask(f"New incorrect answer #{i + 1}", default=default_val))
        questions[index] = {
            "type": "multiple",
            "question": new_question_text,
            "correct": new_correct,
            "wrong_answers": new_wrongs,
        }

    elif qtype == "truefalse":
        new_correct = Prompt.ask(
            "New correct answer", default=qdata.get("correct", ""), choices=["True", "False"]
        )
        new_wrong = "False" if new_correct == "True" else "True"
        questions[index] = {
            "type": "truefalse",
            "question": new_question_text,
            "correct": new_correct,
            "wrong_answers": [new_wrong],
        }

    elif qtype == "fillin":
        new_correct = Prompt.ask("New correct answer", default=qdata.get("correct", ""))
        questions[index] = {
            "type": "fillin",
            "question": new_question_text,
            "correct": new_correct,
            "wrong_answers": [],
        }

    elif qtype == "match":
        pairs = qdata.get("pairs", {})
        left = pairs.get("left", [])
        right = pairs.get("right", [])
        answers = pairs.get("answers", {})

        console.print("\n[cyan]Current left column (items separated by |):")
        console.print(" | ".join(left))
        new_left = Prompt.ask("New left column", default="|".join(left)).split("|")

        console.print("\n[magenta]Current right column (items separated by |):")
        console.print(" | ".join(right))
        new_right = Prompt.ask("New right column", default="|".join(right)).split("|")

        console.print("\n[yellow]Current mapping (e.g., a:1, b:2)")
        current_pairs = ", ".join([f"{k}:{v}" for k, v in answers.items()])
        raw_new = Prompt.ask("New mapping", default=current_pairs)
        new_answers = {}
        for pair in raw_new.split(","):
            if ":" in pair:
                k, v = pair.strip().split(":")
                new_answers[k.strip()] = v.strip()

        questions[index] = {
            "type": "match",
            "question": new_question_text,
            "pairs": {
                "left": [x.strip() for x in new_left if x.strip()],
                "right": [x.strip() for x in new_right if x.strip()],
                "answers": new_answers,
            },
        }

    console.print("[green]✅ Question updated successfully.")

File Utilities

load_json(filename)

Load JSON from file. Returns empty list on any error. For detailed error info, use load_json_result().

Source code in src/qm2/utils/files.py
14
15
16
17
18
19
20
def load_json(filename: str | Path) -> list[Any] | dict[str, Any]:
    """
    Load JSON from file. Returns empty list on any error.
    For detailed error info, use load_json_result().
    """
    data, error = load_json_result(filename)
    return data

load_json_result(filename)

Load JSON from file. Returns (data, error_message). On success: (data, None) On error: ([], "descriptive error message")

Source code in src/qm2/utils/files.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def load_json_result(filename: str | Path) -> tuple[list[Any] | dict[str, Any], str | None]:
    """
    Load JSON from file. Returns (data, error_message).
    On success: (data, None)
    On error: ([], "descriptive error message")
    """
    path = str(filename)
    if not os.path.exists(path):
        return [], f"File not found: {path}"

    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        # Normalize None or non-list/dict to empty list for app compatibility
        if data is None or not isinstance(data, (list, dict)):
            return [], None
        return data, None
    except json.JSONDecodeError as e:
        msg = f"Invalid JSON in {path}: {e}"
        console.print(f"[red]⚠️ {msg}")
        return [], msg
    except UnicodeDecodeError as e:
        msg = f"Encoding error in {path}: {e}"
        console.print(f"[red]⚠️ {msg}")
        return [], msg
    except OSError as e:
        msg = f"Error reading {path}: {e}"
        console.print(f"[red]⚠️ {msg}")
        return [], msg
    except Exception as e:
        msg = f"Unexpected error reading {path}: {e}"
        console.print(f"[red]⚠️ {msg}")
        return [], msg