diff --git a/convert/convertBooks.ts b/convert/convertBooks.ts index 9b016a5f1..2e3528cf5 100644 --- a/convert/convertBooks.ts +++ b/convert/convertBooks.ts @@ -201,8 +201,9 @@ export async function convertBooks( 'quizzes', book.id + '.json' ), - content: JSON.stringify(convertQuizBook(context, book)) + content: JSON.stringify(convertQuizBook(context, book), null, 2) }); + process.stdout.write(` ${book.id}`); break; default: bookConverted = true; @@ -291,32 +292,34 @@ export async function convertBooks( }; } -type QuizAnswer = { +export type QuizExplanation = { + text?: string; + audio?: string; +}; + +export type QuizAnswer = { //\aw or \ar correct: boolean; text?: string; image?: string; audio?: string; - explanation?: { - //\ae - text: string; - audio?: string; - }; + explanation?: QuizExplanation; }; -type QuizQuestion = { +export type QuizQuestion = { //\qu text: string; image?: string; audio?: string; + columns?: number; //\ac + explanation?: QuizExplanation; answers: QuizAnswer[]; }; -type Quiz = { +export type Quiz = { id: string; //\id name?: string; //\qn shortName?: string; //\qs - columns?: number; //\ac rightAnswerAudio?: string[]; //\ra wrongAnswerAudio?: string[]; //\wa questions: QuizQuestion[]; @@ -343,9 +346,6 @@ function convertQuizBook(context: ConvertBookContext, book: Book): Quiz { id: quizSFM.match(/\\id ([^\\\r\n]+)/i)![1], name: quizSFM.match(/\\qn ([^\\\r\n]+)/i)?.at(1), shortName: quizSFM.match(/\\qs ([^\\\r\n]+)/i)?.at(1), - columns: quizSFM.match(/\\ac ([^\\\r\n]+)/i)?.at(1) - ? parseInt(quizSFM.match(/\\ac ([^\\\r\n]+)/i)![1]) - : undefined, rightAnswerAudio: quizSFM.match(/\\ra ([^\\\r\n]+)/gi)?.map((m) => { return m.match(/\\ra ([^\\\r\n]+)/i)![1]; }), @@ -359,7 +359,7 @@ function convertQuizBook(context: ConvertBookContext, book: Book): Quiz { const parsed = m.match(/([0-9]+)( *- *[0-9]+)? ([^\\\r\n]+)/i)!; return { rangeMin: parseInt(parsed[1]), - rangeMax: parsed[2] ? parseInt(parsed[2]) : undefined, + rangeMax: parsed[2] ? parseInt(parsed[2].replace('-', '')) : parseInt(parsed[1]), message: parsed[3] }; }), @@ -370,8 +370,8 @@ function convertQuizBook(context: ConvertBookContext, book: Book): Quiz { let aCount = 0; let question: QuizQuestion = { text: '', answers: [] }; let answer: QuizAnswer = { correct: false }; - quizSFM.match(/\\(qu|aw|ar|ae) ([^\\\r\n]+)/gi)?.forEach((m) => { - const parsed = m.match(/\\(qu|aw|ar|ae) ([^\\\r\n]+)/i)!; + quizSFM.match(/\\(qu|aw|ar|ae|ac) ([^\\\r\n]+)/gi)?.forEach((m) => { + const parsed = m.match(/\\(qu|aw|ar|ae|ac) ([^\\\r\n]+)/i)!; switch (parsed[1]) { case 'qu': if (aCount > 0) { @@ -408,14 +408,34 @@ function convertQuizBook(context: ConvertBookContext, book: Book): Quiz { aCount++; } break; + case 'ac': + question.columns = parseInt(parsed[2]); + break; case 'ae': - if (!question.answers[aCount - 1].explanation) { - question.answers[aCount - 1].explanation = { text: '' }; - } - if (hasAudioExtension(parsed[2])) { - question.answers[aCount - 1].explanation!.audio = parsed[2]; - } else { - question.answers[aCount - 1].explanation!.text = parsed[2]; + { + const isAudio = hasAudioExtension(parsed[2]); + const hasExplanationsInAnswers = isAudio + ? question.answers.some((answer) => answer.explanation?.audio !== undefined) + : question.answers.some((answer) => answer.explanation?.text != undefined); + + if (aCount == 0) { + // Question-level explanation + question.explanation = updateExplanation(question.explanation, parsed[2]); + } else { + if (aCount == 1 || hasExplanationsInAnswers) { + // Answer-specific explanation + question.answers[aCount - 1].explanation = updateExplanation( + question.answers[aCount - 1].explanation, + parsed[2] + ); + } else { + // Question-level explanation (same for all answers) + question.explanation = updateExplanation( + question.explanation, + parsed[2] + ); + } + } } break; } @@ -424,6 +444,21 @@ function convertQuizBook(context: ConvertBookContext, book: Book): Quiz { return quiz; } +function updateExplanation( + explanation: QuizExplanation | undefined, + text: string +): QuizExplanation { + if (!explanation) { + explanation = {}; + } + if (hasAudioExtension(text)) { + explanation.audio = text; + } else { + explanation.text = text; + } + return explanation; +} + function convertScriptureBook( pk: SABProskomma, context: ConvertBookContext, diff --git a/src/lib/components/BookSelector.svelte b/src/lib/components/BookSelector.svelte index 20ea859db..f2a84c6aa 100644 --- a/src/lib/components/BookSelector.svelte +++ b/src/lib/components/BookSelector.svelte @@ -12,7 +12,10 @@ The navbar component. import config from '$lib/data/config'; import SelectList from './SelectList.svelte'; import * as numerals from '$lib/scripts/numeralSystem'; + import { goto } from '$app/navigation'; + import { base } from '$app/paths'; + export let displayLabel; $: book = $nextRef.book === '' ? $refs.book : $nextRef.book; $: chapter = $nextRef.chapter === '' ? $refs.chapter : $nextRef.chapter; $: verseCount = getVerseCount(chapter, chapters); @@ -29,9 +32,11 @@ The navbar component. $: v = $t.Selector_Verse; let bookSelector; - $: label = config.bookCollections - .find((x) => x.id === $refs.collection) - .books.find((x) => x.id === book).name; + $: label = + displayLabel ?? + config.bookCollections + .find((x) => x.id === $refs.collection) + .books.find((x) => x.id === book).name; function chapterCount(book) { let count = Object.keys(books.find((x) => x.bookCode === book).versesByChapters).length; @@ -48,7 +53,20 @@ The navbar component. /** * Pushes reference changes to nextRef. Pushes final change to default reference. */ + async function navigateReference(e) { + // Handle special book navigation first + if (e.detail.tab === b && e.detail?.url) { + const book = e.detail.text; + addHistory({ + collection: $refs.collection, + book, + chapter: '', + url: e.detail.url + }); + goto(e.detail.url); + return; + } if (!showChapterSelector) { $nextRef.book = e.detail.text; await refs.set({ book: $nextRef.book, chapter: 1 }); @@ -104,6 +122,7 @@ The navbar component. verse: $nextRef.verse }); document.activeElement.blur(); + goto(`${base}/`); } function resetNavigation() { @@ -113,11 +132,19 @@ The navbar component. nextRef.reset(); } - /**list of books in current docSet*/ + /**list of books, quizzes, and quiz groups in current docSet*/ $: books = $refs.catalog.documents; /**list of chapters in current book*/ $: chapters = books.find((d) => d.bookCode === book).versesByChapters; + function getBookUrl(book) { + let url; + if (book.type === 'quiz') { + url = `${base}/quiz/${$refs.collection}/${book.id}`; + } + return url; + } + let bookGridGroup = ({ colId, bookLabel = 'abbreviation' }) => { let groups = []; var lastGroup = null; @@ -125,12 +152,15 @@ The navbar component. config.bookCollections .find((x) => x.id === colId) .books.forEach((book) => { - // Include books only in the catalog (i.e. only supported book types) - if (books.find((x) => x.bookCode === book.id)) { + const url = getBookUrl(book); + if (books.find((x) => x.bookCode === book.id) || url) { let label = book[bookLabel] || book.name; - let cell = { label: label, id: book.id }; + let cell = { label, id: book.id, url }; let group = book.testament || ''; - if ((lastGroup == null || group !== lastGroup) && config.mainFeatures['book-group-titles']) { + if ( + (lastGroup == null || group !== lastGroup) && + config.mainFeatures['book-group-titles'] + ) { // Create new group groups.push({ header: book.testament @@ -143,8 +173,8 @@ The navbar component. lastGroup = group; } else { // Add Book to last group - let cells = groups[groups.length - 1].cells; - groups[groups.length - 1].cells = [...cells, cell]; + let cells = groups.at(-1).cells; + groups.at(-1).cells = [...cells, cell]; } } }); diff --git a/src/lib/components/HistoryCard.svelte b/src/lib/components/HistoryCard.svelte index bd23c1703..3565df44b 100644 --- a/src/lib/components/HistoryCard.svelte +++ b/src/lib/components/HistoryCard.svelte @@ -10,6 +10,7 @@ TODO: import { formatDateAndTime } from '$lib/scripts/dateUtils'; import { base } from '$app/paths'; import config from '$lib/data/config'; + import { goto } from '$app/navigation'; export let history: HistoryItem; $: bc = config.bookCollections.find((x) => x.id === history.collection); @@ -22,21 +23,29 @@ TODO: : history.chapter; $: dateFormat = formatDateAndTime(new Date(history.date)); $: textDirection = bc.style.textDirection; - - -
- - + function onHistoryClick() { + if (history.url) { + goto(history.url); + } else { refs.set({ docSet, book: history.book, chapter: history.chapter, verse: history.verse - })} + }) + goto(`${base}/`); + } + } + + + +
+ + +
{dateFormat}
-
+
diff --git a/src/lib/components/SelectGrid.svelte b/src/lib/components/SelectGrid.svelte index 31b6415cc..d1748de35 100644 --- a/src/lib/components/SelectGrid.svelte +++ b/src/lib/components/SelectGrid.svelte @@ -64,9 +64,12 @@ A component to display menu options in a grid. return color; }; - function handleClick(opt: string) { + function handleClick(opt: any) { + const text = opt.id; + const url = opt?.url; dispatch('menuaction', { - text: opt + text, + url }); } @@ -114,7 +117,7 @@ A component to display menu options in a grid. handleClick(cell.id)} + on:click={() => handleClick(cell)} id={cell.id} class="dy-btn dy-btn-square dy-btn-ghost normal-case truncate text-clip" style={cellStyle} diff --git a/src/lib/components/SelectList.svelte b/src/lib/components/SelectList.svelte index 5ecf15a94..907410151 100644 --- a/src/lib/components/SelectList.svelte +++ b/src/lib/components/SelectList.svelte @@ -9,9 +9,12 @@ A component to display menu options in a list. const dispatch = createEventDispatcher(); - function handleClick(opt: string) { + function handleClick(opt: any) { + const text = opt.id; + const url = opt?.url; dispatch('menuaction', { - text: opt + text, + url }); } @@ -32,7 +35,7 @@ A component to display menu options in a list. - handleClick(group.cells[ri * group.cells.length + ci].id)} + handleClick(group.cells[ri * group.cells.length + ci])} class="menu p-0 cursor-pointer hover:bg-base-100 min-w-[16rem]" role="button" > diff --git a/src/lib/components/TabsMenu.svelte b/src/lib/components/TabsMenu.svelte index f49ea42b0..cf8bc4eb8 100644 --- a/src/lib/components/TabsMenu.svelte +++ b/src/lib/components/TabsMenu.svelte @@ -18,6 +18,7 @@ A component to display tabbed menus. function handleMenuaction({ detail }: CustomEvent) { dispatch('menuaction', { text: detail.text, + url: detail?.url, tab: active }); } diff --git a/src/lib/data/history.ts b/src/lib/data/history.ts index 5083614e9..e2c086b5b 100644 --- a/src/lib/data/history.ts +++ b/src/lib/data/history.ts @@ -6,6 +6,7 @@ export interface HistoryItem { book: string; chapter: string; verse?: string; + url?: string; } interface History extends DBSchema { history: { @@ -39,6 +40,7 @@ export async function addHistory(item: { book: string; chapter: string; verse?: string; + url?: string; }) { let history = await openHistory(); if (nextTimer) { diff --git a/src/lib/data/stores/audio.js b/src/lib/data/stores/audio.js index d9b7fe4e1..ebe9c81ef 100644 --- a/src/lib/data/stores/audio.js +++ b/src/lib/data/stores/audio.js @@ -6,6 +6,11 @@ import config from '../config'; setDefaultStorage('audioActive', config.mainFeatures['audio-turn-on-at-startup']); export const audioActive = writable(localStorage.audioActive === 'true'); audioActive.subscribe((value) => (localStorage.audioActive = value)); + +setDefaultStorage('quizAudioActive', true); +export const quizAudioActive = writable(localStorage.quizAudioActive); +quizAudioActive.subscribe((value) => (localStorage.quizAudioActive = value)); + /**which element should be highlighted as the audio is playing*/ function createaudioHighlightElements() { const external = writable([]); diff --git a/src/routes/quiz/[collection]/[id]/+page.js b/src/routes/quiz/[collection]/[id]/+page.js new file mode 100644 index 000000000..5d814116e --- /dev/null +++ b/src/routes/quiz/[collection]/[id]/+page.js @@ -0,0 +1,21 @@ +import { base } from '$app/paths'; + +/** @type {import('./$types').PageLoad} */ +export async function load({ params, fetch }) { + const id = params.id; + const collection = params.collection; + + try { + const response = await fetch(`${base}/collections/${collection}/quizzes/${id}.json`); + + if (!response.ok) { + throw new Error('Failed to fetch quiz JSON file'); + } + + const quizData = await response.json(); + return { quiz: quizData }; + } catch (error) { + console.error('Error fetching quiz JSON file:', error); + return {}; + } +} diff --git a/src/routes/quiz/[collection]/[id]/+page.svelte b/src/routes/quiz/[collection]/[id]/+page.svelte new file mode 100644 index 000000000..a2527cede --- /dev/null +++ b/src/routes/quiz/[collection]/[id]/+page.svelte @@ -0,0 +1,462 @@ + + +
+ + {#if questionNum == shuffledQuestions.length} +
+
+
{$t['Quiz_Score_Page_Message_Before']}
+
+ {score} +
+
+ {$t['Quiz_Score_Page_Message_After'].replace('%n%', questionNum)} +
+
+ {getCommentary(score)} +
+
+
+ {:else} + +
+
+ {questionNum + 1} +
+ {#if currentQuizQuestion.answers} + {#if currentQuizQuestion.answers.some((answer) => answer.text)} +
+
+ {currentQuizQuestion.text} + {#if currentQuizQuestion.image} + + + {/if} +
+
+
+ {#each shuffledAnswers as answer, currentIndex} + + {/each} +
+
+ {#if explanation} +
+ {explanation} +
+ {/if} +
+ {/if} + {#if currentQuizQuestion.answers.some((answer) => answer.image)} +
+
+ {currentQuizQuestion.text} +
+
+
+ {#each shuffledAnswers as answer, currentIndex} +
+ + + {answer.text} { + onQuestionAnswered(answer); + }} + /> +
+ {/each} +
+
+ {#if explanation} +
+ {explanation} +
+ {/if} +
+ {/if} + {/if} + {#if displayCorrect} +
+ +
+ {/if} +
+ + {/if} +
+ +