Skip to content

Commit 79c3c12

Browse files
committed
fix(ci): unbreak lint + typecheck, land split overrides and studio presets
CI's lint and typecheck jobs had been red since the TOC-aware chunking work, which blocked build and install-verification behind them. - ruff format across the 11 drifted files (anki, cli, doctor, parsers, run_state, notebooklm_py uploader and their tests) — formatting only. - anki: type _cards_from_json's `entries` as list[object] and narrow the dict lookup through a local, so iteration typechecks without a stale ignore. - cli: gate the inspect skip-range resolve on `config is not None` so mypy sees the narrowing, and carry heading level alongside each node in _build_section_tree instead of comparing `object` values out of the dict. Also lands the work in progress this fix sat on top of: manual chunk boundary overrides (--split-at / --no-split-at, forced split wins over no-split and over min/max page bounds) plus the desktop workflow presets (D1) and interactive quiz player (B6).
1 parent dd44b9f commit 79c3c12

20 files changed

Lines changed: 1724 additions & 122 deletions

desktop/renderer/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<link rel="stylesheet" href="styles.css">
1212
<link rel="stylesheet" href="logs.css">
1313
<link rel="stylesheet" href="artifact-preview.css">
14+
<link rel="stylesheet" href="presets.css">
1415
<script>
1516
// Apply the persisted (or OS-preferred) theme before first paint to avoid a flash.
1617
(function () {
@@ -255,6 +256,8 @@ <h3 class="font-bold text-slate-900">Studio Builder</h3>
255256
<p id="dashboard-selected-source-meta" class="text-sm text-slate-500">Select a source on the left, then add report, slide, quiz, flashcard, or audio jobs for it.</p>
256257
</div>
257258
</div>
259+
<!-- Workflow presets (roadmap D1): populated by presets.js renderPresetPicker(). -->
260+
<div id="studio-preset-picker" class="preset-picker"></div>
258261
<div id="studio-controls" class="grid grid-cols-1 gap-4">
259262
<label class="glass-panel rounded-3xl p-5 border border-slate-100 flex flex-col gap-3">
260263
<div class="flex items-start justify-between gap-4">

desktop/renderer/modules/artifacts.js

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { appState, summarizeStudioOutputs, studioIconNames } from "./state.js";
22
import { matchesQuery, showToast, escapeHtml, applyOfflineIcons } from "./dom.js";
33
import { prepareStudioView } from "./studio.js";
4+
import { launchQuizPlayer } from "./quizplayer.js";
45

56
function artifactKindToStudio(kind) {
67
const normalized = String(kind || "").toLowerCase();
@@ -346,14 +347,10 @@ function quizAnswerText(answer, options) {
346347
return text;
347348
}
348349

349-
function renderQuizArtifact(content) {
350-
let data;
351-
try {
352-
data = JSON.parse(content);
353-
} catch (error) {
354-
return renderRawArtifact(content);
355-
}
356-
const questions = Array.isArray(data)
350+
// Pull the array of question objects out of the many shapes quiz JSON can take
351+
// (bare array, or wrapped under .questions / .quiz / .items).
352+
function quizQuestionList(data) {
353+
return Array.isArray(data)
357354
? data
358355
: Array.isArray(data && data.questions)
359356
? data.questions
@@ -362,6 +359,85 @@ function renderQuizArtifact(content) {
362359
: Array.isArray(data && data.items)
363360
? data.items
364361
: [];
362+
}
363+
364+
// True/false detection for questions that carry no options list but a boolean
365+
// (or "true"/"false") answer — so the player can still present two choices.
366+
function quizBooleanAnswer(answer) {
367+
if (answer === true) return "true";
368+
if (answer === false) return "false";
369+
const values = Array.isArray(answer) ? answer : [answer];
370+
for (const candidate of values) {
371+
if (candidate === true) return "true";
372+
if (candidate === false) return "false";
373+
const text = String(candidate == null ? "" : candidate).trim().toLowerCase();
374+
if (text === "true" || text === "t" || text === "yes") return "true";
375+
if (text === "false" || text === "f" || text === "no") return "false";
376+
}
377+
return null;
378+
}
379+
380+
// Normalize one raw question object into the structure the player consumes.
381+
// All key-variant handling (question/prompt/text, options/choices/answers,
382+
// answer/correct/correctIndex/…) is reused from the previewer helpers so the two
383+
// stay in lock-step. Returns { text, options:[{text,correct}], correctIndex,
384+
// answerText, explanation }. options is [] for open-ended questions.
385+
function normalizeQuizQuestion(question) {
386+
const text = (question && (question.question ?? question.prompt ?? question.text ?? question.title)) ?? "";
387+
const rawOptions = (question && (question.options ?? question.choices ?? question.answers ?? question.alternatives)) ?? [];
388+
const options = Array.isArray(rawOptions) ? rawOptions : [];
389+
const answer = (question
390+
&& (question.answer ?? question.correct ?? question.correctAnswer ?? question.correct_answer
391+
?? question.correctOption ?? question.correct_option ?? question.solution
392+
?? question.correctIndex ?? question.correct_index)) ?? null;
393+
let normalizedOptions = options.map((option, index) => {
394+
const optionText = quizOptionText(option);
395+
const correct = quizOptionMarkedCorrect(option) || optionMatchesAnswer(optionText, index, answer);
396+
return { text: optionText, correct };
397+
});
398+
// Synthesize True/False choices for boolean questions with no options list.
399+
if (normalizedOptions.length === 0) {
400+
const boolean = quizBooleanAnswer(answer);
401+
const type = String((question && (question.type ?? question.questionType ?? question.kind)) ?? "").toLowerCase();
402+
if (boolean !== null || type.includes("true") || type.includes("bool")) {
403+
normalizedOptions = [
404+
{ text: "True", correct: boolean === "true" },
405+
{ text: "False", correct: boolean === "false" },
406+
];
407+
}
408+
}
409+
const correctIndex = normalizedOptions.findIndex((option) => option.correct);
410+
const answerText = quizAnswerText(answer, options);
411+
const explanation = question && (question.explanation ?? question.rationale ?? question.reason);
412+
return {
413+
text: String(text),
414+
options: normalizedOptions,
415+
correctIndex,
416+
answerText: answerText ? String(answerText) : "",
417+
explanation: explanation ? String(explanation) : "",
418+
};
419+
}
420+
421+
// Parse a quiz artifact's file content into normalized questions for the player.
422+
// Returns [] when the content is not parseable quiz JSON.
423+
function parseQuizQuestions(content) {
424+
let data;
425+
try {
426+
data = JSON.parse(content);
427+
} catch (error) {
428+
return [];
429+
}
430+
return quizQuestionList(data).map(normalizeQuizQuestion);
431+
}
432+
433+
function renderQuizArtifact(content) {
434+
let data;
435+
try {
436+
data = JSON.parse(content);
437+
} catch (error) {
438+
return renderRawArtifact(content);
439+
}
440+
const questions = quizQuestionList(data);
365441
if (questions.length === 0) return renderRawArtifact(content);
366442
const items = questions.map((question) => {
367443
const text = (question && (question.question ?? question.prompt ?? question.text ?? question.title)) ?? "";
@@ -633,6 +709,34 @@ async function openArtifactPreview(artifact) {
633709
}
634710
}
635711

712+
// Resolve the local quiz file for a quiz artifact, parse it, and hand the
713+
// normalized questions to the interactive player (quizplayer.js). Reuses the
714+
// same local-path resolution and JSON parsing as the previewer.
715+
async function openQuizPlayer(artifact) {
716+
if (!artifact) return;
717+
const studioName = artifactKindToStudio(artifact.kind);
718+
const local = resolveLocalArtifactPath(artifact, "quiz");
719+
if (!local || !local.path) {
720+
showToast("This quiz has not been downloaded locally yet.");
721+
return;
722+
}
723+
try {
724+
const result = await window.electronAPI.readFile(local.path);
725+
if (!result || !result.success) {
726+
showToast("The local quiz file could not be read.");
727+
return;
728+
}
729+
const questions = parseQuizQuestions(result.content);
730+
if (!questions.length) {
731+
showToast("No quiz questions could be read from this file.");
732+
return;
733+
}
734+
launchQuizPlayer(questions, artifact.title || "Quiz");
735+
} catch (error) {
736+
showToast("Something went wrong while loading this quiz.");
737+
}
738+
}
739+
636740
// --- Entry point: inject a preview button into each Studio artifact row ------
637741
// The rows themselves are rendered by studio.js (off-limits), so we observe the
638742
// list container and augment freshly rendered rows. Ordering mirrors
@@ -652,6 +756,23 @@ function buildArtifactPreviewButton(artifact) {
652756
return button;
653757
}
654758

759+
// A "Play" button shown only on quiz artifact rows — launches the interactive
760+
// player instead of the static preview.
761+
function buildQuizPlayButton(artifact) {
762+
const button = document.createElement("button");
763+
button.type = "button";
764+
button.className = "artifact-play-btn";
765+
button.title = "Play quiz";
766+
button.setAttribute("aria-label", "Play quiz");
767+
button.innerHTML = '<span class="material-symbols-outlined" data-icon-name="play_circle">play_circle</span>';
768+
button.addEventListener("click", (event) => {
769+
event.preventDefault();
770+
event.stopPropagation();
771+
void openQuizPlayer(artifact);
772+
});
773+
return button;
774+
}
775+
655776
function augmentStudioArtifactRows(list) {
656777
const artifacts = filteredStudioArtifacts();
657778
const rows = list.querySelectorAll(":scope > div");
@@ -673,6 +794,11 @@ function augmentStudioArtifactRows(list) {
673794
} else {
674795
row.appendChild(actions);
675796
}
797+
if (artifactKindToStudio(artifact.kind) === "quiz") {
798+
const playButton = buildQuizPlayButton(artifact);
799+
actions.appendChild(playButton);
800+
applyOfflineIcons(playButton);
801+
}
676802
const button = buildArtifactPreviewButton(artifact);
677803
actions.appendChild(button);
678804
applyOfflineIcons(button);
@@ -697,12 +823,15 @@ export {
697823
toggleStudioArtifactSelection,
698824
deleteSelectedStudioArtifacts,
699825
openArtifactPreview,
826+
openQuizPlayer,
827+
parseQuizQuestions,
700828
initArtifactPreview,
701829
};
702830

703831
// Self-register (app.js is off-limits; other modules use this pattern too).
704832
if (typeof window !== "undefined") {
705833
window.openArtifactPreview = openArtifactPreview;
834+
window.openQuizPlayer = openQuizPlayer;
706835
if (typeof document !== "undefined") {
707836
if (document.readyState === "loading") {
708837
document.addEventListener("DOMContentLoaded", initArtifactPreview, { once: true });

0 commit comments

Comments
 (0)