From b3f2b9c1ecc9b597a0d0bc912e1db53b0bb610ed Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Thu, 8 Oct 2026 15:21:56 +0700 Subject: [PATCH 01/14] feat: AI lecture summary and quiz under the video Two cards below the Pomodoro timer on the study page, filled from FlickMan's v1/study_aids API: - Summary: the lecture as a checklist. Sections are highlighted and jump the video to where each topic starts, points are numbered straight through, and the points a reader ticks are kept on the device. - Quiz: single-best-answer questions one at a time, showing the answer, the explanation, how the class did and a link back to that part of the lecture. Editors chosen on FlickMan can rewrite the summary and add, remove or correct questions, and decide whether readers see their name as a co-author. --- src/app/home/course/course.page.html | 2 + src/app/home/course/course.page.ts | 22 ++ src/app/home/course/markdown-math.ts | 3 +- src/app/shared/_study-card.scss | 113 ++++++ src/app/shared/_study-editor.scss | 38 ++ src/app/shared/checklist-view.component.html | 41 ++ src/app/shared/checklist-view.component.scss | 127 ++++++ src/app/shared/checklist-view.component.ts | 41 ++ src/app/shared/credit-field.component.html | 14 + src/app/shared/credit-field.component.scss | 25 ++ src/app/shared/credit-field.component.ts | 34 ++ src/app/shared/lecture-quiz.component.html | 156 ++++++++ src/app/shared/lecture-quiz.component.scss | 250 ++++++++++++ src/app/shared/lecture-quiz.component.spec.ts | 160 ++++++++ src/app/shared/lecture-quiz.component.ts | 288 ++++++++++++++ src/app/shared/lecture-summary.component.html | 58 +++ src/app/shared/lecture-summary.component.scss | 18 + .../shared/lecture-summary.component.spec.ts | 115 ++++++ src/app/shared/lecture-summary.component.ts | 160 ++++++++ src/app/shared/quiz-editor.component.html | 126 ++++++ src/app/shared/quiz-editor.component.scss | 146 +++++++ src/app/shared/quiz-editor.component.ts | 366 ++++++++++++++++++ src/app/shared/rich-text.component.html | 1 + src/app/shared/rich-text.component.scss | 66 ++++ src/app/shared/rich-text.component.ts | 24 ++ src/app/shared/summary-editor.component.html | 82 ++++ src/app/shared/summary-editor.component.scss | 112 ++++++ src/app/shared/summary-editor.component.ts | 232 +++++++++++ src/app/study-aids.service.spec.ts | 189 +++++++++ src/app/study-aids.service.ts | 219 +++++++++++ src/app/study-aids/byline.ts | 28 ++ src/app/study-aids/checklist.spec.ts | 131 +++++++ src/app/study-aids/checklist.ts | 207 ++++++++++ src/app/study-aids/displayed.ts | 24 ++ src/app/study-aids/local-store.ts | 86 ++++ src/app/study-aids/parse.ts | 136 +++++++ src/app/study-aids/quiz.spec.ts | 105 +++++ src/app/study-aids/quiz.ts | 140 +++++++ src/app/study-aids/study-aids.model.ts | 129 ++++++ 39 files changed, 4213 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/_study-card.scss create mode 100644 src/app/shared/_study-editor.scss create mode 100644 src/app/shared/checklist-view.component.html create mode 100644 src/app/shared/checklist-view.component.scss create mode 100644 src/app/shared/checklist-view.component.ts create mode 100644 src/app/shared/credit-field.component.html create mode 100644 src/app/shared/credit-field.component.scss create mode 100644 src/app/shared/credit-field.component.ts create mode 100644 src/app/shared/lecture-quiz.component.html create mode 100644 src/app/shared/lecture-quiz.component.scss create mode 100644 src/app/shared/lecture-quiz.component.spec.ts create mode 100644 src/app/shared/lecture-quiz.component.ts create mode 100644 src/app/shared/lecture-summary.component.html create mode 100644 src/app/shared/lecture-summary.component.scss create mode 100644 src/app/shared/lecture-summary.component.spec.ts create mode 100644 src/app/shared/lecture-summary.component.ts create mode 100644 src/app/shared/quiz-editor.component.html create mode 100644 src/app/shared/quiz-editor.component.scss create mode 100644 src/app/shared/quiz-editor.component.ts create mode 100644 src/app/shared/rich-text.component.html create mode 100644 src/app/shared/rich-text.component.scss create mode 100644 src/app/shared/rich-text.component.ts create mode 100644 src/app/shared/summary-editor.component.html create mode 100644 src/app/shared/summary-editor.component.scss create mode 100644 src/app/shared/summary-editor.component.ts create mode 100644 src/app/study-aids.service.spec.ts create mode 100644 src/app/study-aids.service.ts create mode 100644 src/app/study-aids/byline.ts create mode 100644 src/app/study-aids/checklist.spec.ts create mode 100644 src/app/study-aids/checklist.ts create mode 100644 src/app/study-aids/displayed.ts create mode 100644 src/app/study-aids/local-store.ts create mode 100644 src/app/study-aids/parse.ts create mode 100644 src/app/study-aids/quiz.spec.ts create mode 100644 src/app/study-aids/quiz.ts create mode 100644 src/app/study-aids/study-aids.model.ts diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index d2fb4d4..6eb61fa 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -110,6 +110,8 @@ } + + this.videoPlayer?.currentTime() ?? 0, + seek: seconds => this.seekTo(seconds), + }; + constructor() { addIcons({ download, documentAttachOutline, documentTextOutline, checkmarkOutline, closeOutline, pauseCircleOutline }); } @@ -441,6 +452,17 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { this.playLog = []; } + private seekTo(seconds: number) { + if (!this.videoPlayer || !this.currentVideo) { + return; + } + // A jump asked for before the metadata arrives must not be undone by resuming the last position + this.expectVideoTimeJump = false; + this.videoPlayer.currentTime(seconds); + this.videoPlayer.play()?.catch(() => undefined); + this.videoPlayer.el().scrollIntoView({behavior: 'smooth', block: 'nearest'}); + } + async setPlaybackSpeed() { const alert = await this.alertController.create({ header: 'Please enter speed!', diff --git a/src/app/home/course/markdown-math.ts b/src/app/home/course/markdown-math.ts index 205f7e0..4cdc490 100644 --- a/src/app/home/course/markdown-math.ts +++ b/src/app/home/course/markdown-math.ts @@ -48,8 +48,9 @@ export const mathExtension: MarkedExtension = { export const markedWithMath = new Marked(mathExtension); // Replaces the math placeholders under `root` with KaTeX output. Invalid TeX is shown as the raw source. +// Placeholders already rendered are skipped, so it is safe to call again after more content appears. export function renderMath(root: HTMLElement) { - root.querySelectorAll(`.${INLINE_CLASS}, .${DISPLAY_CLASS}`).forEach(el => { + root.querySelectorAll(`.${INLINE_CLASS}:not(.math-rendered), .${DISPLAY_CLASS}:not(.math-rendered)`).forEach(el => { const tex = el.textContent ?? ''; katex.render(tex, el, { displayMode: el.classList.contains(DISPLAY_CLASS), diff --git a/src/app/shared/_study-card.scss b/src/app/shared/_study-card.scss new file mode 100644 index 0000000..3ab53c0 --- /dev/null +++ b/src/app/shared/_study-card.scss @@ -0,0 +1,113 @@ +// The look the summary and quiz cards share with the Pomodoro card above them. + +:host { + // A block, so the settings sheet can tell when it hides the card (see study-aids/displayed.ts). + display: block; + width: 100%; +} + +.study-card { + margin: 0.75rem 0 0 0; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: var(--flick-welcome-card-shadow, 0 4px 16px rgba(0, 0, 0, 0.08)); +} + +.card-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + border: 0; + background: transparent; + color: var(--ion-text-color); + font: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } +} + +.card-heading { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; + min-width: 0; +} + +.card-title { + font-size: 1rem; + font-weight: 600; +} + +.card-badge { + padding: 0.1rem 0.5rem; + border-radius: 1rem; + background: rgba(var(--ion-color-warning-rgb), 0.25); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.card-meta { + color: var(--ion-color-medium); + font-size: 0.8rem; + white-space: nowrap; +} + +.collapse-icon { + flex: none; + color: var(--ion-color-medium); + font-size: 1.2rem; +} + +.card-body { + padding: 0 1rem 1rem; + color: var(--ion-text-color); +} + +.byline { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.25rem 0.75rem; + margin-bottom: 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; + line-height: 1.4; +} + +.card-note { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.85rem; + line-height: 1.45; +} + +.card-loading { + display: flex; + justify-content: center; + padding: 0.5rem 0; +} + +.small-button { + margin: 0; + --padding-start: 0.6rem; + --padding-end: 0.6rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/src/app/shared/_study-editor.scss b/src/app/shared/_study-editor.scss new file mode 100644 index 0000000..503adfa --- /dev/null +++ b/src/app/shared/_study-editor.scss @@ -0,0 +1,38 @@ +// Shared by the summary and quiz editors, whose styles are unscoped (see their components). + +// Ionic attaches modals to the app root, so their size can only be set from unscoped styles. +// Phones keep Ionic's full-screen modal. +@media (min-width: 768px) and (min-height: 600px) { + ion-modal.study-editor-modal { + --width: min(1100px, 95vw); + --height: 92vh; + --max-width: 100%; + --max-height: 100%; + } +} + +.study-editor { + .editor-note { + margin: 0 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.85rem; + line-height: 1.45; + } + + .editor-problem { + margin: 0 0 0.75rem; + padding: 0.6rem 0.85rem; + border-radius: 0.5rem; + background: rgba(var(--ion-color-danger-rgb), 0.12); + color: var(--ion-text-color); + font-size: 0.85rem; + line-height: 1.45; + } + + .field-hint { + margin: 0.35rem 0 0; + color: var(--ion-color-medium); + font-size: 0.78rem; + line-height: 1.4; + } +} diff --git a/src/app/shared/checklist-view.component.html b/src/app/shared/checklist-view.component.html new file mode 100644 index 0000000..fff1c02 --- /dev/null +++ b/src/app/shared/checklist-view.component.html @@ -0,0 +1,41 @@ +@if (checklist.title) { +

{{ checklist.title }}

+} +@for (section of checklist.sections; track $index) { +
+ @if (section.headingHtml || section.time !== null) { +

+ @if (section.headingHtml) { + + } + @if (section.time !== null) { + @if (seekable) { + + } @else { + {{ formatClock(section.time) }} + } + } +

+ } + @for (block of section.blocks; track block.key) { + @if (block.kind === 'point') { +
+ @if (ticked) { + + } + {{ block.number }}. + +
+ } @else if (block.kind === 'label') { + + } @else { + + } + } +
+} diff --git a/src/app/shared/checklist-view.component.scss b/src/app/shared/checklist-view.component.scss new file mode 100644 index 0000000..97b6e7d --- /dev/null +++ b/src/app/shared/checklist-view.component.scss @@ -0,0 +1,127 @@ +app-checklist-view { + display: block; + font-size: 0.95rem; + line-height: 1.55; + + .checklist-title { + margin: 0 0 0.75rem; + font-size: 1.1rem; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.2em; + } + + .checklist-section + .checklist-section { + margin-top: 1rem; + } + + .checklist-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + margin: 0 0 0.4rem; + font-size: 1.05rem; + font-weight: 700; + } + + .heading-text { + padding: 0.05em 0.35em; + border-radius: 0.25rem; + background: rgba(var(--ion-color-primary-rgb), 0.18); + } + + .time-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.1rem 0.55rem; + border: 1px solid rgba(var(--ion-color-primary-rgb), 0.45); + border-radius: 1rem; + background: transparent; + color: var(--ion-color-primary); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + button.time-chip { + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + } + + .checklist-point { + display: grid; + grid-template-columns: 2.4em minmax(0, 1fr); + column-gap: 0.4rem; + padding: 0.12rem 0; + } + + &.with-ticks .checklist-point { + grid-template-columns: 1.1rem 2.4em minmax(0, 1fr); + } + + .point-tick { + width: 1rem; + height: 1rem; + margin: 0.22em 0 0; + accent-color: var(--ion-color-primary); + cursor: pointer; + } + + .point-number { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .is-ticked .point-number, + .is-ticked .point-body { + opacity: 0.55; + } + + .point-body { + strong { + color: var(--ion-color-primary); + } + + ol, + ul { + margin: 0.1rem 0; + padding-left: 1.6em; + list-style-type: lower-alpha; + } + + :is(ol, ul) :is(ol, ul) { + list-style-type: lower-roman; + } + + :is(ol, ul) :is(ol, ul) :is(ol, ul) { + list-style-type: decimal; + } + } + + /* Lines up with the text of the points around it. */ + .checklist-label { + margin: 0.5rem 0 0.2rem calc(2.4em + 0.4rem); + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.2em; + } + + &.with-ticks .checklist-label { + margin-left: calc(1.1rem + 2.4em + 0.8rem); + } + + .checklist-block { + margin: 0.4rem 0; + } +} diff --git a/src/app/shared/checklist-view.component.ts b/src/app/shared/checklist-view.component.ts new file mode 100644 index 0000000..5e053c4 --- /dev/null +++ b/src/app/shared/checklist-view.component.ts @@ -0,0 +1,41 @@ +import {Component, EventEmitter, Input, Output, ViewEncapsulation} from '@angular/core'; +import {IonIcon} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {play} from 'ionicons/icons'; +import {RichTextComponent} from './rich-text.component'; +import {Checklist, formatClock} from '../study-aids/checklist'; + +export interface TickChange { + key: string; + ticked: boolean; +} + +/** A summary laid out as a checklist: highlighted sections, points numbered straight through. */ +@Component({ + selector: 'app-checklist-view', + templateUrl: './checklist-view.component.html', + styleUrls: ['./checklist-view.component.scss'], + // The points arrive as HTML, which scoped styles would never reach + encapsulation: ViewEncapsulation.None, + imports: [IonIcon, RichTextComponent], + host: {'[class.with-ticks]': '!!ticked'}, +}) +export class ChecklistViewComponent { + @Input() checklist: Checklist; + /** The points this reader has ticked, or null to show the summary without boxes. */ + @Input() ticked: ReadonlySet | null = null; + /** Whether a section's time jumps the video there, rather than only being shown. */ + @Input() seekable = false; + @Output() tickChange = new EventEmitter(); + @Output() seek = new EventEmitter(); + + protected readonly formatClock = formatClock; + + constructor() { + addIcons({play}); + } + + onTick(key: string, event: Event): void { + this.tickChange.emit({key, ticked: (event.target as HTMLInputElement).checked}); + } +} diff --git a/src/app/shared/credit-field.component.html b/src/app/shared/credit-field.component.html new file mode 100644 index 0000000..13d970b --- /dev/null +++ b/src/app/shared/credit-field.component.html @@ -0,0 +1,14 @@ +
+ + Show my name as a co-author + + @if (choice.credit) { + +

Readers see this name with the {{ part }}. Leave it empty to use your account name.

+ } @else { +

Readers will not see your name. If it was shown before, saving removes it.

+ } +
diff --git a/src/app/shared/credit-field.component.scss b/src/app/shared/credit-field.component.scss new file mode 100644 index 0000000..9e2c434 --- /dev/null +++ b/src/app/shared/credit-field.component.scss @@ -0,0 +1,25 @@ +:host { + display: block; +} + +.credit-field { + margin-top: 1.25rem; + padding: 0.85rem 1rem; + border-radius: 0.75rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); +} + +ion-checkbox { + font-weight: 600; +} + +.credit-name { + margin-top: 0.75rem; +} + +.credit-hint { + margin: 0.5rem 0 0; + color: var(--ion-color-medium); + font-size: 0.8rem; + line-height: 1.4; +} diff --git a/src/app/shared/credit-field.component.ts b/src/app/shared/credit-field.component.ts new file mode 100644 index 0000000..def1f0f --- /dev/null +++ b/src/app/shared/credit-field.component.ts @@ -0,0 +1,34 @@ +import {Component, EventEmitter, inject, Input, Output} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {IonCheckbox, IonInput} from '@ionic/angular/standalone'; +import {map} from 'rxjs/operators'; +import {AuthService} from '../auth.service'; +import {CreditChoice, LIMITS} from '../study-aids/study-aids.model'; + +/** + * The co-author box in the editors. Ticked, readers see the editor's name with the summary or + * the questions; unticked, the editor stays anonymous, and saving removes a name shown before. + */ +@Component({ + selector: 'app-credit-field', + templateUrl: './credit-field.component.html', + styleUrls: ['./credit-field.component.scss'], + imports: [AsyncPipe, IonCheckbox, IonInput], +}) +export class CreditFieldComponent { + @Input() choice: CreditChoice = {credit: false, name: ''}; + @Output() choiceChange = new EventEmitter(); + /** What is being edited, as the hint names it. */ + @Input() part = 'summary'; + + protected readonly maxName = LIMITS.creditNameChars; + protected readonly accountName$ = inject(AuthService).user.pipe(map(user => user?.displayName ?? '')); + + onCredit(event: Event): void { + this.choiceChange.emit({...this.choice, credit: (event as CustomEvent<{checked: boolean}>).detail.checked}); + } + + onName(event: Event): void { + this.choiceChange.emit({...this.choice, name: (event as CustomEvent<{value: string | null}>).detail.value ?? ''}); + } +} diff --git a/src/app/shared/lecture-quiz.component.html b/src/app/shared/lecture-quiz.component.html new file mode 100644 index 0000000..9194f90 --- /dev/null +++ b/src/app/shared/lecture-quiz.component.html @@ -0,0 +1,156 @@ +@if (view$ | async; as view) { + @if (shows(view)) { + + + + @if (!collapsed) { +
+ @if (view.status === 'loading') { +
+ +
+ } @else if (view.status === 'error') { +

The quiz could not be loaded.

+ Try again + } @else if (view.quiz && view.items.length) { + + + @switch (phase(view)) { + @case ('start') { +
+

+ {{ view.items.length }} single-best-answer {{ view.items.length === 1 ? 'question' : 'questions' }} + on this lecture. + @if (answeredCount(view); as answered) { + You have answered {{ answered }} so far. + } +

+ + {{ answeredCount(view) ? 'Continue' : 'Start quiz' }} + +
+ } + @case ('question') { + @let item = view.items[current ?? 0]; +
+ Question {{ (current ?? 0) + 1 }} of {{ view.items.length }} + +
+ + + +
+ @for (choice of item.choicesHtml; track $index; let i = $index) { + @let state = choiceState(item, i); + + } +
+ + @if (isAnswered(item)) { +
+ + @if (statsLine(item.question); as line) { + + } + @if (item.explanationHtml) { + + } + @if (item.question.time !== null && playback) { + + + Watch this part ({{ formatClock(item.question.time) }}) + + } +
+
+ @if (answeredCount(view) === view.items.length) { + All results + } + + {{ nextIndex(view) === null ? 'See results' : 'Next question' }} + +
+ } + } + @case ('results') { +

+ {{ scoreOf(view) }} of {{ view.items.length }} correct +

+
    + @for (item of view.items; track item.question.id; let i = $index) { +
  1. + +
  2. + } +
+ + + Retake quiz + + } + } + } @else if (view.aids?.can_edit) { +

{{ emptyNote(view.aids!) }}

+ Add questions + } @else { +

The AI is still writing questions on this lecture. Check back later.

+ } +

{{ announcement }}

+
+ } +
+ } +} diff --git a/src/app/shared/lecture-quiz.component.scss b/src/app/shared/lecture-quiz.component.scss new file mode 100644 index 0000000..92a1348 --- /dev/null +++ b/src/app/shared/lecture-quiz.component.scss @@ -0,0 +1,250 @@ +@use 'study-card'; + +.quiz-start ion-button { + margin: 0.25rem 0 0; +} + +.quiz-progress { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.35rem 0.75rem; + margin-bottom: 0.6rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.progress-dots { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.18); +} + +.dot-right { + background: var(--ion-color-success); +} + +.dot-wrong { + background: var(--ion-color-danger); +} + +.dot-current { + outline: 2px solid var(--ion-color-primary); + outline-offset: 1px; +} + +.quiz-prompt { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.quiz-choices { + display: grid; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.quiz-choice { + display: grid; + grid-template-columns: 1.9rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.16); + border-radius: 0.6rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + line-height: 1.4; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; + + &:hover:not(:disabled) { + border-color: var(--ion-color-primary); + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + + &:disabled { + cursor: default; + } + + &.is-correct { + border-color: var(--ion-color-success); + background: rgba(var(--ion-color-success-rgb), 0.14); + } + + &.is-wrong { + border-color: var(--ion-color-danger); + background: rgba(var(--ion-color-danger-rgb), 0.12); + } + + &.is-muted { + opacity: 0.6; + } +} + +.choice-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + background: rgba(var(--ion-color-primary-rgb), 0.12); + color: var(--ion-color-primary); + font-size: 0.85rem; + font-weight: 700; +} + +.is-correct .choice-letter { + background: var(--ion-color-success); + color: var(--ion-color-success-contrast); +} + +.is-wrong .choice-letter { + background: var(--ion-color-danger); + color: var(--ion-color-danger-contrast); +} + +.choice-mark { + font-size: 1.3rem; +} + +.is-correct .choice-mark { + color: var(--ion-color-success); +} + +.is-wrong .choice-mark { + color: var(--ion-color-danger); +} + +.quiz-feedback { + padding: 0.75rem 0.85rem; + border-left: 3px solid var(--ion-color-danger); + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); + font-size: 0.9rem; + line-height: 1.5; + + &.is-right { + border-left-color: var(--ion-color-success); + } +} + +.feedback-verdict { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0 0 0.35rem; + font-weight: 700; + + ion-icon { + flex: none; + color: var(--ion-color-danger); + font-size: 1.15rem; + } +} + +.is-right .feedback-verdict ion-icon { + color: var(--ion-color-success); +} + +.feedback-stats { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.watch-button { + margin-top: 0.4rem; + --padding-start: 0; +} + +.quiz-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; + + ion-button { + margin: 0; + } +} + +.results-score { + margin: 0 0 0.5rem; + font-size: 1.05rem; +} + +.results-list { + margin: 0 0 0.75rem; + padding: 0; + list-style: none; +} + +.results-row { + display: grid; + grid-template-columns: 1.3rem 1.8em minmax(0, 1fr); + align-items: start; + gap: 0.4rem; + width: 100%; + padding: 0.4rem 0.3rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + font-size: 0.9rem; + text-align: left; + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } + + ion-icon { + margin-top: 0.15em; + font-size: 1.1rem; + } +} + +.mark-right { + color: var(--ion-color-success); +} + +.mark-wrong { + color: var(--ion-color-danger); +} + +.results-number { + color: var(--ion-color-medium); + font-variant-numeric: tabular-nums; +} + +.results-prompt { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/src/app/shared/lecture-quiz.component.spec.ts b/src/app/shared/lecture-quiz.component.spec.ts new file mode 100644 index 0000000..f1397d4 --- /dev/null +++ b/src/app/shared/lecture-quiz.component.spec.ts @@ -0,0 +1,160 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModalController} from '@ionic/angular/standalone'; +import {BehaviorSubject, of} from 'rxjs'; +import {LectureQuizComponent} from './lecture-quiz.component'; +import {StudyAidsService} from '../study-aids.service'; +import {Lecture} from '../man.service'; +import {QuizQuestion, StudyAids, StudyAidsState} from '../study-aids/study-aids.model'; + +const VIDEO = {id: 7, title: 'Organelles', duration: 3000} as Lecture; + +function question(id: string, answer: number, mine: number | null = null): QuizQuestion { + return { + id, + prompt: 'Question ' + id, + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer, + explanation: 'Because of **steroids**.', + time: 465, + stats: {answered: 12, correct: 10}, + mine, + }; +} + +function aids(questions: QuizQuestion[], canEdit = false): StudyAids { + return { + video_id: 7, + can_edit: canEdit, + generation: 'ready', + summary: null, + quiz: {questions, revision: 2, origin: 'ai', edited: true, contributors: [{name: 'Kan & Chain'}], updated_at: null}, + }; +} + +describe('LectureQuizComponent', () => { + let fixture: ComponentFixture; + let state$: BehaviorSubject; + let service: jasmine.SpyObj>; + let seek: jasmine.Spy; + + const element = () => fixture.nativeElement as HTMLElement; + const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; + const buttons = (selector: string) => Array.from(element().querySelectorAll(selector)); + const clickButton = (label: string) => { + const button = buttons('ion-button, button').find(candidate => candidate.textContent?.trim() === label); + expect(button).withContext(label).toBeTruthy(); + button?.click(); + fixture.detectChanges(); + }; + + function create(initial: StudyAids): void { + state$ = new BehaviorSubject({status: 'ready', aids: initial}); + service.watch.and.returnValue(state$); + fixture = TestBed.createComponent(LectureQuizComponent); + fixture.componentRef.setInput('video', VIDEO); + fixture.componentRef.setInput('playback', {currentTime: () => 0, seek}); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.removeItem('flickQuizAnswers:7'); + localStorage.removeItem('flickStudyAidsCollapsed'); + service = jasmine.createSpyObj('StudyAidsService', ['watch', 'answer', 'refresh']); + service.answer.and.returnValue(of({answered: 13, correct: 11})); + seek = jasmine.createSpy('seek'); + TestBed.configureTestingModule({ + imports: [LectureQuizComponent], + providers: [ + {provide: StudyAidsService, useValue: service}, + {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['create'])}, + ], + }); + }); + + afterEach(() => { + localStorage.removeItem('flickQuizAnswers:7'); + }); + + it('offers to start, then shows the first question', () => { + create(aids([question('a', 2), question('b', 0)])); + + expect(text()).toContain('2 single-best-answer questions'); + expect(text()).toContain('AI draft, edited by Kan & Chain'); + clickButton('Start quiz'); + + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice').length).toBe(5); + }); + + it('marks the answer, explains it and records it once', () => { + create(aids([question('a', 2), question('b', 0)])); + clickButton('Start quiz'); + buttons('.quiz-choice')[1].click(); + fixture.detectChanges(); + + expect(buttons('.quiz-choice.is-wrong').length).toBe(1); + expect(buttons('.quiz-choice.is-correct').length).toBe(1); + expect(text()).toContain('Not quite. The answer is C.'); + expect(text()).toContain('83% of 12 students got this right.'); + expect(service.answer).toHaveBeenCalledOnceWith(7, 'a', 1); + + buttons('.quiz-choice')[2].click(); + fixture.detectChanges(); + expect(service.answer).toHaveBeenCalledTimes(1); + }); + + it('jumps the lecture to where the question is taught', () => { + create(aids([question('a', 2)])); + clickButton('Start quiz'); + buttons('.quiz-choice')[0].click(); + fixture.detectChanges(); + clickButton('Watch this part (7:45)'); + + expect(seek).toHaveBeenCalledWith(465); + }); + + it('scores the quiz and lets the reader start again', () => { + create(aids([question('a', 2), question('b', 0)])); + clickButton('Start quiz'); + buttons('.quiz-choice')[2].click(); + fixture.detectChanges(); + clickButton('Next question'); + buttons('.quiz-choice')[3].click(); + fixture.detectChanges(); + clickButton('See results'); + + expect(text()).toContain('1 of 2 correct'); + clickButton('Retake quiz'); + expect(text()).toContain('Question 1 of 2'); + expect(buttons('.quiz-choice.is-correct').length).toBe(0); + }); + + it('starts from the answers given on another device', () => { + create(aids([question('a', 2, 2), question('b', 0, 1)])); + + expect(text()).toContain('1 of 2 correct'); + expect(service.answer).not.toHaveBeenCalled(); + }); + + it('carries on where this device left off', () => { + localStorage.setItem('flickQuizAnswers:7', JSON.stringify({a: 2})); + create(aids([question('a', 2), question('b', 0)])); + + expect(text()).toContain('You have answered 1 so far.'); + clickButton('Continue'); + expect(text()).toContain('Question 2 of 2'); + }); + + it('stays hidden from readers when a lecture has no questions', () => { + create(aids([])); + + expect(element().querySelector('ion-card')).toBeNull(); + }); + + it('offers editors to add questions', () => { + create(aids([], true)); + + expect(text()).toContain('There are no questions for this lecture yet.'); + expect(text()).toContain('Add questions'); + }); +}); diff --git a/src/app/shared/lecture-quiz.component.ts b/src/app/shared/lecture-quiz.component.ts new file mode 100644 index 0000000..5de9a52 --- /dev/null +++ b/src/app/shared/lecture-quiz.component.ts @@ -0,0 +1,288 @@ +import {Component, ElementRef, inject, Input, NgZone} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {IonButton, IonCard, IonIcon, IonSpinner, ModalController} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {checkmarkCircle, chevronDown, chevronUp, closeCircle, createOutline, playCircleOutline, refreshOutline} from 'ionicons/icons'; +import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {RichTextComponent} from './rich-text.component'; +import {QuizEditorComponent} from './quiz-editor.component'; +import {formatClock, plainText, renderBlock, renderInline} from '../study-aids/checklist'; +import {byline, isAiDraft} from '../study-aids/byline'; +import {displayed} from '../study-aids/displayed'; +import {readAnswers, readCollapsed, writeAnswers, writeCollapsed} from '../study-aids/local-store'; +import {CHOICE_LETTERS, score} from '../study-aids/quiz'; +import {LecturePlayback, QuizPart, QuizQuestion, StudyAids, StudyAidsState} from '../study-aids/study-aids.model'; + +/** Fewer answers than this say nothing about how hard a question is. */ +const MIN_ANSWERS_FOR_STATS = 5; + +interface QuizItem { + question: QuizQuestion; + promptHtml: string; + promptText: string; + choicesHtml: string[]; + explanationHtml: string | null; +} + +interface QuizView { + videoId: number; + duration: number | null; + status: StudyAidsState['status']; + aids: StudyAids | null; + quiz: QuizPart | null; + items: QuizItem[]; +} + +type ChoiceState = 'correct' | 'wrong' | 'muted' | null; + +/** + * Practice questions on the lecture, under the video: one at a time, with the answer, the + * explanation and how the class did shown as soon as the reader picks a choice. + */ +@Component({ + selector: 'app-lecture-quiz', + templateUrl: './lecture-quiz.component.html', + styleUrls: ['./lecture-quiz.component.scss'], + imports: [AsyncPipe, IonCard, IonButton, IonIcon, IonSpinner, RichTextComponent], +}) +export class LectureQuizComponent { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + + private readonly video$ = new BehaviorSubject(null); + + @Input() set video(video: Lecture | null) { + this.video$.next(video); + } + + @Input() playback: LecturePlayback | null = null; + + protected collapsed = readCollapsed('quiz'); + /** This attempt's answers, by question id. */ + protected answers: Record = {}; + /** The open question; null shows the start or, once everything is answered, the results. */ + protected current: number | null = null; + /** Read out by screen readers when an answer is checked. */ + protected announcement = ''; + protected readonly byline = byline; + protected readonly isAiDraft = isAiDraft; + protected readonly formatClock = formatClock; + + private sessionVideo: number | null = null; + private sessionLoaded = false; + private sent = new Set(); + private renderedFrom: QuizQuestion[] | null = null; + private rendered: QuizItem[] = []; + + protected readonly view$: Observable = combineLatest([ + this.video$, + displayed(inject>(ElementRef).nativeElement, inject(NgZone)), + ]).pipe( + switchMap(([video, shown]) => video?.id && shown + ? this.service.watch(video.id).pipe(map(state => this.toView(video, state))) + : of(null)), + ); + + constructor() { + addIcons({checkmarkCircle, chevronDown, chevronUp, closeCircle, createOutline, playCircleOutline, refreshOutline}); + } + + shows(view: QuizView): boolean { + if (view.status !== 'ready') { + return view.status !== 'unavailable'; + } + + return view.items.length > 0 || !!view.aids?.can_edit || (view.aids?.generation === 'pending' && !view.quiz); + } + + phase(view: QuizView): 'start' | 'question' | 'results' { + if (this.current !== null && view.items[this.current]) { + return 'question'; + } + + return this.answeredCount(view) === view.items.length ? 'results' : 'start'; + } + + answeredCount(view: QuizView): number { + return view.items.filter(item => item.question.id in this.answers).length; + } + + scoreOf(view: QuizView): number { + return score(view.items.map(item => item.question), this.answers); + } + + isAnswered(item: QuizItem): boolean { + return item.question.id in this.answers; + } + + isRight(item: QuizItem): boolean { + return this.answers[item.question.id] === item.question.answer; + } + + choiceState(item: QuizItem, index: number): ChoiceState { + const chosen = this.answers[item.question.id]; + if (chosen === undefined) { + return null; + } + if (index === item.question.answer) { + return 'correct'; + } + + return index === chosen ? 'wrong' : 'muted'; + } + + letter(index: number): string { + return CHOICE_LETTERS[index] ?? String(index + 1); + } + + statsLine(question: QuizQuestion): string | null { + const stats = question.stats; + if (!stats || stats.answered < MIN_ANSWERS_FOR_STATS) { + return null; + } + + return `${Math.round(stats.correct / stats.answered * 100)}% of ${stats.answered} students got this right.`; + } + + /** Where Next goes: the following question, else one still unanswered, else the results. */ + nextIndex(view: QuizView): number | null { + const following = (this.current ?? -1) + 1; + if (following < view.items.length) { + return following; + } + const unanswered = view.items.findIndex(item => !this.isAnswered(item)); + + return unanswered >= 0 ? unanswered : null; + } + + emptyNote(aids: StudyAids): string { + switch (aids.generation) { + case 'pending': + return 'The AI is still drafting questions. If you add some now, the AI draft will not replace them.'; + case 'failed': + return 'The AI could not draft questions for this lecture.'; + default: + return 'There are no questions for this lecture yet.'; + } + } + + toggle(): void { + this.collapsed = !this.collapsed; + writeCollapsed('quiz', this.collapsed); + } + + start(view: QuizView): void { + const unanswered = view.items.findIndex(item => !this.isAnswered(item)); + this.current = unanswered >= 0 ? unanswered : 0; + } + + open(index: number): void { + this.current = index; + } + + showResults(): void { + this.current = null; + } + + choose(view: QuizView, item: QuizItem, choice: number): void { + const id = item.question.id; + if (this.isAnswered(item)) { + return; + } + this.answers = {...this.answers, [id]: choice}; + writeAnswers(view.videoId, this.answers); + this.announcement = choice === item.question.answer + ? 'Correct.' + : `Not quite. The answer is ${this.letter(item.question.answer)}.`; + + // Only a first answer counts towards the class stats, so later ones need not be sent. + if (item.question.mine === null && !this.sent.has(id)) { + this.sent.add(id); + this.service.answer(view.videoId, id, choice).subscribe(); + } + } + + next(view: QuizView): void { + this.current = this.nextIndex(view); + this.announcement = ''; + } + + retake(view: QuizView): void { + this.answers = {}; + writeAnswers(view.videoId, this.answers); + this.current = 0; + this.announcement = ''; + } + + seek(seconds: number): void { + this.playback?.seek(seconds); + } + + retry(view: QuizView): void { + this.service.refresh(view.videoId); + } + + async edit(view: QuizView): Promise { + if (!view.aids) { + return; + } + const modal = await this.modalCtrl.create({ + component: QuizEditorComponent, + cssClass: 'study-editor-modal', + backdropDismiss: false, + componentProps: { + videoId: view.videoId, + quiz: view.quiz, + generation: view.aids.generation, + duration: view.duration, + playback: this.playback, + }, + }); + await modal.present(); + } + + private toView(video: Lecture, state: StudyAidsState): QuizView { + const videoId = video.id as number; + if (videoId !== this.sessionVideo) { + this.sessionVideo = videoId; + this.sessionLoaded = false; + this.answers = {}; + this.current = null; + this.announcement = ''; + this.sent.clear(); + } + const aids = state.status === 'ready' ? state.aids : null; + const quiz = aids?.quiz ?? null; + const items = quiz ? this.itemsFor(quiz.questions) : []; + if (quiz && !this.sessionLoaded) { + // A reader who answered on another device starts from those first answers. + this.answers = readAnswers(videoId) ?? Object.fromEntries(quiz.questions + .filter(question => question.mine !== null) + .map(question => [question.id, question.mine as number])); + this.sessionLoaded = true; + } + if (this.current !== null && this.current >= items.length) { + this.current = null; + } + + return {videoId, duration: video.duration ?? null, status: state.status, aids, quiz, items}; + } + + private itemsFor(questions: QuizQuestion[]): QuizItem[] { + if (questions !== this.renderedFrom) { + this.renderedFrom = questions; + this.rendered = questions.map(question => ({ + question, + promptHtml: renderBlock(question.prompt), + promptText: plainText(question.prompt), + choicesHtml: question.choices.map(choice => renderInline(choice)), + explanationHtml: question.explanation ? renderBlock(question.explanation) : null, + })); + } + + return this.rendered; + } +} diff --git a/src/app/shared/lecture-summary.component.html b/src/app/shared/lecture-summary.component.html new file mode 100644 index 0000000..06e92b6 --- /dev/null +++ b/src/app/shared/lecture-summary.component.html @@ -0,0 +1,58 @@ +@if (view$ | async; as view) { + @if (shows(view)) { + + + + @if (!collapsed) { +
+ @if (view.status === 'loading') { +
+ +
+ } @else if (view.status === 'error') { +

The summary could not be loaded.

+ Try again + } @else if (view.summary && view.checklist) { + + @if (view.checklist.pointKeys.length) { +

+ @if (reviewed(view.checklist); as count) { + {{ count }} of {{ view.checklist.pointKeys.length }} points ticked. + + } @else { + Tick each point once you know it. + } +

+ } + + } @else if (view.aids?.can_edit) { +

{{ emptyNote(view.aids!) }}

+ Write a summary + } @else { +

The AI is still writing a summary of this lecture. Check back later.

+ } +
+ } +
+ } +} diff --git a/src/app/shared/lecture-summary.component.scss b/src/app/shared/lecture-summary.component.scss new file mode 100644 index 0000000..366e612 --- /dev/null +++ b/src/app/shared/lecture-summary.component.scss @@ -0,0 +1,18 @@ +@use 'study-card'; + +.review-progress { + margin: -0.25rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.link-button { + margin-left: 0.25rem; + padding: 0; + border: 0; + background: none; + color: var(--ion-color-primary); + font: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/app/shared/lecture-summary.component.spec.ts b/src/app/shared/lecture-summary.component.spec.ts new file mode 100644 index 0000000..1612299 --- /dev/null +++ b/src/app/shared/lecture-summary.component.spec.ts @@ -0,0 +1,115 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModalController} from '@ionic/angular/standalone'; +import {of} from 'rxjs'; +import {LectureSummaryComponent} from './lecture-summary.component'; +import {StudyAidsService} from '../study-aids.service'; +import {Lecture} from '../man.service'; +import {StudyAids, StudyAidsState} from '../study-aids/study-aids.model'; + +const VIDEO = {id: 7, title: 'Organelles'} as Lecture; + +const MARKDOWN = '# Organelles\n\n## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1\n2. **Nucleolus** : rRNA\n'; + +function aids(changes: Partial = {}): StudyAids { + return { + video_id: 7, + can_edit: false, + generation: 'ready', + summary: {markdown: MARKDOWN, revision: 1, origin: 'ai', edited: false, contributors: [], updated_at: null}, + quiz: null, + ...changes, + }; +} + +describe('LectureSummaryComponent', () => { + let fixture: ComponentFixture; + let service: jasmine.SpyObj>; + let seek: jasmine.Spy; + + const element = () => fixture.nativeElement as HTMLElement; + const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; + + function create(state: StudyAidsState): void { + service.watch.and.returnValue(of(state)); + fixture = TestBed.createComponent(LectureSummaryComponent); + fixture.componentRef.setInput('video', VIDEO); + fixture.componentRef.setInput('playback', {currentTime: () => 0, seek}); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.removeItem('flickSummaryTicks:7'); + localStorage.removeItem('flickStudyAidsCollapsed'); + service = jasmine.createSpyObj('StudyAidsService', ['watch', 'refresh']); + seek = jasmine.createSpy('seek'); + TestBed.configureTestingModule({ + imports: [LectureSummaryComponent], + providers: [ + {provide: StudyAidsService, useValue: service}, + {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['create'])}, + ], + }); + }); + + afterEach(() => { + localStorage.removeItem('flickSummaryTicks:7'); + }); + + it('shows the AI draft as a numbered checklist', () => { + create({status: 'ready', aids: aids()}); + + expect(text()).toContain('AI draft'); + expect(text()).toContain('Organelles'); + expect(Array.from(element().querySelectorAll('.point-number')).map(number => number.textContent)).toEqual(['1.', '2.']); + expect(element().querySelector('ion-button')).toBeNull(); + }); + + it('jumps the lecture to a section', () => { + create({status: 'ready', aids: aids()}); + element().querySelector('button.time-chip')?.click(); + + expect(seek).toHaveBeenCalledWith(70); + }); + + it('remembers ticked points on this device', () => { + create({status: 'ready', aids: aids()}); + const box = element().querySelector('.point-tick'); + box!.checked = true; + box!.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(text()).toContain('1 of 2 points ticked.'); + expect(JSON.parse(localStorage.getItem('flickSummaryTicks:7') ?? '[]').length).toBe(1); + }); + + it('names the editors who chose to be credited', () => { + create({status: 'ready', aids: aids({summary: {...aids().summary!, origin: 'editor', edited: true, contributors: [{name: 'Kan'}, {name: 'Chain'}]}})}); + + expect(text()).toContain('By Kan and Chain'); + expect(text()).not.toContain('AI draft'); + }); + + it('shows nothing when there is no summary for a reader', () => { + create({status: 'ready', aids: aids({summary: null})}); + + expect(element().querySelector('ion-card')).toBeNull(); + }); + + it('shows nothing when the server has no study aids', () => { + create({status: 'unavailable'}); + + expect(element().querySelector('ion-card')).toBeNull(); + }); + + it('tells readers a summary is on its way', () => { + create({status: 'ready', aids: aids({summary: null, generation: 'pending'})}); + + expect(text()).toContain('The AI is still writing a summary of this lecture.'); + }); + + it('lets editors write one', () => { + create({status: 'ready', aids: aids({summary: null, can_edit: true})}); + + expect(text()).toContain('Write a summary'); + }); +}); diff --git a/src/app/shared/lecture-summary.component.ts b/src/app/shared/lecture-summary.component.ts new file mode 100644 index 0000000..8e7411f --- /dev/null +++ b/src/app/shared/lecture-summary.component.ts @@ -0,0 +1,160 @@ +import {Component, ElementRef, inject, Input, NgZone} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {IonButton, IonCard, IonIcon, IonSpinner, ModalController} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {chevronDown, chevronUp, createOutline} from 'ionicons/icons'; +import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {ChecklistViewComponent, TickChange} from './checklist-view.component'; +import {SummaryEditorComponent} from './summary-editor.component'; +import {buildChecklist, Checklist} from '../study-aids/checklist'; +import {byline, isAiDraft} from '../study-aids/byline'; +import {displayed} from '../study-aids/displayed'; +import {readCollapsed, readTicks, writeCollapsed, writeTicks} from '../study-aids/local-store'; +import {LecturePlayback, StudyAids, StudyAidsState, SummaryPart} from '../study-aids/study-aids.model'; + +interface SummaryView { + videoId: number; + status: StudyAidsState['status']; + aids: StudyAids | null; + summary: SummaryPart | null; + checklist: Checklist | null; +} + +/** + * The lecture's summary as a checklist, under the video. Readers tick the points they know; + * editors can rewrite it. Shows nothing for a lecture that has no summary. + */ +@Component({ + selector: 'app-lecture-summary', + templateUrl: './lecture-summary.component.html', + styleUrls: ['./lecture-summary.component.scss'], + imports: [AsyncPipe, IonCard, IonButton, IonIcon, IonSpinner, ChecklistViewComponent], +}) +export class LectureSummaryComponent { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + + private readonly video$ = new BehaviorSubject(null); + + @Input() set video(video: Lecture | null) { + this.video$.next(video); + } + + @Input() playback: LecturePlayback | null = null; + + protected collapsed = readCollapsed('summary'); + protected ticked: ReadonlySet = new Set(); + protected readonly byline = byline; + protected readonly isAiDraft = isAiDraft; + + private tickedVideo: number | null = null; + private builtFrom: string | null = null; + private built: Checklist | null = null; + + protected readonly view$: Observable = combineLatest([ + this.video$, + displayed(inject>(ElementRef).nativeElement, inject(NgZone)), + ]).pipe( + switchMap(([video, shown]) => video?.id && shown + ? this.service.watch(video.id).pipe(map(state => this.toView(video.id as number, state))) + : of(null)), + ); + + constructor() { + addIcons({chevronDown, chevronUp, createOutline}); + } + + shows(view: SummaryView): boolean { + if (view.status !== 'ready') { + return view.status !== 'unavailable'; + } + + return !!view.summary || !!view.aids?.can_edit || view.aids?.generation === 'pending'; + } + + reviewed(checklist: Checklist): number { + return checklist.pointKeys.filter(key => this.ticked.has(key)).length; + } + + emptyNote(aids: StudyAids): string { + switch (aids.generation) { + case 'pending': + return 'The AI is still drafting a summary. If you write one now, the AI draft will not replace it.'; + case 'failed': + return 'The AI could not draft a summary of this lecture.'; + default: + return 'There is no summary of this lecture yet.'; + } + } + + toggle(): void { + this.collapsed = !this.collapsed; + writeCollapsed('summary', this.collapsed); + } + + onTick(view: SummaryView, change: TickChange): void { + const ticked = new Set(this.ticked); + if (change.ticked) { + ticked.add(change.key); + } else { + ticked.delete(change.key); + } + this.ticked = ticked; + writeTicks(view.videoId, ticked); + } + + clearTicks(view: SummaryView): void { + this.ticked = new Set(); + writeTicks(view.videoId, this.ticked); + } + + seek(seconds: number): void { + this.playback?.seek(seconds); + } + + retry(view: SummaryView): void { + this.service.refresh(view.videoId); + } + + async edit(view: SummaryView): Promise { + if (!view.aids) { + return; + } + const modal = await this.modalCtrl.create({ + component: SummaryEditorComponent, + cssClass: 'study-editor-modal', + backdropDismiss: false, + componentProps: { + videoId: view.videoId, + summary: view.summary, + generation: view.aids.generation, + playback: this.playback, + }, + }); + await modal.present(); + } + + private toView(videoId: number, state: StudyAidsState): SummaryView { + if (videoId !== this.tickedVideo) { + this.tickedVideo = videoId; + this.ticked = readTicks(videoId); + } + const aids = state.status === 'ready' ? state.aids : null; + const summary = aids?.summary ?? null; + + return {videoId, status: state.status, aids, summary, checklist: summary ? this.checklistFor(summary.markdown) : null}; + } + + /** Parsing a long summary is not free, so it is redone only when the text changes. */ + private checklistFor(markdown: string): Checklist { + if (markdown !== this.builtFrom || !this.built) { + this.builtFrom = markdown; + this.built = buildChecklist(markdown); + } + + return this.built; + } +} diff --git a/src/app/shared/quiz-editor.component.html b/src/app/shared/quiz-editor.component.html new file mode 100644 index 0000000..97a8771 --- /dev/null +++ b/src/app/shared/quiz-editor.component.html @@ -0,0 +1,126 @@ + + + + Cancel + + {{ quiz?.questions?.length ? 'Edit questions' : 'Add questions' }} + + + @if (saving) { + + } @else { + Save + } + + + + + + +
+ @if (notice) { +

{{ notice }}

+ } + @if (problem) { + + } + @if (removed) { +

+ Question removed. + Undo +

+ } + + @for (form of forms; track form.key; let index = $index, first = $first, last = $last) { +
+
+

Question {{ index + 1 }}

+ + + + + + + + + + + +
+ + + +
+ Choices, with the correct answer selected + @for (choice of form.choices; track $index; let c = $index) { +
+ + + + + +
+ } + @if (form.choices.length < limits.maxChoices) { + + + Add a choice + + } +
+ + + +
+ + @if (playback) { + + + Use current time + + } +
+

Readers who answer wrongly can jump to this part of the lecture.

+ + @if (form.stats && form.stats.answered) { +

+ {{ form.stats.correct }} of {{ form.stats.answered }} students got this right. + @if (resetsStats(form)) { + Saving starts these numbers again, because the question, its choices or its answer changed. + } +

+ } + + @let problems = problemsOf(form, index); + @if (problems.length) { + + } +
+ } @empty { +

There are no questions yet.

+ } + + + + Add a question + + + +
+
diff --git a/src/app/shared/quiz-editor.component.scss b/src/app/shared/quiz-editor.component.scss new file mode 100644 index 0000000..470070b --- /dev/null +++ b/src/app/shared/quiz-editor.component.scss @@ -0,0 +1,146 @@ +@use 'study-editor'; + +.quiz-editor { + max-width: 52rem; + margin: 0 auto; + + .undo-bar { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0 0 0.75rem; + font-size: 0.85rem; + + ion-button { + margin: 0; + } + } + + .question-form { + display: grid; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.85rem 1rem 1rem; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.14); + border-radius: 0.75rem; + } + + .question-head { + display: flex; + align-items: center; + justify-content: space-between; + + h2 { + margin: 0; + font-size: 1rem; + font-weight: 700; + } + + ion-button { + margin: 0; + } + } + + .choices { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + border: 0; + + legend { + margin-bottom: 0.4rem; + padding: 0; + color: var(--ion-color-medium); + font-size: 0.8rem; + } + + > ion-button { + justify-self: start; + margin: 0; + } + } + + .choice-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + + ion-button { + margin: 0; + } + } + + .answer-pick { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + + input { + width: 1.05rem; + height: 1.05rem; + margin: 0; + accent-color: var(--ion-color-success); + cursor: pointer; + } + } + + .answer-letter { + width: 1.1em; + font-weight: 700; + text-align: center; + } + + .is-answer .answer-letter { + color: var(--ion-color-success); + } + + .time-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; + + ion-input { + flex: 1 1 12rem; + max-width: 16rem; + } + + ion-button { + margin: 0 0 0.35rem; + } + } + + .field-hint { + margin-top: -0.5rem; + } + + .stats-note { + margin: 0; + color: var(--ion-color-medium); + font-size: 0.8rem; + line-height: 1.4; + + &.resets { + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: rgba(var(--ion-color-warning-rgb), 0.18); + color: var(--ion-text-color); + } + } + + .question-problems { + margin: 0; + padding: 0.5rem 0.75rem 0.5rem 1.75rem; + border-radius: 0.5rem; + background: rgba(var(--ion-color-danger-rgb), 0.1); + font-size: 0.8rem; + line-height: 1.45; + } + + .add-question { + margin: 0; + } +} diff --git a/src/app/shared/quiz-editor.component.ts b/src/app/shared/quiz-editor.component.ts new file mode 100644 index 0000000..088f990 --- /dev/null +++ b/src/app/shared/quiz-editor.component.ts @@ -0,0 +1,366 @@ +import {afterNextRender, Component, inject, Injector, Input, OnInit, ViewEncapsulation} from '@angular/core'; +import { + AlertController, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonInput, + IonSpinner, + IonTextarea, + IonTitle, + IonToolbar, + ModalController, +} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {addOutline, arrowDown, arrowUp, closeOutline, timeOutline, trashOutline} from 'ionicons/icons'; +import {StudyAidsService} from '../study-aids.service'; +import {CreditFieldComponent} from './credit-field.component'; +import {formatStamp} from '../study-aids/checklist'; +import {creditFields, joinNames} from '../study-aids/byline'; +import {readCreditChoice, writeCreditChoice} from '../study-aids/local-store'; +import { + blankQuestionForm, + CHOICE_LETTERS, + errorsByQuestion, + QuestionForm, + questionChange, + questionForm, + questionProblems, + resetsStats, +} from '../study-aids/quiz'; +import {CreditChoice, GenerationState, LecturePlayback, LIMITS, QuizPart, SaveFailure} from '../study-aids/study-aids.model'; + +function inputValue(event: Event): string { + return (event as CustomEvent<{value: string | null}>).detail.value ?? ''; +} + +/** + * Editors add, remove, reorder and correct the lecture's questions here, and save them all + * at once. + */ +@Component({ + selector: 'app-quiz-editor', + templateUrl: './quiz-editor.component.html', + styleUrls: ['./quiz-editor.component.scss'], + // Sizes the ion-modal, which sits outside this component (see _study-editor.scss) + encapsulation: ViewEncapsulation.None, + imports: [ + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonButton, + IonContent, + IonIcon, + IonInput, + IonTextarea, + IonSpinner, + CreditFieldComponent, + ], +}) +export class QuizEditorComponent implements OnInit { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + private alertCtrl = inject(AlertController); + private injector = inject(Injector); + + @Input() videoId: number; + @Input() quiz: QuizPart | null = null; + @Input() generation: GenerationState = 'none'; + /** The lecture's length in seconds, when known, to check the video times against. */ + @Input() duration: number | null = null; + @Input() playback: LecturePlayback | null = null; + + protected forms: QuestionForm[] = []; + protected credit: CreditChoice = readCreditChoice(); + protected saving = false; + protected problem: string | null = null; + /** Problems are pointed out once the editor first tries to save, not while typing. */ + protected checked = false; + protected removed: {form: QuestionForm; index: number} | null = null; + protected readonly letters = CHOICE_LETTERS; + protected readonly limits = LIMITS; + protected readonly resetsStats = resetsStats; + + private serverProblems = new Map(); + private nextKey = 0; + private baseRevision = 0; + private savedSnapshot = ''; + private readonly savedCredit = this.credit; + + constructor() { + addIcons({addOutline, arrowDown, arrowUp, closeOutline, timeOutline, trashOutline}); + } + + ngOnInit(): void { + this.load(this.quiz); + } + + get notice(): string | null { + if (!this.quiz) { + return this.generation === 'pending' + ? 'The AI is still drafting questions. Once you save yours, the AI draft will not replace them.' + : null; + } + + return this.quiz.origin === 'ai' && !this.quiz.edited + ? 'These are the AI\'s questions. Once you save a change, the AI will not rewrite them.' + : null; + } + + get maxTime(): number { + return this.duration && this.duration > 0 ? Math.floor(this.duration) : LIMITS.maxTime; + } + + get changed(): boolean { + return this.snapshot() !== this.savedSnapshot + || this.credit.credit !== this.savedCredit.credit + || (this.credit.credit && this.credit.name.trim() !== this.savedCredit.name.trim()); + } + + problemsOf(form: QuestionForm, index: number): string[] { + return [ + ...(this.checked ? questionProblems(form, this.maxTime) : []), + ...(this.serverProblems.get(index) ?? []), + ]; + } + + add(): void { + const form = blankQuestionForm(this.nextKey++); + this.forms = [...this.forms, form]; + this.edited(); + this.reveal(form); + } + + remove(index: number): void { + this.removed = {form: this.forms[index], index}; + this.forms = this.forms.filter((form, i) => i !== index); + this.edited(); + } + + undoRemove(): void { + if (!this.removed) { + return; + } + const {form, index} = this.removed; + this.forms = [...this.forms.slice(0, index), form, ...this.forms.slice(index)]; + this.removed = null; + this.edited(); + } + + move(index: number, offset: number): void { + const target = index + offset; + if (target < 0 || target >= this.forms.length) { + return; + } + const forms = [...this.forms]; + [forms[index], forms[target]] = [forms[target], forms[index]]; + this.forms = forms; + this.edited(); + } + + setPrompt(form: QuestionForm, event: Event): void { + form.prompt = inputValue(event); + this.edited(); + } + + setChoice(form: QuestionForm, index: number, event: Event): void { + form.choices[index] = inputValue(event); + this.edited(); + } + + setAnswer(form: QuestionForm, index: number): void { + form.answer = index; + this.edited(); + } + + addChoice(form: QuestionForm): void { + if (form.choices.length < LIMITS.maxChoices) { + form.choices = [...form.choices, '']; + this.edited(); + } + } + + removeChoice(form: QuestionForm, index: number): void { + if (form.choices.length <= LIMITS.minChoices) { + return; + } + form.choices = form.choices.filter((choice, i) => i !== index); + if (form.answer === index) { + form.answer = null; + } else if (form.answer !== null && form.answer > index) { + form.answer--; + } + this.edited(); + } + + setExplanation(form: QuestionForm, event: Event): void { + form.explanation = inputValue(event); + this.edited(); + } + + setTime(form: QuestionForm, event: Event): void { + form.time = inputValue(event); + this.edited(); + } + + useCurrentTime(form: QuestionForm): void { + if (this.playback) { + form.time = formatStamp(this.playback.currentTime()); + this.edited(); + } + } + + async save(): Promise { + if (this.saving) { + return; + } + this.checked = true; + const invalid = this.forms + .map((form, index) => questionProblems(form, this.maxTime).length ? index + 1 : 0) + .filter(number => number > 0); + if (invalid.length) { + this.problem = `Fix ${invalid.length === 1 ? 'question' : 'questions'} ${joinNames(invalid.map(String))} first.`; + this.reveal(this.forms[invalid[0] - 1]); + + return; + } + if (this.forms.length > LIMITS.questions) { + this.problem = `A quiz can have at most ${LIMITS.questions} questions.`; + + return; + } + if (!this.forms.length && this.quiz?.questions.length && !(await this.confirmRemoveAll())) { + return; + } + + this.saving = true; + this.problem = null; + this.serverProblems.clear(); + this.service.saveQuiz(this.videoId, { + questions: this.forms.map(questionChange), + revision: this.baseRevision, + ...creditFields(this.credit), + }).subscribe({ + next: () => { + this.saving = false; + writeCreditChoice(this.credit); + void this.modalCtrl.dismiss(null, 'saved'); + }, + error: (failure: SaveFailure) => { + this.saving = false; + void this.explain(failure); + }, + }); + } + + async cancel(): Promise { + if (this.snapshot() !== this.savedSnapshot) { + const alert = await this.alertCtrl.create({ + header: 'Discard your changes?', + buttons: [ + {text: 'Keep editing', role: 'cancel'}, + {text: 'Discard', role: 'destructive'}, + ], + }); + await alert.present(); + if ((await alert.onDidDismiss()).role !== 'destructive') { + return; + } + } + await this.modalCtrl.dismiss(null, 'cancel'); + } + + private load(quiz: QuizPart | null): void { + this.forms = (quiz?.questions ?? []).map(question => questionForm(question, this.nextKey++)); + this.baseRevision = quiz?.revision ?? 0; + this.savedSnapshot = this.snapshot(); + this.serverProblems.clear(); + this.removed = null; + this.checked = false; + } + + /** Problems FlickMan reported are tied to positions, which an edit may have changed. */ + private edited(): void { + this.serverProblems.clear(); + } + + private snapshot(): string { + return JSON.stringify(this.forms.map(form => [form.id, form.prompt, form.choices, form.answer, form.explanation, form.time])); + } + + private reveal(form: QuestionForm): void { + afterNextRender(() => document.getElementById('question-' + form.key) + ?.scrollIntoView({behavior: 'smooth', block: 'center'}), {injector: this.injector}); + } + + private async confirmRemoveAll(): Promise { + const alert = await this.alertCtrl.create({ + header: 'Remove every question?', + message: 'Readers will not see a quiz for this lecture.', + buttons: [ + {text: 'Keep editing', role: 'cancel'}, + {text: 'Remove all', role: 'destructive'}, + ], + }); + await alert.present(); + + return (await alert.onDidDismiss()).role === 'destructive'; + } + + private async explain(failure: SaveFailure): Promise { + switch (failure.kind) { + case 'conflict': + return this.resolveConflict(failure.current as QuizPart | null); + case 'invalid': { + const {byQuestion, general} = errorsByQuestion(failure.errors); + this.serverProblems = byQuestion; + this.problem = general.length ? general.join(' ') : failure.message; + break; + } + case 'forbidden': + this.problem = 'This account can no longer edit questions.'; + break; + case 'rate-limited': + this.problem = 'Too many saves in a short time. Wait a minute, then save again.'; + break; + case 'network': + this.problem = 'Flick could not reach the server. Check your connection, then save again.'; + break; + default: + this.problem = failure.message; + } + } + + private async resolveConflict(current: QuizPart | null): Promise { + const alert = await this.alertCtrl.create({ + header: 'Someone else saved first', + message: 'Another editor changed these questions while you were editing them.', + backdropDismiss: false, + buttons: [ + {text: 'Keep editing', role: 'cancel'}, + {text: 'Discard mine, load theirs', role: 'theirs'}, + {text: 'Replace theirs with mine', role: 'mine'}, + ], + }); + await alert.present(); + const {role} = await alert.onDidDismiss(); + if (role === 'theirs') { + this.load(current); + } else if (role === 'mine') { + // Questions they removed or the AI replaced are sent as new ones. + const ids = new Set(current?.questions.map(question => question.id) ?? []); + for (const form of this.forms) { + if (form.id && !ids.has(form.id)) { + form.id = null; + form.stats = null; + form.original = null; + } + } + this.baseRevision = current?.revision ?? 0; + await this.save(); + } + } +} diff --git a/src/app/shared/rich-text.component.html b/src/app/shared/rich-text.component.html new file mode 100644 index 0000000..11df65a --- /dev/null +++ b/src/app/shared/rich-text.component.html @@ -0,0 +1 @@ +
diff --git a/src/app/shared/rich-text.component.scss b/src/app/shared/rich-text.component.scss new file mode 100644 index 0000000..417ec04 --- /dev/null +++ b/src/app/shared/rich-text.component.scss @@ -0,0 +1,66 @@ +@import 'katex/dist/katex.min.css'; + +app-rich-text { + display: block; + overflow-wrap: anywhere; + + &.inline, + &.inline > .rich-text-body { + display: inline; + } + + p { + margin: 0 0 0.5em; + } + + p:last-child { + margin-bottom: 0; + } + + mark { + padding: 0 0.15em; + border-radius: 0.2em; + background: rgba(var(--ion-color-warning-rgb), 0.35); + color: inherit; + } + + a { + color: var(--ion-color-primary); + } + + code { + padding: 0.1em 0.3em; + border-radius: 0.25em; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.07); + font-size: 0.9em; + } + + .math-display { + overflow-x: auto; + overflow-y: hidden; + margin: 0.5em 0; + } + + .table-scroll { + overflow-x: auto; + margin: 0.5em 0; + } + + table { + border-collapse: collapse; + font-size: 0.9em; + } + + th, + td { + padding: 0.35em 0.6em; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.15); + text-align: left; + vertical-align: top; + } + + th { + background: rgba(var(--ion-color-primary-rgb), 0.12); + font-weight: 700; + } +} diff --git a/src/app/shared/rich-text.component.ts b/src/app/shared/rich-text.component.ts new file mode 100644 index 0000000..eeb40c0 --- /dev/null +++ b/src/app/shared/rich-text.component.ts @@ -0,0 +1,24 @@ +import {afterNextRender, Component, ElementRef, inject, Injector, Input, OnChanges, ViewEncapsulation} from '@angular/core'; +import {renderMath} from '../home/course/markdown-math'; + +/** + * HTML rendered from a summary's or a question's Markdown. Angular sanitizes it on the way in, and + * the maths is drawn afterwards because KaTeX's markup would not survive the sanitizer. + */ +@Component({ + selector: 'app-rich-text', + templateUrl: './rich-text.component.html', + styleUrls: ['./rich-text.component.scss'], + // [innerHTML] content and KaTeX's output carry no scoping attributes, so scoped styles would never match them + encapsulation: ViewEncapsulation.None, +}) +export class RichTextComponent implements OnChanges { + private host = inject>(ElementRef); + private injector = inject(Injector); + + @Input() html = ''; + + ngOnChanges() { + afterNextRender(() => renderMath(this.host.nativeElement), {injector: this.injector}); + } +} diff --git a/src/app/shared/summary-editor.component.html b/src/app/shared/summary-editor.component.html new file mode 100644 index 0000000..025f791 --- /dev/null +++ b/src/app/shared/summary-editor.component.html @@ -0,0 +1,82 @@ + + + + Cancel + + {{ summary ? 'Edit summary' : 'Write a summary' }} + + + @if (saving) { + + } @else { + Save + } + + + + + + +
+ @if (notice) { +

{{ notice }}

+ } + @if (problem) { + + } + + + Write + Preview + + +
+
+
+ Key term + Highlight + @if (playback) { + Insert video time + } + + Formatting help + +
+ @if (showHelp) { +
+
## Heading [00:12:34]
+
A highlighted section. The time is optional and lets readers jump to that part of the lecture.
+
1. Term : fact
+
A point. Points are numbered through the whole summary for you.
+
   1. detail
+
A sub-point: indent it three spaces under its point.
+
### Label
+
A small underlined label inside a section.
+
**term**
+
A key term.
+
==text==
+
Highlighted, for what matters in the exam.
+
| A | B |
+
A table: a header row, then |---|---|, then one row per line.
+
$x^2$
+
Maths.
+
+ } + +

+ {{ text.length | number }} / {{ limit | number }} characters +

+
+
+ @if (preview.title || preview.sections.length) { + + } @else { +

The checklist appears here as you write.

+ } +
+
+ + +
+
diff --git a/src/app/shared/summary-editor.component.scss b/src/app/shared/summary-editor.component.scss new file mode 100644 index 0000000..fdff299 --- /dev/null +++ b/src/app/shared/summary-editor.component.scss @@ -0,0 +1,112 @@ +@use 'study-editor'; + +.summary-editor { + .editor-mode { + margin-bottom: 0.75rem; + } + + .editor-panes { + display: grid; + gap: 1rem; + } + + .editor-panes:not(.showing-preview) .preview-pane, + .editor-panes.showing-preview .write-pane { + display: none; + } + + .format-bar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.5rem; + + ion-button { + margin: 0; + } + } + + .format-help { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.35rem 0.75rem; + margin: 0 0 0.75rem; + padding: 0.75rem; + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); + font-size: 0.8rem; + line-height: 1.4; + + dt { + white-space: nowrap; + } + + dd { + margin: 0; + } + } + + .markdown-input { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 55vh; + padding: 0.75rem; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.2); + border-radius: 0.5rem; + background: transparent; + color: var(--ion-text-color); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + line-height: 1.55; + resize: vertical; + tab-size: 3; + + &:focus { + border-color: var(--ion-color-primary); + outline: none; + } + } + + .char-count { + margin: 0.35rem 0 0; + color: var(--ion-color-medium); + font-size: 0.75rem; + text-align: right; + + &.over-limit { + color: var(--ion-color-danger); + font-weight: 600; + } + } + + .preview-pane { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.03); + } + + // Wide enough to write and see the result side by side. + @media (min-width: 992px) { + .editor-mode { + display: none; + } + + .editor-panes { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } + + .editor-panes:not(.showing-preview) .preview-pane, + .editor-panes.showing-preview .write-pane { + display: block; + } + + .preview-pane { + position: sticky; + top: 0; + max-height: calc(92vh - 9rem); + overflow-y: auto; + } + } +} diff --git a/src/app/shared/summary-editor.component.ts b/src/app/shared/summary-editor.component.ts new file mode 100644 index 0000000..2fd2585 --- /dev/null +++ b/src/app/shared/summary-editor.component.ts @@ -0,0 +1,232 @@ +import {Component, ElementRef, inject, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation} from '@angular/core'; +import {DecimalPipe} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import { + AlertController, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonLabel, + IonSegment, + IonSegmentButton, + IonSpinner, + IonTitle, + IonToolbar, + ModalController, +} from '@ionic/angular/standalone'; +import {StudyAidsService} from '../study-aids.service'; +import {ChecklistViewComponent} from './checklist-view.component'; +import {CreditFieldComponent} from './credit-field.component'; +import {buildChecklist, Checklist, formatStamp} from '../study-aids/checklist'; +import {creditFields} from '../study-aids/byline'; +import {readCreditChoice, writeCreditChoice} from '../study-aids/local-store'; +import {CreditChoice, GenerationState, LecturePlayback, LIMITS, SaveFailure, SummaryPart} from '../study-aids/study-aids.model'; + +/** Editors rewrite the summary's Markdown here, with a live preview of the checklist it makes. */ +@Component({ + selector: 'app-summary-editor', + templateUrl: './summary-editor.component.html', + styleUrls: ['./summary-editor.component.scss'], + // Sizes the ion-modal, which sits outside this component (see _study-editor.scss) + encapsulation: ViewEncapsulation.None, + imports: [ + DecimalPipe, + FormsModule, + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonButton, + IonContent, + IonSegment, + IonSegmentButton, + IonLabel, + IonSpinner, + ChecklistViewComponent, + CreditFieldComponent, + ], +}) +export class SummaryEditorComponent implements OnInit, OnDestroy { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + private alertCtrl = inject(AlertController); + + @Input() videoId: number; + @Input() summary: SummaryPart | null = null; + @Input() generation: GenerationState = 'none'; + @Input() playback: LecturePlayback | null = null; + + @ViewChild('input') input: ElementRef; + + protected readonly limit = LIMITS.summaryChars; + protected text = ''; + protected mode: 'write' | 'preview' = 'write'; + protected preview: Checklist = buildChecklist(''); + protected credit: CreditChoice = readCreditChoice(); + protected saving = false; + protected problem: string | null = null; + protected showHelp = false; + + private baseRevision = 0; + private savedText = ''; + private readonly savedCredit = this.credit; + private previewTimer: ReturnType | undefined; + + ngOnInit(): void { + this.text = this.savedText = this.summary?.markdown ?? ''; + this.baseRevision = this.summary?.revision ?? 0; + this.preview = buildChecklist(this.text); + } + + ngOnDestroy(): void { + clearTimeout(this.previewTimer); + } + + get notice(): string | null { + if (!this.summary) { + return this.generation === 'pending' + ? 'The AI is still drafting a summary. Once you save yours, the AI draft will not replace it.' + : null; + } + + return this.summary.origin === 'ai' && !this.summary.edited + ? 'This is the AI draft. Once you save a change, the AI will not rewrite it.' + : null; + } + + get changed(): boolean { + return this.text !== this.savedText + || this.credit.credit !== this.savedCredit.credit + || (this.credit.credit && this.credit.name.trim() !== this.savedCredit.name.trim()); + } + + canSave(): boolean { + return !this.saving && this.changed && !!this.text.trim() && this.text.length <= this.limit; + } + + onTextChange(): void { + this.problem = null; + clearTimeout(this.previewTimer); + this.previewTimer = setTimeout(() => this.preview = buildChecklist(this.text), 300); + } + + onModeChange(event: Event): void { + this.mode = (event as CustomEvent<{value: string}>).detail.value === 'preview' ? 'preview' : 'write'; + if (this.mode === 'preview') { + clearTimeout(this.previewTimer); + this.preview = buildChecklist(this.text); + } + } + + /** Wraps the selection in a marker such as `**`, or inserts an example to type over. */ + wrap(marker: string): void { + const input = this.input.nativeElement; + const start = input.selectionStart; + const selected = input.value.slice(start, input.selectionEnd) || 'text'; + input.setRangeText(marker + selected + marker, start, input.selectionEnd, 'end'); + input.focus(); + input.setSelectionRange(start + marker.length, start + marker.length + selected.length); + // Lets ngModel see the change. + input.dispatchEvent(new Event('input')); + } + + /** Puts the video's current time at the cursor, for the end of a section heading. */ + insertTime(): void { + if (!this.playback) { + return; + } + const input = this.input.nativeElement; + const before = input.value.slice(0, input.selectionStart); + const stamp = (before && !/\s$/.test(before) ? ' ' : '') + '[' + formatStamp(this.playback.currentTime()) + ']'; + input.setRangeText(stamp, input.selectionStart, input.selectionEnd, 'end'); + input.focus(); + input.dispatchEvent(new Event('input')); + } + + save(): void { + if (!this.canSave()) { + return; + } + this.saving = true; + this.problem = null; + this.service.saveSummary(this.videoId, { + markdown: this.text.trim(), + revision: this.baseRevision, + ...creditFields(this.credit), + }).subscribe({ + next: () => { + this.saving = false; + writeCreditChoice(this.credit); + void this.modalCtrl.dismiss(null, 'saved'); + }, + error: (failure: SaveFailure) => { + this.saving = false; + void this.explain(failure); + }, + }); + } + + async cancel(): Promise { + if (this.text !== this.savedText) { + const alert = await this.alertCtrl.create({ + header: 'Discard your changes?', + buttons: [ + {text: 'Keep editing', role: 'cancel'}, + {text: 'Discard', role: 'destructive'}, + ], + }); + await alert.present(); + if ((await alert.onDidDismiss()).role !== 'destructive') { + return; + } + } + await this.modalCtrl.dismiss(null, 'cancel'); + } + + private async explain(failure: SaveFailure): Promise { + switch (failure.kind) { + case 'conflict': + return this.resolveConflict(failure.current as SummaryPart | null); + case 'invalid': { + const details = Object.values(failure.errors).flat(); + this.problem = details.length ? details.join(' ') : failure.message; + break; + } + case 'forbidden': + this.problem = 'This account can no longer edit summaries. Copy your text if you want to keep it.'; + break; + case 'rate-limited': + this.problem = 'Too many saves in a short time. Wait a minute, then save again.'; + break; + case 'network': + this.problem = 'Flick could not reach the server. Check your connection, then save again.'; + break; + default: + this.problem = failure.message; + } + } + + private async resolveConflict(current: SummaryPart | null): Promise { + const alert = await this.alertCtrl.create({ + header: 'Someone else saved first', + message: 'Another editor changed this summary while you were editing it.', + backdropDismiss: false, + buttons: [ + {text: 'Keep editing', role: 'cancel'}, + {text: 'Discard mine, load theirs', role: 'theirs'}, + {text: 'Replace theirs with mine', role: 'mine'}, + ], + }); + await alert.present(); + const {role} = await alert.onDidDismiss(); + if (role === 'theirs') { + this.text = this.savedText = current?.markdown ?? ''; + this.baseRevision = current?.revision ?? 0; + this.preview = buildChecklist(this.text); + } else if (role === 'mine') { + this.baseRevision = current?.revision ?? 0; + this.save(); + } + } +} diff --git a/src/app/study-aids.service.spec.ts b/src/app/study-aids.service.spec.ts new file mode 100644 index 0000000..0f5cd82 --- /dev/null +++ b/src/app/study-aids.service.spec.ts @@ -0,0 +1,189 @@ +import {TestBed} from '@angular/core/testing'; +import {HttpErrorResponse} from '@angular/common/http'; +import {Observable, of, throwError} from 'rxjs'; +import {ManService} from './man.service'; +import {StudyAidsService} from './study-aids.service'; +import {SaveFailure, StudyAidsState, SummaryPart} from './study-aids/study-aids.model'; + +const SUMMARY = { + markdown: '## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1', + revision: 1, + origin: 'ai', + edited: false, + contributors: [], + updated_at: '2026-10-08T14:41:13+07:00', +}; + +const QUESTION = { + id: '01M4D7B2STKMV08W6Z0ZF3G56N', + prompt: 'เซลล์ชนิดใดมี smooth ER มากที่สุด', + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer: 2, + explanation: null, + time: 465, + stats: {answered: 12, correct: 10}, + mine: null, +}; + +function aidsResponse(changes: object = {}) { + return { + status: 'success', + data: { + video_id: 7, + can_edit: true, + generation: 'ready', + summary: SUMMARY, + quiz: {questions: [QUESTION], revision: 2, origin: 'ai', edited: true, contributors: [{name: 'Kan & Chain'}], updated_at: null}, + ...changes, + }, + }; +} + +function httpError(status: number, body: unknown = null): Observable { + return throwError(() => new HttpErrorResponse({status, error: body})); +} + +describe('StudyAidsService', () => { + let service: StudyAidsService; + let man: jasmine.SpyObj>; + + const latest = (videoId: number): StudyAidsState => { + let state: StudyAidsState = {status: 'loading'}; + service.watch(videoId).subscribe(value => state = value).unsubscribe(); + + return state; + }; + + beforeEach(() => { + man = jasmine.createSpyObj('ManService', ['get', 'post']); + man.get.and.returnValue(of(aidsResponse())); + TestBed.configureTestingModule({ + providers: [{provide: ManService, useValue: man}], + }); + service = TestBed.inject(StudyAidsService); + }); + + describe('watch', () => { + it('reads a lecture\'s summary and quiz', () => { + const state = latest(7); + + expect(man.get).toHaveBeenCalledWith('v1/study_aids/7'); + expect(state.status).toBe('ready'); + if (state.status === 'ready') { + expect(state.aids.can_edit).toBeTrue(); + expect(state.aids.summary?.markdown).toBe(SUMMARY.markdown); + expect(state.aids.quiz?.questions[0].time).toBe(465); + expect(state.aids.quiz?.contributors).toEqual([{name: 'Kan & Chain'}]); + } + }); + + it('asks once for both cards', () => { + latest(7); + latest(7); + + expect(man.get).toHaveBeenCalledTimes(1); + }); + + it('drops a malformed question and keeps the rest', () => { + man.get.and.returnValue(of(aidsResponse({ + quiz: {questions: [QUESTION, {...QUESTION, id: 'bad', answer: 9}], revision: 1, origin: 'ai', edited: false, contributors: []}, + }))); + const state = latest(7); + + expect(state.status === 'ready' && state.aids.quiz?.questions.map(question => question.id)).toEqual([QUESTION.id]); + }); + + it('shows nothing for a lecture FlickMan does not know', () => { + man.get.and.returnValue(httpError(404, {status: 'error', message: 'No such video.'})); + + expect(latest(7).status).toBe('unavailable'); + man.get.and.returnValue(of(aidsResponse())); + expect(latest(8).status).toBe('ready'); + }); + + it('stops asking once the server turns out to have no study aids', () => { + man.get.and.returnValue(httpError(404, {message: 'Not Found'})); + + expect(latest(7).status).toBe('unavailable'); + expect(latest(8).status).toBe('unavailable'); + expect(man.get).toHaveBeenCalledTimes(1); + }); + + it('asks again after a failure', () => { + man.get.and.returnValue(httpError(500)); + + expect(latest(7).status).toBe('error'); + man.get.and.returnValue(of(aidsResponse())); + expect(latest(7).status).toBe('ready'); + }); + }); + + describe('saveSummary', () => { + const change = {markdown: '## New', revision: 1, credit: true, credit_name: 'Kan'}; + + it('sends the change and shows the saved summary', () => { + latest(7); + man.post.and.returnValue(of({status: 'success', data: {...SUMMARY, markdown: '## New', revision: 2, edited: true, contributors: [{name: 'Kan'}]}})); + let saved: SummaryPart | undefined; + service.saveSummary(7, change).subscribe(part => saved = part); + + expect(man.post).toHaveBeenCalledWith('v1/study_aids/7/summary', change); + expect(saved?.revision).toBe(2); + const state = latest(7); + expect(state.status === 'ready' && state.aids.summary?.markdown).toBe('## New'); + }); + + it('reports a conflict with the version saved first', () => { + latest(7); + man.post.and.returnValue(httpError(409, {status: 'error', message: 'Stale', data: {...SUMMARY, revision: 5, markdown: '## Theirs'}})); + let failure: SaveFailure | undefined; + service.saveSummary(7, change).subscribe({error: error => failure = error}); + + expect(failure?.kind).toBe('conflict'); + expect(failure?.kind === 'conflict' && failure.current?.revision).toBe(5); + const state = latest(7); + expect(state.status === 'ready' && state.aids.summary?.markdown).toBe('## Theirs'); + }); + + it('passes on validation messages', () => { + man.post.and.returnValue(httpError(422, {status: 'error', message: 'Invalid', errors: {markdown: ['The markdown is required.']}})); + let failure: SaveFailure | undefined; + service.saveSummary(7, change).subscribe({error: error => failure = error}); + + expect(failure).toEqual({kind: 'invalid', message: 'Invalid', errors: {markdown: ['The markdown is required.']}}); + }); + + it('tells a rate limit and a lost connection apart', () => { + let failure: SaveFailure | undefined; + man.post.and.returnValue(httpError(429, {message: 'Too Many Attempts.'})); + service.saveSummary(7, change).subscribe({error: error => failure = error}); + expect(failure?.kind).toBe('rate-limited'); + + man.post.and.returnValue(httpError(0)); + service.saveSummary(7, change).subscribe({error: error => failure = error}); + expect(failure?.kind).toBe('network'); + }); + }); + + describe('answer', () => { + it('records the answer and shows the new class stats', () => { + latest(7); + man.post.and.returnValue(of({status: 'success', data: {answered: 13, correct: 11}})); + service.answer(7, QUESTION.id, 2).subscribe(); + + expect(man.post).toHaveBeenCalledWith('v1/study_aids/7/answers', {question_id: QUESTION.id, choice: 2}); + const state = latest(7); + const question = state.status === 'ready' ? state.aids.quiz?.questions[0] : undefined; + expect(question?.stats).toEqual({answered: 13, correct: 11}); + expect(question?.mine).toBe(2); + }); + + it('reloads the quiz when the question has gone', () => { + latest(7); + man.post.and.returnValue(httpError(404, {status: 'error', message: 'No such question.'})); + service.answer(7, QUESTION.id, 2).subscribe(); + + expect(man.get).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/app/study-aids.service.ts b/src/app/study-aids.service.ts new file mode 100644 index 0000000..3186d23 --- /dev/null +++ b/src/app/study-aids.service.ts @@ -0,0 +1,219 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpErrorResponse} from '@angular/common/http'; +import {BehaviorSubject, Observable, of, Subscription, throwError} from 'rxjs'; +import {catchError, map, tap} from 'rxjs/operators'; +import {JSend, ManService} from './man.service'; +import {isObject, parseErrors, parseQuiz, parseStats, parseStudyAids, parseSummary, text} from './study-aids/parse'; +import { + AnswerStats, + QuizChange, + QuizPart, + QuizQuestion, + SaveFailure, + StudyAids, + StudyAidsState, + SummaryChange, + SummaryPart, +} from './study-aids/study-aids.model'; + +/** A lecture's study aids are fetched again when a card shows them after this long. */ +const FRESH_FOR_MS = 5 * 60 * 1000; + +interface Entry { + state: BehaviorSubject; + fetchedAt: number; + request: Subscription | null; +} + +function path(videoId: number): string { + return 'v1/study_aids/' + videoId; +} + +function isSaveFailure(value: unknown): value is SaveFailure { + return isObject(value) && typeof value['kind'] === 'string'; +} + +/** + * The AI summary and quiz FlickMan keeps for each lecture. Both cards share one request per + * lecture, and an edit or an answer updates what they show without fetching again. + */ +@Injectable({ + providedIn: 'root', +}) +export class StudyAidsService { + private man = inject(ManService); + + private readonly entries = new Map(); + /** Turned off for the visit once FlickMan turns out to have no study aids at all. */ + private supported = true; + + watch(videoId: number): Observable { + const entry = this.entry(videoId); + const status = entry.state.value.status; + if (!this.busy(entry) && (status === 'error' || Date.now() - entry.fetchedAt > FRESH_FOR_MS)) { + this.fetch(videoId, entry); + } + + return entry.state.asObservable(); + } + + refresh(videoId: number): void { + this.fetch(videoId, this.entry(videoId)); + } + + saveSummary(videoId: number, change: SummaryChange): Observable { + return this.save(videoId, '/summary', change, parseSummary, (aids, summary) => ({...aids, summary})); + } + + saveQuiz(videoId: number, change: QuizChange): Observable { + return this.save(videoId, '/quiz', change, parseQuiz, (aids, quiz) => ({...aids, quiz})); + } + + /** Adds an answer to the class stats. Only a reader's first answer to a question counts. */ + answer(videoId: number, questionId: string, choice: number): Observable { + return this.man.post>(path(videoId) + '/answers', {question_id: questionId, choice}).pipe( + map(response => parseStats(response?.data)), + tap(stats => this.updateQuestion(videoId, questionId, question => ({ + ...question, + stats: stats ?? question.stats, + mine: question.mine ?? choice, + }))), + catchError((error: unknown) => { + // The question was removed or replaced since the quiz was loaded. + if (error instanceof HttpErrorResponse && error.status === 404) { + this.refresh(videoId); + } + + return of(null); + }), + ); + } + + private save

( + videoId: number, + part: string, + change: SummaryChange | QuizChange, + parse: (value: unknown) => P | null, + apply: (aids: StudyAids, saved: P) => StudyAids, + ): Observable

{ + return this.man.post>(path(videoId) + part, change).pipe( + map(response => { + const saved = parse(response?.data); + if (!saved) { + throw (response + ? {kind: 'failed', message: 'FlickMan sent back something unexpected.'} + : {kind: 'network'}) satisfies SaveFailure; + } + + return saved; + }), + tap(saved => this.update(videoId, aids => apply(aids, saved))), + catchError((error: unknown) => { + const failure = this.saveFailure(error, parse); + if (failure.kind === 'conflict' && failure.current) { + // Show everyone's latest version behind the editor straight away. + this.update(videoId, aids => apply(aids, failure.current as P)); + } else if (failure.kind === 'forbidden') { + this.refresh(videoId); + } + + return throwError(() => failure); + }), + ); + } + + private saveFailure(error: unknown, parse: (value: unknown) => SummaryPart | QuizPart | null): SaveFailure { + if (isSaveFailure(error)) { + return error; + } + if (!(error instanceof HttpErrorResponse) || error.status === 0) { + return {kind: 'network'}; + } + const body = isObject(error.error) ? error.error : {}; + switch (error.status) { + case 409: + return {kind: 'conflict', current: parse(body['data'])}; + case 422: + return {kind: 'invalid', message: text(body['message']) ?? 'FlickMan did not accept the change.', errors: parseErrors(body['errors'])}; + case 401: + case 403: + return {kind: 'forbidden'}; + case 429: + return {kind: 'rate-limited'}; + default: + return {kind: 'failed', message: text(body['message']) ?? `FlickMan answered with error ${error.status}.`}; + } + } + + private fetch(videoId: number, entry: Entry): void { + entry.request?.unsubscribe(); + if (!this.supported) { + entry.state.next({status: 'unavailable'}); + + return; + } + if (entry.state.value.status !== 'ready') { + entry.state.next({status: 'loading'}); + } + entry.request = this.man.get>(path(videoId)).subscribe({ + next: response => { + const aids = parseStudyAids(response?.data); + entry.fetchedAt = Date.now(); + entry.state.next(aids ? {status: 'ready', aids} : {status: response ? 'unavailable' : 'error'}); + }, + error: (error: unknown) => { + const status = this.errorState(error); + entry.fetchedAt = Date.now(); + // A failed refresh keeps what the cards already show. + if (status === 'unavailable' || entry.state.value.status !== 'ready') { + entry.state.next({status}); + } + }, + }); + } + + private errorState(error: unknown): 'unavailable' | 'error' { + if (!(error instanceof HttpErrorResponse)) { + return 'error'; + } + if (error.status === 404) { + // FlickMan says a lecture is missing in JSend; any other 404 means it has no study aids routes. + if (!isObject(error.error) || error.error['status'] !== 'error') { + this.supported = false; + } + + return 'unavailable'; + } + + return error.status === 401 || error.status === 403 ? 'unavailable' : 'error'; + } + + private update(videoId: number, change: (aids: StudyAids) => StudyAids): void { + const entry = this.entries.get(videoId); + const state = entry?.state.value; + if (entry && state?.status === 'ready') { + entry.state.next({status: 'ready', aids: change(state.aids)}); + } + } + + private updateQuestion(videoId: number, questionId: string, change: (question: QuizQuestion) => QuizQuestion): void { + this.update(videoId, aids => aids.quiz ? { + ...aids, + quiz: {...aids.quiz, questions: aids.quiz.questions.map(question => question.id === questionId ? change(question) : question)}, + } : aids); + } + + private busy(entry: Entry): boolean { + return !!entry.request && !entry.request.closed; + } + + private entry(videoId: number): Entry { + let entry = this.entries.get(videoId); + if (!entry) { + entry = {state: new BehaviorSubject({status: 'loading'}), fetchedAt: 0, request: null}; + this.entries.set(videoId, entry); + } + + return entry; + } +} diff --git a/src/app/study-aids/byline.ts b/src/app/study-aids/byline.ts new file mode 100644 index 0000000..55c9a63 --- /dev/null +++ b/src/app/study-aids/byline.ts @@ -0,0 +1,28 @@ +import {CreditChoice, PartMeta} from './study-aids.model'; + +/** "Kan", "Kan and Chain", "Kan, Chain and Pim". */ +export function joinNames(names: string[]): string { + return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; +} + +export function isAiDraft(part: PartMeta): boolean { + return part.origin === 'ai' && !part.edited; +} + +/** Who made a summary or quiz, as shown under the card's title. */ +export function byline(part: PartMeta): string { + const names = joinNames(part.contributors.map(contributor => contributor.name)); + if (part.origin === 'editor') { + return names ? 'By ' + names : 'Written by an editor'; + } + if (!part.edited) { + return 'AI draft from the lecture recording. Check it against the lecture.'; + } + + return names ? 'AI draft, edited by ' + names : 'AI draft, edited by an editor'; +} + +/** The co-author box as sent with a save: no name means FlickMan uses the account's name. */ +export function creditFields(choice: CreditChoice): {credit: boolean; credit_name: string | null} { + return {credit: choice.credit, credit_name: choice.credit ? choice.name.trim() || null : null}; +} diff --git a/src/app/study-aids/checklist.spec.ts b/src/app/study-aids/checklist.spec.ts new file mode 100644 index 0000000..8a10c22 --- /dev/null +++ b/src/app/study-aids/checklist.spec.ts @@ -0,0 +1,131 @@ +import {buildChecklist, formatClock, formatStamp, parseTimestamp, plainText, renderInline} from './checklist'; + +// Shaped like FlickMan's demo summary: numbering restarts in every section, as the AI writes it. +const SUMMARY = `# Organelles + +## Nucleus [00:01:10] + +1. **Barr body** : inactivated X = facultative heterochromatin; จำนวน Barr body = จำนวน X − 1 +2. **Nucleolus** : สังเคราะห์ rRNA และประกอบ ribosomal subunit + +## Endoplasmic reticulum [00:06:30] + +1. **RER** : เริ่ม ==N-linked glycosylation (O-linked เกิดที่ Golgi)== +2. **SER** : เก็บ intracellular Ca²⁺ + +## Lysosome [00:21:40] + +1. **Gaucher disease** : ขาด glucocerebrosidase (*GBA1*) + 1. Gaucher cell : cytoplasm แบบ "crumpled tissue paper" + 2. Hepatosplenomegaly, pancytopenia, bone crisis +2. **Catalase** : $2\\,H_2O_2 \\rightarrow 2\\,H_2O + O_2$ + +### Compared + +| | Apoptosis | Necrosis | +|---|---|---| +| Cell | หดเล็กลง | บวม | +`; + +describe('buildChecklist', () => { + const checklist = buildChecklist(SUMMARY); + const points = checklist.sections.flatMap(section => section.blocks.filter(block => block.kind === 'point')); + + it('takes the first heading as the title', () => { + expect(checklist.title).toBe('Organelles'); + }); + + it('makes a section of each second-level heading and reads its time', () => { + expect(checklist.sections.length).toBe(3); + expect(checklist.sections.map(section => section.time)).toEqual([70, 390, 1300]); + expect(checklist.sections[0].headingHtml).toBe('Nucleus'); + }); + + it('numbers the points straight through the summary', () => { + expect(points.map(point => point.number)).toEqual([1, 2, 3, 4, 5, 6]); + expect(checklist.pointKeys.length).toBe(6); + }); + + it('keeps sub-points inside their point', () => { + const gaucher = points[4]; + + expect(gaucher.html).toContain('

    '); + expect(gaucher.html).toContain('crumpled tissue paper'); + expect(points.length).toBe(6); + }); + + it('renders highlights, key terms and maths placeholders', () => { + expect(points[2].html).toContain('N-linked glycosylation (O-linked เกิดที่ Golgi)'); + expect(points[0].html).toContain('Barr body'); + expect(points[5].html).toContain('class="math-inline"'); + }); + + it('keeps labels and tables as blocks of their own', () => { + const blocks = checklist.sections[2].blocks; + + expect(blocks.some(block => block.kind === 'label' && block.html === 'Compared')).toBeTrue(); + expect(blocks.some(block => block.kind === 'html' && block.html.includes(''))).toBeTrue(); + }); + + it('keys a point by its text, so editing another point keeps its tick', () => { + const edited = buildChecklist(SUMMARY.replace('**SER**', '**SER (smooth ER)**')); + + expect(edited.pointKeys[0]).toBe(checklist.pointKeys[0]); + expect(edited.pointKeys[3]).not.toBe(checklist.pointKeys[3]); + }); + + it('gives repeated points different keys', () => { + const repeated = buildChecklist('1. Same point\n2. Same point\n'); + + expect(new Set(repeated.pointKeys).size).toBe(2); + }); + + it('puts content before any heading into a section without a heading', () => { + const loose = buildChecklist('1. First\n\n## Later\n\n1. Second\n'); + + expect(loose.title).toBeNull(); + expect(loose.sections[0].headingHtml).toBeNull(); + expect(loose.sections[1].headingHtml).toBe('Later'); + expect(loose.pointKeys.length).toBe(2); + }); + + it('leaves a heading alone when its time is not a real time', () => { + const odd = buildChecklist('## Odd [00:75:00]\n'); + + expect(odd.sections[0].time).toBeNull(); + expect(odd.sections[0].headingHtml).toContain('[00:75:00]'); + }); + + it('opens links in a new tab', () => { + expect(renderInline('[notes](https://example.com)')).toContain('target="_blank"'); + }); +}); + +describe('timestamps', () => { + it('reads m:ss, mm:ss and h:mm:ss', () => { + expect(parseTimestamp('7:45')).toBe(465); + expect(parseTimestamp('07:45')).toBe(465); + expect(parseTimestamp('1:02:03')).toBe(3723); + expect(parseTimestamp(' 00:01:10 ')).toBe(70); + }); + + it('refuses what is not a time', () => { + expect(parseTimestamp('')).toBeNull(); + expect(parseTimestamp('7:60')).toBeNull(); + expect(parseTimestamp('1:60:00')).toBeNull(); + expect(parseTimestamp('abc')).toBeNull(); + }); + + it('formats for reading and for writing', () => { + expect(formatClock(465)).toBe('7:45'); + expect(formatClock(3723)).toBe('1:02:03'); + expect(formatStamp(465)).toBe('00:07:45'); + expect(formatStamp(465.9)).toBe('00:07:45'); + }); +}); + +describe('plainText', () => { + it('drops the formatting', () => { + expect(plainText('**Bold** and ==marked==\nline')).toBe('Bold and marked line'); + }); +}); diff --git a/src/app/study-aids/checklist.ts b/src/app/study-aids/checklist.ts new file mode 100644 index 0000000..45e2b10 --- /dev/null +++ b/src/app/study-aids/checklist.ts @@ -0,0 +1,207 @@ +import {Marked, MarkedExtension} from 'marked'; +import {mathExtension} from '../home/course/markdown-math'; + +/* + * A summary is Markdown written to these conventions (FlickMan's AI follows them too): + * + * # Title + * ## Section heading [HH:MM:SS] the time is optional, where the lecture starts the topic + * ### Label a small heading inside a section + * 1. Term : fact every top-level list item is one numbered point + * 1. detail nested items are sub-points + * **key term**, ==exam point==, tables and $maths$ + * + * Points are numbered here, continuously through the whole summary, so the numbers written in + * the Markdown never matter and an editor can add a point anywhere without renumbering. + */ + +export interface ChecklistBlock { + kind: 'point' | 'label' | 'html'; + /** Ticks are stored under a point's key, which only changes when that point's text does. */ + key: string; + /** The point's number; null for other blocks. */ + number: number | null; + html: string; +} + +export interface ChecklistSection { + headingHtml: string | null; + /** Seconds into the lecture where this section starts. */ + time: number | null; + blocks: ChecklistBlock[]; +} + +export interface Checklist { + title: string | null; + sections: ChecklistSection[]; + pointKeys: string[]; +} + +const HIGHLIGHT_RULE = /^==(?=\S)([\s\S]*?\S)==/; + +/** `==text==` becomes ``, for the points worth remembering for the exam. */ +export const highlightExtension: MarkedExtension = { + extensions: [ + { + name: 'highlight', + level: 'inline', + start: (src: string) => { + const index = src.indexOf('=='); + + return index >= 0 ? index : undefined; + }, + tokenizer(src: string) { + const match = HIGHLIGHT_RULE.exec(src); + + return match + ? {type: 'highlight', raw: match[0], text: match[1], tokens: this.lexer.inlineTokens(match[1])} + : undefined; + }, + renderer(token) { + return `${this.parser.parseInline(token.tokens ?? [])}`; + }, + }, + ], +}; + +export const studyMarked = new Marked(mathExtension, highlightExtension); + +/** Markdown for a block of text, such as a question or an explanation. */ +export function renderBlock(markdown: string): string { + return openLinksElsewhere(studyMarked.parse(markdown, {async: false})); +} + +/** Markdown for text that sits inside a line, such as a choice. */ +export function renderInline(markdown: string): string { + return openLinksElsewhere(studyMarked.parseInline(markdown, {async: false})); +} + +/** The words of some Markdown without its formatting, for a one-line preview. */ +export function plainText(markdown: string): string { + const text = new DOMParser().parseFromString(renderInline(markdown), 'text/html').body.textContent ?? ''; + + return text.replace(/\s+/g, ' ').trim(); +} + +const TIMESTAMP = /^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/; +const HEADING_TIME = /\s*\[((?:\d{1,2}:)?\d{1,2}:\d{2})\]\s*$/; + +/** Reads `m:ss`, `mm:ss` or `h:mm:ss` into seconds. */ +export function parseTimestamp(value: string): number | null { + const match = TIMESTAMP.exec(value.trim()); + if (!match) { + return null; + } + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + if (seconds >= 60 || (match[1] !== undefined && minutes >= 60)) { + return null; + } + + return hours * 3600 + minutes * 60 + seconds; +} + +/** `7:45` or `1:02:03`, for reading. */ +export function formatClock(totalSeconds: number): string { + const total = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor(total % 3600 / 60); + const seconds = String(total % 60).padStart(2, '0'); + + return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`; +} + +/** `00:07:45`, the form written into summaries. */ +export function formatStamp(totalSeconds: number): string { + const total = Math.max(0, Math.floor(totalSeconds)); + + return [Math.floor(total / 3600), Math.floor(total % 3600 / 60), total % 60] + .map(part => String(part).padStart(2, '0')) + .join(':'); +} + +/** FNV-1a, enough to tell a point's text from another's. */ +function hash(value: string): string { + let result = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + result ^= value.charCodeAt(i); + result = Math.imul(result, 0x01000193); + } + + return (result >>> 0).toString(36); +} + +function openLinksElsewhere(html: string): string { + // Following a link from a summary must not take the reader away from the lecture. + return html.replace(/(); + let section: ChecklistSection | null = null; + let blockCount = 0; + + const currentSection = (): ChecklistSection => { + if (!section) { + section = {headingHtml: null, time: null, blocks: []}; + checklist.sections.push(section); + } + + return section; + }; + const addBlock = (kind: ChecklistBlock['kind'], content: string) => { + currentSection().blocks.push({kind, key: 'block-' + blockCount++, number: null, html: content}); + }; + + for (const element of Array.from(body.children)) { + const tag = element.tagName; + if (tag === 'H1' && checklist.title === null && checklist.sections.length === 0) { + checklist.title = element.textContent?.trim() || null; + } else if (tag === 'H1' || tag === 'H2') { + const time = takeHeadingTime(element); + section = {headingHtml: element.innerHTML.trim(), time, blocks: []}; + checklist.sections.push(section); + } else if (/^H[3-6]$/.test(tag)) { + addBlock('label', element.innerHTML.trim()); + } else if (tag === 'OL' || tag === 'UL') { + for (const item of Array.from(element.children).filter(child => child.tagName === 'LI')) { + const content = (item.textContent ?? '').replace(/\s+/g, ' ').trim(); + const seen = (occurrences.get(content) ?? 0) + 1; + occurrences.set(content, seen); + const key = hash(content) + (seen > 1 ? '-' + seen : ''); + checklist.pointKeys.push(key); + currentSection().blocks.push({kind: 'point', key, number: checklist.pointKeys.length, html: item.innerHTML.trim()}); + } + } else if (tag === 'TABLE') { + addBlock('html', `
    ${element.outerHTML}
    `); + } else { + addBlock('html', element.outerHTML); + } + } + + return checklist; +} diff --git a/src/app/study-aids/displayed.ts b/src/app/study-aids/displayed.ts new file mode 100644 index 0000000..793886b --- /dev/null +++ b/src/app/study-aids/displayed.ts @@ -0,0 +1,24 @@ +import {NgZone} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** + * Whether the element is laid out at all. The settings sheet switches a card off with CSS, and a + * card that is switched off should cost the server nothing. Needs the element to be a block. + */ +export function displayed(element: HTMLElement, zone: NgZone): Observable { + return new Observable(subscriber => { + let shown = element.getClientRects().length > 0; + subscriber.next(shown); + // Resizes are frequent while a card's content changes, so only a change re-enters Angular. + const observer = new ResizeObserver(() => { + const now = element.getClientRects().length > 0; + if (now !== shown) { + shown = now; + zone.run(() => subscriber.next(now)); + } + }); + observer.observe(element); + + return () => observer.disconnect(); + }); +} diff --git a/src/app/study-aids/local-store.ts b/src/app/study-aids/local-store.ts new file mode 100644 index 0000000..5e0b9aa --- /dev/null +++ b/src/app/study-aids/local-store.ts @@ -0,0 +1,86 @@ +import {CreditChoice} from './study-aids.model'; + +/* + * What a reader does with the summary and quiz stays on the device: ticks, quiz answers in + * progress and which cards are folded away. Storage may be unavailable (private browsing), in + * which case everything still works for the visit. + */ + +const TICKS_PREFIX = 'flickSummaryTicks:'; +const ANSWERS_PREFIX = 'flickQuizAnswers:'; +const COLLAPSED_KEY = 'flickStudyAidsCollapsed'; +const CREDIT_KEY = 'flickStudyAidsCredit'; + +function read(key: string): unknown { + try { + const raw = localStorage.getItem(key); + + return raw === null ? null : JSON.parse(raw); + } catch { + return null; + } +} + +function write(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Kept for this visit only. + } +} + +export function readTicks(videoId: number): Set { + const stored = read(TICKS_PREFIX + videoId); + + return new Set(Array.isArray(stored) ? stored.filter((key): key is string => typeof key === 'string') : []); +} + +export function writeTicks(videoId: number, ticks: ReadonlySet): void { + write(TICKS_PREFIX + videoId, [...ticks]); +} + +/** Null when this device has never started the quiz, as opposed to having cleared it. */ +export function readAnswers(videoId: number): Record | null { + const stored = read(ANSWERS_PREFIX + videoId); + if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) { + return null; + } + const answers: Record = {}; + for (const [id, choice] of Object.entries(stored)) { + if (Number.isInteger(choice) && (choice as number) >= 0) { + answers[id] = choice as number; + } + } + + return answers; +} + +export function writeAnswers(videoId: number, answers: Record): void { + write(ANSWERS_PREFIX + videoId, answers); +} + +export function readCollapsed(card: 'summary' | 'quiz'): boolean { + const stored = read(COLLAPSED_KEY); + + return typeof stored === 'object' && stored !== null && (stored as Record)[card] === true; +} + +export function writeCollapsed(card: 'summary' | 'quiz', collapsed: boolean): void { + const stored = read(COLLAPSED_KEY); + write(COLLAPSED_KEY, {...(typeof stored === 'object' && stored !== null ? stored : {}), [card]: collapsed}); +} + +/** The co-author box starts as the editor last left it. */ +export function readCreditChoice(): CreditChoice { + const stored = read(CREDIT_KEY); + if (typeof stored !== 'object' || stored === null) { + return {credit: false, name: ''}; + } + const fields = stored as Record; + + return {credit: fields['credit'] === true, name: typeof fields['name'] === 'string' ? fields['name'] : ''}; +} + +export function writeCreditChoice(choice: CreditChoice): void { + write(CREDIT_KEY, choice); +} diff --git a/src/app/study-aids/parse.ts b/src/app/study-aids/parse.ts new file mode 100644 index 0000000..f737979 --- /dev/null +++ b/src/app/study-aids/parse.ts @@ -0,0 +1,136 @@ +import {AnswerStats, Contributor, GenerationState, PartMeta, QuizPart, QuizQuestion, StudyAids, SummaryPart} from './study-aids.model'; + +/* + * FlickMan's answers are checked field by field rather than trusted, so a malformed question is + * dropped instead of breaking the whole card. + */ + +const GENERATION_STATES: GenerationState[] = ['none', 'pending', 'ready', 'failed']; + +type Fields = Record; + +export function isObject(value: unknown): value is Fields { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function text(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function count(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; +} + +function seconds(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; +} + +function contributors(value: unknown): Contributor[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map(entry => isObject(entry) ? text(entry['name'])?.trim() : null) + .filter((name): name is string => !!name) + .map(name => ({name})); +} + +function meta(fields: Fields): PartMeta { + return { + revision: count(fields['revision']) ?? 0, + origin: fields['origin'] === 'editor' ? 'editor' : 'ai', + edited: fields['edited'] === true, + contributors: contributors(fields['contributors']), + updated_at: text(fields['updated_at']), + }; +} + +export function parseStats(value: unknown): AnswerStats | null { + if (!isObject(value)) { + return null; + } + const answered = count(value['answered']); + const correct = count(value['correct']); + + return answered !== null && correct !== null && correct <= answered ? {answered, correct} : null; +} + +function parseQuestion(value: unknown): QuizQuestion | null { + if (!isObject(value)) { + return null; + } + const id = text(value['id']); + const prompt = text(value['prompt']); + const choices = Array.isArray(value['choices']) && value['choices'].every(choice => typeof choice === 'string') + ? value['choices'] as string[] + : null; + const answer = count(value['answer']); + if (!id || prompt === null || !choices || choices.length < 2 || answer === null || answer >= choices.length) { + return null; + } + const mine = count(value['mine']); + + return { + id, + prompt, + choices, + answer, + explanation: text(value['explanation']) || null, + time: seconds(value['time']), + stats: parseStats(value['stats']), + mine: mine !== null && mine < choices.length ? mine : null, + }; +} + +export function parseSummary(value: unknown): SummaryPart | null { + if (!isObject(value)) { + return null; + } + const markdown = text(value['markdown']); + + return markdown === null ? null : {...meta(value), markdown}; +} + +export function parseQuiz(value: unknown): QuizPart | null { + if (!isObject(value) || !Array.isArray(value['questions'])) { + return null; + } + + return { + ...meta(value), + questions: value['questions'].map(parseQuestion).filter((question): question is QuizQuestion => question !== null), + }; +} + +export function parseStudyAids(value: unknown): StudyAids | null { + if (!isObject(value)) { + return null; + } + const videoId = count(value['video_id']); + if (videoId === null) { + return null; + } + + return { + video_id: videoId, + can_edit: value['can_edit'] === true, + generation: GENERATION_STATES.find(state => state === value['generation']) ?? 'none', + summary: parseSummary(value['summary']), + quiz: parseQuiz(value['quiz']), + }; +} + +/** Laravel's validation errors: field path to messages. */ +export function parseErrors(value: unknown): Record { + const errors: Record = {}; + if (isObject(value)) { + for (const [field, messages] of Object.entries(value)) { + if (Array.isArray(messages)) { + errors[field] = messages.filter((message): message is string => typeof message === 'string'); + } + } + } + + return errors; +} diff --git a/src/app/study-aids/quiz.spec.ts b/src/app/study-aids/quiz.spec.ts new file mode 100644 index 0000000..535ec29 --- /dev/null +++ b/src/app/study-aids/quiz.spec.ts @@ -0,0 +1,105 @@ +import {blankQuestionForm, errorsByQuestion, questionChange, questionForm, questionProblems, resetsStats, score} from './quiz'; +import {QuizQuestion} from './study-aids.model'; + +const QUESTION: QuizQuestion = { + id: '01M4D7B2STKMV08W6Z0ZF3G56N', + prompt: 'เซลล์ชนิดใดมี smooth ER มากที่สุด', + choices: ['Plasma cell', 'Pancreatic acinar cell', 'Leydig cell', 'Neutrophil', 'Mature red blood cell'], + answer: 2, + explanation: 'SER สังเคราะห์ steroid hormone', + time: 465, + stats: {answered: 12, correct: 10}, + mine: null, +}; + +describe('questionProblems', () => { + it('accepts a complete question', () => { + expect(questionProblems(questionForm(QUESTION, 0), 3000)).toEqual([]); + }); + + it('asks for everything a new question is missing', () => { + const problems = questionProblems(blankQuestionForm(0), 3000); + + expect(problems).toContain('Write the question.'); + expect(problems).toContain('Fill in or remove the empty choice.'); + expect(problems).toContain('Mark the correct answer.'); + }); + + it('refuses choices that differ only in case or spacing', () => { + const form = {...questionForm(QUESTION, 0), choices: ['Leydig cell', ' leydig CELL ', 'Neutrophil']}; + + expect(questionProblems(form, 3000)).toContain('Two choices say the same thing.'); + }); + + it('checks the video time against the lecture', () => { + expect(questionProblems({...questionForm(QUESTION, 0), time: '7:4'}, 3000)) + .toContain('Write the video time as m:ss or h:mm:ss.'); + expect(questionProblems({...questionForm(QUESTION, 0), time: '51:00'}, 3000)) + .toContain('The video time is after the end of the lecture.'); + }); + + it('refuses an answer beyond the choices', () => { + const form = {...questionForm(QUESTION, 0), choices: ['A', 'B'], answer: 2}; + + expect(questionProblems(form, 3000)).toContain('Mark the correct answer.'); + }); +}); + +describe('questionChange', () => { + it('trims what is sent and leaves out empty optional fields', () => { + const form = {...questionForm(QUESTION, 0), prompt: ' Which? ', explanation: ' ', time: ''}; + + expect(questionChange(form)).toEqual({ + id: QUESTION.id, + prompt: 'Which?', + choices: QUESTION.choices, + answer: 2, + explanation: null, + time: null, + }); + }); + + it('sends a new question without an id', () => { + expect(questionChange(blankQuestionForm(3)).id).toBeNull(); + }); +}); + +describe('resetsStats', () => { + it('is false for a changed explanation or time', () => { + expect(resetsStats({...questionForm(QUESTION, 0), explanation: 'Other', time: '1:00'})).toBeFalse(); + }); + + it('is true for a changed prompt, choice or answer', () => { + expect(resetsStats({...questionForm(QUESTION, 0), prompt: 'Changed'})).toBeTrue(); + expect(resetsStats({...questionForm(QUESTION, 0), answer: 1})).toBeTrue(); + const form = questionForm(QUESTION, 0); + form.choices[4] = 'Platelet'; + + expect(resetsStats(form)).toBeTrue(); + }); + + it('is false for a question nobody has answered', () => { + expect(resetsStats({...questionForm(QUESTION, 0), stats: null, prompt: 'Changed'})).toBeFalse(); + }); +}); + +describe('errorsByQuestion', () => { + it('sorts errors by question', () => { + const {byQuestion, general} = errorsByQuestion({ + 'questions.1.choices.2': ['Choices must be distinct.'], + 'questions.1.answer': ['The answer is out of range.'], + 'revision': ['The revision is required.'], + }); + + expect(byQuestion.get(1)).toEqual(['Choices must be distinct.', 'The answer is out of range.']); + expect(general).toEqual(['The revision is required.']); + }); +}); + +describe('score', () => { + it('counts right answers only', () => { + const other = {...QUESTION, id: 'other', answer: 0}; + + expect(score([QUESTION, other], {[QUESTION.id]: 2, other: 1})).toBe(1); + }); +}); diff --git a/src/app/study-aids/quiz.ts b/src/app/study-aids/quiz.ts new file mode 100644 index 0000000..bbca0a5 --- /dev/null +++ b/src/app/study-aids/quiz.ts @@ -0,0 +1,140 @@ +import {formatStamp, parseTimestamp} from './checklist'; +import {AnswerStats, LIMITS, QuestionChange, QuizQuestion} from './study-aids.model'; + +export const CHOICE_LETTERS = 'ABCDEFGH'; + +/** One question as the quiz editor holds it while it is being changed. */ +export interface QuestionForm { + /** Tracks the question in the editor's list; unrelated to the server id. */ + key: number; + id: string | null; + prompt: string; + choices: string[]; + answer: number | null; + explanation: string; + /** As typed, `m:ss` or `h:mm:ss`, or empty. */ + time: string; + stats: AnswerStats | null; + /** What the question asked when the editor opened, to tell whether saving resets its stats. */ + original: {prompt: string; choices: string[]; answer: number} | null; +} + +/** New questions get five choices, the A–E single-best-answer format FlickMan's AI writes. */ +export const NEW_QUESTION_CHOICES = 5; + +export function questionForm(question: QuizQuestion, key: number): QuestionForm { + return { + key, + id: question.id, + prompt: question.prompt, + choices: [...question.choices], + answer: question.answer, + explanation: question.explanation ?? '', + time: question.time === null ? '' : formatStamp(question.time), + stats: question.stats, + original: {prompt: question.prompt, choices: [...question.choices], answer: question.answer}, + }; +} + +export function blankQuestionForm(key: number): QuestionForm { + return { + key, + id: null, + prompt: '', + choices: Array.from({length: NEW_QUESTION_CHOICES}, () => ''), + answer: null, + explanation: '', + time: '', + stats: null, + original: null, + }; +} + +/** What needs fixing before FlickMan would accept the question. */ +export function questionProblems(form: QuestionForm, maxTime: number): string[] { + const problems: string[] = []; + const prompt = form.prompt.trim(); + if (!prompt) { + problems.push('Write the question.'); + } else if (prompt.length > LIMITS.promptChars) { + problems.push(`Shorten the question to ${LIMITS.promptChars} characters.`); + } + + const choices = form.choices.map(choice => choice.trim()); + if (choices.length < LIMITS.minChoices) { + problems.push(`Give at least ${LIMITS.minChoices} choices.`); + } + if (choices.some(choice => !choice)) { + problems.push('Fill in or remove the empty choice.'); + } + if (choices.some(choice => choice.length > LIMITS.choiceChars)) { + problems.push(`Shorten each choice to ${LIMITS.choiceChars} characters.`); + } + const filled = choices.filter(Boolean).map(choice => choice.toLowerCase()); + if (new Set(filled).size !== filled.length) { + problems.push('Two choices say the same thing.'); + } + if (form.answer === null || form.answer >= choices.length) { + problems.push('Mark the correct answer.'); + } + + if (form.explanation.trim().length > LIMITS.explanationChars) { + problems.push(`Shorten the explanation to ${LIMITS.explanationChars} characters.`); + } + if (form.time.trim()) { + const time = parseTimestamp(form.time); + if (time === null) { + problems.push('Write the video time as m:ss or h:mm:ss.'); + } else if (time > maxTime) { + problems.push('The video time is after the end of the lecture.'); + } + } + + return problems; +} + +export function questionChange(form: QuestionForm): QuestionChange { + return { + id: form.id, + prompt: form.prompt.trim(), + choices: form.choices.map(choice => choice.trim()), + answer: form.answer ?? 0, + explanation: form.explanation.trim() || null, + time: form.time.trim() ? parseTimestamp(form.time) : null, + }; +} + +/** FlickMan restarts a question's class stats when what it asks or its answer changes. */ +export function resetsStats(form: QuestionForm): boolean { + if (!form.original || !form.stats?.answered) { + return false; + } + const change = questionChange(form); + + return change.prompt !== form.original.prompt.trim() + || change.answer !== form.original.answer + || change.choices.length !== form.original.choices.length + || change.choices.some((choice, i) => choice !== form.original!.choices[i].trim()); +} + +/** Sorts Laravel's `questions.3.choices.1`-style errors by question, and the rest. */ +export function errorsByQuestion(errors: Record): {byQuestion: Map; general: string[]} { + const byQuestion = new Map(); + const general: string[] = []; + for (const [field, messages] of Object.entries(errors)) { + const match = /^questions\.(\d+)(?:\.|$)/.exec(field); + if (match) { + const index = Number(match[1]); + byQuestion.set(index, [...(byQuestion.get(index) ?? []), ...messages]); + } else { + general.push(...messages); + } + } + + return {byQuestion, general}; +} + +/** How many of the given answers are right. */ +export function score(questions: QuizQuestion[], answers: Record): number { + return questions.filter(question => answers[question.id] === question.answer).length; +} diff --git a/src/app/study-aids/study-aids.model.ts b/src/app/study-aids/study-aids.model.ts new file mode 100644 index 0000000..48837bd --- /dev/null +++ b/src/app/study-aids/study-aids.model.ts @@ -0,0 +1,129 @@ +/** + * The summary and quiz FlickMan keeps for each lecture, as served by `GET v1/study_aids/{video}`. + * Field names follow the API, like the rest of the ManService models. + */ +export interface StudyAids { + video_id: number; + /** Whether this user may edit the summary and the questions. */ + can_edit: boolean; + generation: GenerationState; + summary: SummaryPart | null; + quiz: QuizPart | null; +} + +/** + * `none`: no lecture document to work from. `pending`: the AI is writing the parts, which can take + * up to a day. A part an editor wrote stays as it is whatever the state. + */ +export type GenerationState = 'none' | 'pending' | 'ready' | 'failed'; + +/** `ai` parts began as an AI draft and stay `ai` after editing; `editor` parts were written from scratch. */ +export type PartOrigin = 'ai' | 'editor'; + +export interface Contributor { + name: string; +} + +export interface PartMeta { + /** The version an edit starts from; a save based on an older one is refused. */ + revision: number; + origin: PartOrigin; + /** False while the part is an untouched AI draft. */ + edited: boolean; + /** Editors who chose to be named, in the order they first contributed. */ + contributors: Contributor[]; + updated_at: string | null; +} + +export interface SummaryPart extends PartMeta { + markdown: string; +} + +export interface QuizPart extends PartMeta { + questions: QuizQuestion[]; +} + +export interface QuizQuestion { + id: string; + prompt: string; + choices: string[]; + /** Index into `choices`. */ + answer: number; + explanation: string | null; + /** Seconds into the lecture where the question's topic is taught. */ + time: number | null; + stats: AnswerStats | null; + /** This user's first answer, from any device. Only the first answer counts towards the stats. */ + mine: number | null; +} + +export interface AnswerStats { + answered: number; + correct: number; +} + +/** The co-author box in the editors: whether students see this editor's name, and which name. */ +export interface CreditChoice { + credit: boolean; + name: string; +} + +export interface SummaryChange { + markdown: string; + revision: number; + credit: boolean; + credit_name: string | null; +} + +export interface QuestionChange { + /** Null for a new question; FlickMan assigns the id. */ + id: string | null; + prompt: string; + choices: string[]; + answer: number; + explanation: string | null; + time: number | null; +} + +export interface QuizChange { + questions: QuestionChange[]; + revision: number; + credit: boolean; + credit_name: string | null; +} + +/** Lets the cards read and move the lecture video without depending on the player. */ +export interface LecturePlayback { + currentTime(): number; + seek(seconds: number): void; +} + +export type StudyAidsState = + | {status: 'loading'} + | {status: 'ready'; aids: StudyAids} + /** Nothing to show for this lecture, or no study aids on this server. */ + | {status: 'unavailable'} + | {status: 'error'}; + +/** Why a save was refused, in the terms the editors need to explain it. */ +export type SaveFailure = + | {kind: 'conflict'; current: SummaryPart | QuizPart | null} + | {kind: 'invalid'; message: string; errors: Record} + | {kind: 'forbidden'} + | {kind: 'rate-limited'} + | {kind: 'network'} + | {kind: 'failed'; message: string}; + +/** The limits FlickMan enforces, checked here first so editors see the problem before saving. */ +export const LIMITS = { + summaryChars: 100_000, + questions: 50, + promptChars: 2000, + minChoices: 2, + maxChoices: 6, + choiceChars: 500, + explanationChars: 4000, + creditNameChars: 50, + /** For lectures whose duration is not known. */ + maxTime: 86_400, +}; From 77da30445bbe351075b15f6d9e621add3ede1804 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Thu, 8 Oct 2026 19:07:06 +0700 Subject: [PATCH 02/14] fix: draw the summary ticks and answer picks in the theme's colours Native checkboxes and radios follow the system's light or dark scheme, so on a light theme under a dark system they showed as dark squares. --- src/app/shared/checklist-view.component.scss | 32 +++++++++++++++++++- src/app/shared/quiz-editor.component.scss | 30 +++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/app/shared/checklist-view.component.scss b/src/app/shared/checklist-view.component.scss index 97b6e7d..964df9c 100644 --- a/src/app/shared/checklist-view.component.scss +++ b/src/app/shared/checklist-view.component.scss @@ -70,12 +70,42 @@ app-checklist-view { grid-template-columns: 1.1rem 2.4em minmax(0, 1fr); } + /* Drawn from the theme: a native box follows the system's light or dark scheme, not the theme. */ .point-tick { + appearance: none; + display: grid; + place-content: center; width: 1rem; height: 1rem; margin: 0.22em 0 0; - accent-color: var(--ion-color-primary); + border: 1.5px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.45); + border-radius: 0.25rem; + background: transparent; cursor: pointer; + + &::after { + content: ''; + width: 0.28rem; + height: 0.55rem; + margin-top: -0.12rem; + border: solid var(--ion-color-primary-contrast); + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + } + + &:checked { + border-color: var(--ion-color-primary); + background: var(--ion-color-primary); + } + + &:checked::after { + transform: rotate(45deg) scale(1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } } .point-number { diff --git a/src/app/shared/quiz-editor.component.scss b/src/app/shared/quiz-editor.component.scss index 470070b..533a2f0 100644 --- a/src/app/shared/quiz-editor.component.scss +++ b/src/app/shared/quiz-editor.component.scss @@ -78,12 +78,40 @@ gap: 0.35rem; cursor: pointer; + // Drawn from the theme, like the summary's tick boxes. input { + appearance: none; + display: grid; + place-content: center; width: 1.05rem; height: 1.05rem; margin: 0; - accent-color: var(--ion-color-success); + border: 1.5px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.45); + border-radius: 50%; + background: transparent; cursor: pointer; + + &::after { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--ion-color-success); + transform: scale(0); + } + + &:checked { + border-color: var(--ion-color-success); + } + + &:checked::after { + transform: scale(1); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } } } From c8b1a835a051accf6e251c6f6d693e37e130bfff Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Thu, 8 Oct 2026 20:30:24 +0700 Subject: [PATCH 03/14] feat: let admins choose the editors, add images and share the quiz by link - Admins get an Editors screen on the summary and quiz cards: add someone by their @docchula.com address as an editor or admin, change roles, remove them. FlickMan keeps at least one admin. - Editors can add images to a summary and to a question or its explanation. Large photos are made smaller before upload; only images stored on FlickMan are ever loaded, so a pasted image cannot track readers. - The quiz card has a Share button. Its link (?video=&quiz=1) opens the lecture with the quiz ready. --- src/app/shared/_study-card.scss | 6 + src/app/shared/lecture-quiz.component.html | 35 +++- src/app/shared/lecture-quiz.component.scss | 7 + src/app/shared/lecture-quiz.component.spec.ts | 32 +++ src/app/shared/lecture-quiz.component.ts | 78 +++++++- src/app/shared/lecture-summary.component.html | 26 ++- .../shared/lecture-summary.component.spec.ts | 1 + src/app/shared/lecture-summary.component.ts | 9 +- src/app/shared/quiz-editor.component.html | 42 ++++ src/app/shared/quiz-editor.component.scss | 50 +++++ src/app/shared/quiz-editor.component.spec.ts | 57 ++++++ src/app/shared/quiz-editor.component.ts | 102 +++++++--- src/app/shared/rich-text.component.scss | 8 + .../shared/study-aids-editors.component.html | 58 ++++++ .../shared/study-aids-editors.component.scss | 50 +++++ .../study-aids-editors.component.spec.ts | 97 +++++++++ .../shared/study-aids-editors.component.ts | 189 ++++++++++++++++++ src/app/shared/summary-editor.component.html | 10 + .../shared/summary-editor.component.spec.ts | 68 +++++++ src/app/shared/summary-editor.component.ts | 68 ++++--- src/app/study-aids.service.spec.ts | 49 +++++ src/app/study-aids.service.ts | 57 +++++- src/app/study-aids/checklist.spec.ts | 15 ++ src/app/study-aids/checklist.ts | 38 +++- src/app/study-aids/failures.ts | 22 ++ src/app/study-aids/images.spec.ts | 52 +++++ src/app/study-aids/images.ts | 89 +++++++++ src/app/study-aids/parse.ts | 45 ++++- src/app/study-aids/study-aids.model.ts | 24 +++ src/app/study-aids/upload-image.ts | 29 +++ 30 files changed, 1338 insertions(+), 75 deletions(-) create mode 100644 src/app/shared/quiz-editor.component.spec.ts create mode 100644 src/app/shared/study-aids-editors.component.html create mode 100644 src/app/shared/study-aids-editors.component.scss create mode 100644 src/app/shared/study-aids-editors.component.spec.ts create mode 100644 src/app/shared/study-aids-editors.component.ts create mode 100644 src/app/shared/summary-editor.component.spec.ts create mode 100644 src/app/study-aids/failures.ts create mode 100644 src/app/study-aids/images.spec.ts create mode 100644 src/app/study-aids/images.ts create mode 100644 src/app/study-aids/upload-image.ts diff --git a/src/app/shared/_study-card.scss b/src/app/shared/_study-card.scss index 3ab53c0..7e2bb11 100644 --- a/src/app/shared/_study-card.scss +++ b/src/app/shared/_study-card.scss @@ -84,6 +84,12 @@ line-height: 1.4; } +.editor-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; +} + .card-note { margin: 0 0 0.5rem; color: var(--ion-color-medium); diff --git a/src/app/shared/lecture-quiz.component.html b/src/app/shared/lecture-quiz.component.html index 9194f90..290f9ac 100644 --- a/src/app/shared/lecture-quiz.component.html +++ b/src/app/shared/lecture-quiz.component.html @@ -32,13 +32,28 @@ } @else if (view.quiz && view.items.length) { + @if (shareNote) { + + } @switch (phase(view)) { @case ('start') { @@ -144,7 +159,15 @@ } } @else if (view.aids?.can_edit) {

    {{ emptyNote(view.aids!) }}

    - Add questions + + Add questions + @if (view.aids?.can_manage) { + + + Editors + + } + } @else {

    The AI is still writing questions on this lecture. Check back later.

    } diff --git a/src/app/shared/lecture-quiz.component.scss b/src/app/shared/lecture-quiz.component.scss index 92a1348..340ccb8 100644 --- a/src/app/shared/lecture-quiz.component.scss +++ b/src/app/shared/lecture-quiz.component.scss @@ -1,5 +1,12 @@ @use 'study-card'; +.share-note { + margin: -0.4rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + .quiz-start ion-button { margin: 0.25rem 0 0; } diff --git a/src/app/shared/lecture-quiz.component.spec.ts b/src/app/shared/lecture-quiz.component.spec.ts index f1397d4..9812139 100644 --- a/src/app/shared/lecture-quiz.component.spec.ts +++ b/src/app/shared/lecture-quiz.component.spec.ts @@ -1,4 +1,5 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, convertToParamMap} from '@angular/router'; import {ModalController} from '@ionic/angular/standalone'; import {BehaviorSubject, of} from 'rxjs'; import {LectureQuizComponent} from './lecture-quiz.component'; @@ -25,6 +26,7 @@ function aids(questions: QuizQuestion[], canEdit = false): StudyAids { return { video_id: 7, can_edit: canEdit, + can_manage: false, generation: 'ready', summary: null, quiz: {questions, revision: 2, origin: 'ai', edited: true, contributors: [{name: 'Kan & Chain'}], updated_at: null}, @@ -36,6 +38,7 @@ describe('LectureQuizComponent', () => { let state$: BehaviorSubject; let service: jasmine.SpyObj>; let seek: jasmine.Spy; + let query: Record; const element = () => fixture.nativeElement as HTMLElement; const text = () => element().textContent?.replace(/\s+/g, ' ') ?? ''; @@ -62,10 +65,14 @@ describe('LectureQuizComponent', () => { service = jasmine.createSpyObj('StudyAidsService', ['watch', 'answer', 'refresh']); service.answer.and.returnValue(of({answered: 13, correct: 11})); seek = jasmine.createSpy('seek'); + query = {}; TestBed.configureTestingModule({ imports: [LectureQuizComponent], providers: [ {provide: StudyAidsService, useValue: service}, + {provide: ActivatedRoute, useValue: {snapshot: {get queryParamMap() { + return convertToParamMap(query); + }}}}, {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['create'])}, ], }); @@ -157,4 +164,29 @@ describe('LectureQuizComponent', () => { expect(text()).toContain('There are no questions for this lecture yet.'); expect(text()).toContain('Add questions'); }); + + it('builds a link that opens this lecture\'s quiz', () => { + create(aids([question('a', 2)])); + const link = new URL((fixture.componentInstance as unknown as {shareLink(view: {videoId: number}): string}).shareLink({videoId: 7})); + + expect(link.searchParams.get('video')).toBe('7'); + expect(link.searchParams.get('quiz')).toBe('1'); + }); + + it('opens the quiz when the page was reached through its link', () => { + localStorage.setItem('flickStudyAidsCollapsed', JSON.stringify({quiz: true})); + query = {video: '7', quiz: '1'}; + create(aids([question('a', 2)])); + + expect(text()).toContain('Start quiz'); + expect(JSON.parse(localStorage.getItem('flickStudyAidsCollapsed') ?? '{}').quiz).toBeTrue(); + }); + + it('stays folded away for a link to another lecture', () => { + localStorage.setItem('flickStudyAidsCollapsed', JSON.stringify({quiz: true})); + query = {video: '8', quiz: '1'}; + create(aids([question('a', 2)])); + + expect(text()).not.toContain('Start quiz'); + }); }); diff --git a/src/app/shared/lecture-quiz.component.ts b/src/app/shared/lecture-quiz.component.ts index 5de9a52..22f7221 100644 --- a/src/app/shared/lecture-quiz.component.ts +++ b/src/app/shared/lecture-quiz.component.ts @@ -1,14 +1,26 @@ -import {Component, ElementRef, inject, Input, NgZone} from '@angular/core'; +import {afterNextRender, Component, ElementRef, inject, Injector, Input, NgZone} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; import {AsyncPipe} from '@angular/common'; import {IonButton, IonCard, IonIcon, IonSpinner, ModalController} from '@ionic/angular/standalone'; import {addIcons} from 'ionicons'; -import {checkmarkCircle, chevronDown, chevronUp, closeCircle, createOutline, playCircleOutline, refreshOutline} from 'ionicons/icons'; +import { + checkmarkCircle, + chevronDown, + chevronUp, + closeCircle, + createOutline, + peopleOutline, + playCircleOutline, + refreshOutline, + shareSocialOutline, +} from 'ionicons/icons'; import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; import {Lecture} from '../man.service'; import {StudyAidsService} from '../study-aids.service'; import {RichTextComponent} from './rich-text.component'; import {QuizEditorComponent} from './quiz-editor.component'; +import {openEditorsList} from './study-aids-editors.component'; import {formatClock, plainText, renderBlock, renderInline} from '../study-aids/checklist'; import {byline, isAiDraft} from '../study-aids/byline'; import {displayed} from '../study-aids/displayed'; @@ -19,6 +31,9 @@ import {LecturePlayback, QuizPart, QuizQuestion, StudyAids, StudyAidsState} from /** Fewer answers than this say nothing about how hard a question is. */ const MIN_ANSWERS_FOR_STATS = 5; +/** `?video=…&quiz=1` opens the lecture with its quiz ready, which is what a shared link sends. */ +const QUIZ_LINK_PARAM = 'quiz'; + interface QuizItem { question: QuizQuestion; promptHtml: string; @@ -29,6 +44,7 @@ interface QuizItem { interface QuizView { videoId: number; + title: string; duration: number | null; status: StudyAidsState['status']; aids: StudyAids | null; @@ -51,6 +67,8 @@ type ChoiceState = 'correct' | 'wrong' | 'muted' | null; export class LectureQuizComponent { private service = inject(StudyAidsService); private modalCtrl = inject(ModalController); + private host = inject>(ElementRef); + private injector = inject(Injector); private readonly video$ = new BehaviorSubject(null); @@ -67,6 +85,8 @@ export class LectureQuizComponent { protected current: number | null = null; /** Read out by screen readers when an answer is checked. */ protected announcement = ''; + /** What happened to the last shared link. */ + protected shareNote = ''; protected readonly byline = byline; protected readonly isAiDraft = isAiDraft; protected readonly formatClock = formatClock; @@ -76,10 +96,12 @@ export class LectureQuizComponent { private sent = new Set(); private renderedFrom: QuizQuestion[] | null = null; private rendered: QuizItem[] = []; + /** The lecture a shared quiz link points at, until its quiz has been opened. */ + private linkedVideo: string | null = null; protected readonly view$: Observable = combineLatest([ this.video$, - displayed(inject>(ElementRef).nativeElement, inject(NgZone)), + displayed(this.host.nativeElement, inject(NgZone)), ]).pipe( switchMap(([video, shown]) => video?.id && shown ? this.service.watch(video.id).pipe(map(state => this.toView(video, state))) @@ -87,7 +109,11 @@ export class LectureQuizComponent { ); constructor() { - addIcons({checkmarkCircle, chevronDown, chevronUp, closeCircle, createOutline, playCircleOutline, refreshOutline}); + addIcons({checkmarkCircle, chevronDown, chevronUp, closeCircle, createOutline, peopleOutline, playCircleOutline, refreshOutline, shareSocialOutline}); + const query = inject(ActivatedRoute, {optional: true})?.snapshot.queryParamMap; + if (query?.has(QUIZ_LINK_PARAM)) { + this.linkedVideo = query.get('video'); + } } shows(view: QuizView): boolean { @@ -225,6 +251,42 @@ export class LectureQuizComponent { this.service.refresh(view.videoId); } + manageEditors(view: QuizView): void { + void openEditorsList(this.modalCtrl, view.videoId); + } + + /** A link that opens this lecture with its quiz ready, for sending to the class. */ + shareLink(view: QuizView): string { + const url = new URL(location.href); + url.search = ''; + url.hash = ''; + url.searchParams.set('video', String(view.videoId)); + url.searchParams.set(QUIZ_LINK_PARAM, '1'); + + return url.toString(); + } + + async share(view: QuizView): Promise { + const url = this.shareLink(view); + if (typeof navigator.share === 'function') { + try { + await navigator.share({title: 'Quiz: ' + view.title, url}); + + return; + } catch (error) { + if ((error as DOMException).name === 'AbortError') { + return; + } + } + } + try { + await navigator.clipboard.writeText(url); + this.shareNote = 'Link copied. Anyone who signs in to Flick can open the quiz with it.'; + } catch { + this.shareNote = 'Copy this link to share the quiz: ' + url; + } + } + async edit(view: QuizView): Promise { if (!view.aids) { return; @@ -252,6 +314,7 @@ export class LectureQuizComponent { this.answers = {}; this.current = null; this.announcement = ''; + this.shareNote = ''; this.sent.clear(); } const aids = state.status === 'ready' ? state.aids : null; @@ -267,8 +330,13 @@ export class LectureQuizComponent { if (this.current !== null && this.current >= items.length) { this.current = null; } + if (this.linkedVideo === String(videoId) && items.length) { + this.linkedVideo = null; + this.collapsed = false; + afterNextRender(() => this.host.nativeElement.scrollIntoView({behavior: 'smooth', block: 'start'}), {injector: this.injector}); + } - return {videoId, duration: video.duration ?? null, status: state.status, aids, quiz, items}; + return {videoId, title: video.title, duration: video.duration ?? null, status: state.status, aids, quiz, items}; } private itemsFor(questions: QuizQuestion[]): QuizItem[] { diff --git a/src/app/shared/lecture-summary.component.html b/src/app/shared/lecture-summary.component.html index 06e92b6..86174e2 100644 --- a/src/app/shared/lecture-summary.component.html +++ b/src/app/shared/lecture-summary.component.html @@ -27,10 +27,18 @@ @if (view.checklist.pointKeys.length) { @@ -47,7 +55,15 @@ (tickChange)="onTick(view, $event)" (seek)="seek($event)"> } @else if (view.aids?.can_edit) {

    {{ emptyNote(view.aids!) }}

    - Write a summary + + Write a summary + @if (view.aids?.can_manage) { + + + Editors + + } + } @else {

    The AI is still writing a summary of this lecture. Check back later.

    } diff --git a/src/app/shared/lecture-summary.component.spec.ts b/src/app/shared/lecture-summary.component.spec.ts index 1612299..f2f9526 100644 --- a/src/app/shared/lecture-summary.component.spec.ts +++ b/src/app/shared/lecture-summary.component.spec.ts @@ -14,6 +14,7 @@ function aids(changes: Partial = {}): StudyAids { return { video_id: 7, can_edit: false, + can_manage: false, generation: 'ready', summary: {markdown: MARKDOWN, revision: 1, origin: 'ai', edited: false, contributors: [], updated_at: null}, quiz: null, diff --git a/src/app/shared/lecture-summary.component.ts b/src/app/shared/lecture-summary.component.ts index 8e7411f..5e6e085 100644 --- a/src/app/shared/lecture-summary.component.ts +++ b/src/app/shared/lecture-summary.component.ts @@ -2,13 +2,14 @@ import {Component, ElementRef, inject, Input, NgZone} from '@angular/core'; import {AsyncPipe} from '@angular/common'; import {IonButton, IonCard, IonIcon, IonSpinner, ModalController} from '@ionic/angular/standalone'; import {addIcons} from 'ionicons'; -import {chevronDown, chevronUp, createOutline} from 'ionicons/icons'; +import {chevronDown, chevronUp, createOutline, peopleOutline} from 'ionicons/icons'; import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; import {Lecture} from '../man.service'; import {StudyAidsService} from '../study-aids.service'; import {ChecklistViewComponent, TickChange} from './checklist-view.component'; import {SummaryEditorComponent} from './summary-editor.component'; +import {openEditorsList} from './study-aids-editors.component'; import {buildChecklist, Checklist} from '../study-aids/checklist'; import {byline, isAiDraft} from '../study-aids/byline'; import {displayed} from '../study-aids/displayed'; @@ -64,7 +65,7 @@ export class LectureSummaryComponent { ); constructor() { - addIcons({chevronDown, chevronUp, createOutline}); + addIcons({chevronDown, chevronUp, createOutline, peopleOutline}); } shows(view: SummaryView): boolean { @@ -119,6 +120,10 @@ export class LectureSummaryComponent { this.service.refresh(view.videoId); } + manageEditors(view: SummaryView): void { + void openEditorsList(this.modalCtrl, view.videoId); + } + async edit(view: SummaryView): Promise { if (!view.aids) { return; diff --git a/src/app/shared/quiz-editor.component.html b/src/app/shared/quiz-editor.component.html index 97a8771..5662bae 100644 --- a/src/app/shared/quiz-editor.component.html +++ b/src/app/shared/quiz-editor.component.html @@ -51,6 +51,20 @@

    Question {{ index + 1 }}

    +
    + + @if (uploadingFor === form.key + '-prompt') { + + } @else { + + Add image + } + + + + {{ previewing.has(form.key) ? 'Hide preview' : 'Preview' }} + +
    Choices, with the correct answer selected @@ -81,6 +95,33 @@

    Question {{ index + 1 }}

    +
    + + @if (uploadingFor === form.key + '-explanation') { + + } @else { + + Add image to the explanation + } + +
    + + @if (previewing.has(form.key)) { +
    + +
      + @for (choice of form.choices; track $index; let c = $index) { +
    1. + {{ letters[c] }} + +
    2. + } +
    + @if (form.explanation.trim()) { + + } +
    + }
    Question {{ index + 1 }} +
    diff --git a/src/app/shared/quiz-editor.component.scss b/src/app/shared/quiz-editor.component.scss index 533a2f0..f00142a 100644 --- a/src/app/shared/quiz-editor.component.scss +++ b/src/app/shared/quiz-editor.component.scss @@ -168,6 +168,56 @@ line-height: 1.45; } + .field-tools { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: -0.5rem; + + ion-button { + margin: 0; + --padding-start: 0.25rem; + } + } + + .question-preview { + padding: 0.75rem 0.85rem; + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); + font-size: 0.9rem; + line-height: 1.5; + } + + .preview-prompt { + font-weight: 600; + } + + .preview-choices { + display: grid; + gap: 0.25rem; + margin: 0.5rem 0; + padding: 0; + list-style: none; + + li { + display: flex; + gap: 0.5rem; + } + + .is-answer { + color: var(--ion-color-success-shade); + font-weight: 600; + } + } + + .preview-letter { + font-weight: 700; + } + + .preview-explanation { + color: var(--ion-color-medium); + } + .add-question { margin: 0; } diff --git a/src/app/shared/quiz-editor.component.spec.ts b/src/app/shared/quiz-editor.component.spec.ts new file mode 100644 index 0000000..9b86bea --- /dev/null +++ b/src/app/shared/quiz-editor.component.spec.ts @@ -0,0 +1,57 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {AlertController, ModalController} from '@ionic/angular/standalone'; +import {BehaviorSubject, of} from 'rxjs'; +import {QuizEditorComponent} from './quiz-editor.component'; +import {StudyAidsService} from '../study-aids.service'; +import {AuthService} from '../auth.service'; +import {QuestionForm} from '../study-aids/quiz'; + +const IMAGE_URL = 'https://flick-man-app.docchula.com/v1/study_aids/images/01M4D7B2STKMV08W6Z0ZF3G56N.png'; + +describe('QuizEditorComponent', () => { + let fixture: ComponentFixture; + let service: jasmine.SpyObj>; + + const editor = () => fixture.componentInstance as unknown as { + forms: QuestionForm[]; + previewing: Set; + pickImage(form: QuestionForm, field: 'prompt' | 'explanation'): void; + onImagePicked(event: Event): Promise; + }; + + beforeEach(() => { + service = jasmine.createSpyObj('StudyAidsService', ['uploadImage', 'saveQuiz']); + TestBed.configureTestingModule({ + imports: [QuizEditorComponent], + providers: [ + {provide: StudyAidsService, useValue: service}, + {provide: AuthService, useValue: {user: new BehaviorSubject(null)}}, + {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['dismiss'])}, + {provide: AlertController, useValue: jasmine.createSpyObj('AlertController', ['create'])}, + ], + }); + fixture = TestBed.createComponent(QuizEditorComponent); + fixture.componentRef.setInput('videoId', 7); + fixture.componentRef.setInput('quiz', { + questions: [{id: 'a', prompt: 'Which cell?', choices: ['A', 'B'], answer: 0, explanation: null, time: null, stats: null, mine: null}], + revision: 1, origin: 'ai', edited: false, contributors: [], updated_at: null, + }); + fixture.detectChanges(); + }); + + it('adds an uploaded image under the question and shows the preview', async () => { + service.uploadImage.and.returnValue(of({url: IMAGE_URL, width: 20, height: 10})); + const picker = (fixture.nativeElement as HTMLElement).querySelector('input[type=file]') as HTMLInputElement; + spyOn(picker, 'click'); + const form = editor().forms[0]; + editor().pickImage(form, 'prompt'); + Object.defineProperty(picker, 'files', {value: [new File(['x'], 'Cell.gif', {type: 'image/gif'})], configurable: true}); + await editor().onImagePicked({target: picker} as unknown as Event); + fixture.detectChanges(); + + expect(picker.click).toHaveBeenCalled(); + expect(form.prompt).toBe('Which cell?\n\n![Cell](' + IMAGE_URL + ')'); + expect(editor().previewing.has(form.key)).toBeTrue(); + expect((fixture.nativeElement as HTMLElement).querySelector('.question-preview img')?.getAttribute('src')).toBe(IMAGE_URL); + }); +}); diff --git a/src/app/shared/quiz-editor.component.ts b/src/app/shared/quiz-editor.component.ts index 088f990..4e8f268 100644 --- a/src/app/shared/quiz-editor.component.ts +++ b/src/app/shared/quiz-editor.component.ts @@ -1,4 +1,4 @@ -import {afterNextRender, Component, inject, Injector, Input, OnInit, ViewEncapsulation} from '@angular/core'; +import {afterNextRender, Component, ElementRef, inject, Injector, Input, OnInit, ViewChild, ViewEncapsulation} from '@angular/core'; import { AlertController, IonButton, @@ -14,12 +14,16 @@ import { ModalController, } from '@ionic/angular/standalone'; import {addIcons} from 'ionicons'; -import {addOutline, arrowDown, arrowUp, closeOutline, timeOutline, trashOutline} from 'ionicons/icons'; +import {addOutline, arrowDown, arrowUp, closeOutline, eyeOutline, imageOutline, timeOutline, trashOutline} from 'ionicons/icons'; import {StudyAidsService} from '../study-aids.service'; import {CreditFieldComponent} from './credit-field.component'; -import {formatStamp} from '../study-aids/checklist'; +import {RichTextComponent} from './rich-text.component'; +import {formatStamp, renderBlock, renderInline} from '../study-aids/checklist'; import {creditFields, joinNames} from '../study-aids/byline'; +import {describeFailure} from '../study-aids/failures'; +import {IMAGE_TYPES} from '../study-aids/images'; import {readCreditChoice, writeCreditChoice} from '../study-aids/local-store'; +import {uploadPickedImage} from '../study-aids/upload-image'; import { blankQuestionForm, CHOICE_LETTERS, @@ -58,6 +62,7 @@ function inputValue(event: Event): string { IonTextarea, IonSpinner, CreditFieldComponent, + RichTextComponent, ], }) export class QuizEditorComponent implements OnInit { @@ -73,6 +78,8 @@ export class QuizEditorComponent implements OnInit { @Input() duration: number | null = null; @Input() playback: LecturePlayback | null = null; + @ViewChild('imagePicker') imagePicker: ElementRef; + protected forms: QuestionForm[] = []; protected credit: CreditChoice = readCreditChoice(); protected saving = false; @@ -80,18 +87,24 @@ export class QuizEditorComponent implements OnInit { /** Problems are pointed out once the editor first tries to save, not while typing. */ protected checked = false; protected removed: {form: QuestionForm; index: number} | null = null; + /** Which field an image is uploading into, as `key-field`. */ + protected uploadingFor: string | null = null; + protected previewing = new Set(); + protected readonly imageTypes = IMAGE_TYPES.join(','); protected readonly letters = CHOICE_LETTERS; protected readonly limits = LIMITS; protected readonly resetsStats = resetsStats; private serverProblems = new Map(); + private pickTarget: {form: QuestionForm; field: 'prompt' | 'explanation'} | null = null; + private previews = new Map(); private nextKey = 0; private baseRevision = 0; private savedSnapshot = ''; private readonly savedCredit = this.credit; constructor() { - addIcons({addOutline, arrowDown, arrowUp, closeOutline, timeOutline, trashOutline}); + addIcons({addOutline, arrowDown, arrowUp, closeOutline, eyeOutline, imageOutline, timeOutline, trashOutline}); } ngOnInit(): void { @@ -206,6 +219,57 @@ export class QuizEditorComponent implements OnInit { this.edited(); } + pickImage(form: QuestionForm, field: 'prompt' | 'explanation'): void { + this.pickTarget = {form, field}; + this.imagePicker.nativeElement.click(); + } + + async onImagePicked(event: Event): Promise { + const picker = event.target as HTMLInputElement; + const file = picker.files?.[0]; + // Cleared so that picking the same file again still counts as a change. + picker.value = ''; + const target = this.pickTarget; + this.pickTarget = null; + if (!file || !target) { + return; + } + this.uploadingFor = target.form.key + '-' + target.field; + this.problem = null; + try { + const markdown = await uploadPickedImage(this.service, this.videoId, file); + const current = target.form[target.field].trimEnd(); + target.form[target.field] = (current ? current + '\n\n' : '') + markdown; + this.previewing.add(target.form.key); + this.edited(); + } catch (error) { + this.problem = (error as Error).message; + } finally { + this.uploadingFor = null; + } + } + + togglePreview(form: QuestionForm): void { + if (!this.previewing.delete(form.key)) { + this.previewing.add(form.key); + } + } + + /** Rendered Markdown for the previews, kept so typing elsewhere does not redo it. */ + preview(markdown: string, inline = false): string { + const key = (inline ? 'i:' : 'b:') + markdown; + let html = this.previews.get(key); + if (html === undefined) { + if (this.previews.size > 300) { + this.previews.clear(); + } + html = inline ? renderInline(markdown) : renderBlock(markdown); + this.previews.set(key, html); + } + + return html; + } + useCurrentTime(form: QuestionForm): void { if (this.playback) { form.time = formatStamp(this.playback.currentTime()); @@ -311,27 +375,17 @@ export class QuizEditorComponent implements OnInit { } private async explain(failure: SaveFailure): Promise { - switch (failure.kind) { - case 'conflict': - return this.resolveConflict(failure.current as QuizPart | null); - case 'invalid': { - const {byQuestion, general} = errorsByQuestion(failure.errors); - this.serverProblems = byQuestion; - this.problem = general.length ? general.join(' ') : failure.message; - break; - } - case 'forbidden': - this.problem = 'This account can no longer edit questions.'; - break; - case 'rate-limited': - this.problem = 'Too many saves in a short time. Wait a minute, then save again.'; - break; - case 'network': - this.problem = 'Flick could not reach the server. Check your connection, then save again.'; - break; - default: - this.problem = failure.message; + if (failure.kind === 'conflict') { + return this.resolveConflict(failure.current as QuizPart | null); + } + if (failure.kind === 'invalid') { + const {byQuestion, general} = errorsByQuestion(failure.errors); + this.serverProblems = byQuestion; + this.problem = general.length ? general.join(' ') : failure.message; + + return; } + this.problem = failure.kind === 'forbidden' ? 'This account can no longer edit questions.' : describeFailure(failure); } private async resolveConflict(current: QuizPart | null): Promise { diff --git a/src/app/shared/rich-text.component.scss b/src/app/shared/rich-text.component.scss index 417ec04..3970ac2 100644 --- a/src/app/shared/rich-text.component.scss +++ b/src/app/shared/rich-text.component.scss @@ -28,6 +28,14 @@ app-rich-text { color: var(--ion-color-primary); } + img { + display: block; + max-width: 100%; + height: auto; + margin: 0.5em 0; + border-radius: 0.4rem; + } + code { padding: 0.1em 0.3em; border-radius: 0.25em; diff --git a/src/app/shared/study-aids-editors.component.html b/src/app/shared/study-aids-editors.component.html new file mode 100644 index 0000000..90cfc9d --- /dev/null +++ b/src/app/shared/study-aids-editors.component.html @@ -0,0 +1,58 @@ + + + Editors + + Close + + + + + +

    + Editors can rewrite the summary and the questions of every lecture. + Admins can also choose who the editors are. +

    + @if (problem) { + + } + +
    + + + Editor + Admin + + Add + + + @if (loading) { +
    + } @else if (editors.length) { + + @for (editor of editors; track editor.email) { + + +

    + {{ editor.name ?? editor.email }} + @if (isMe(editor)) { + (you) + } +

    + @if (editor.name) { +

    {{ editor.email }}

    + } +
    + + Editor + Admin + + Remove +
    + } +
    + } +
    diff --git a/src/app/shared/study-aids-editors.component.scss b/src/app/shared/study-aids-editors.component.scss new file mode 100644 index 0000000..3b0ae13 --- /dev/null +++ b/src/app/shared/study-aids-editors.component.scss @@ -0,0 +1,50 @@ +.intro { + margin: 0 0 1rem; + color: var(--ion-color-medium); + font-size: 0.85rem; + line-height: 1.45; +} + +.problem { + margin: 0 0 1rem; + padding: 0.6rem 0.85rem; + border-radius: 0.5rem; + background: rgba(var(--ion-color-danger-rgb), 0.12); + font-size: 0.85rem; +} + +.add-editor { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; + margin-bottom: 1.25rem; + + ion-button { + margin: 0 0 0.3rem; + } +} + +.add-email { + flex: 1 1 14rem; +} + +.add-role { + flex: 0 0 8rem; +} + +.loading { + display: flex; + justify-content: center; + padding: 1rem 0; +} + +.editors { + border-radius: 0.75rem; +} + +.you { + color: var(--ion-color-medium); + font-size: 0.8rem; + font-weight: 400; +} diff --git a/src/app/shared/study-aids-editors.component.spec.ts b/src/app/shared/study-aids-editors.component.spec.ts new file mode 100644 index 0000000..6d6a7b7 --- /dev/null +++ b/src/app/shared/study-aids-editors.component.spec.ts @@ -0,0 +1,97 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {AlertController, ModalController} from '@ionic/angular/standalone'; +import {BehaviorSubject, of} from 'rxjs'; +import {StudyAidsEditorsComponent} from './study-aids-editors.component'; +import {StudyAidsService} from '../study-aids.service'; +import {AuthService} from '../auth.service'; +import {StudyAidsEditor} from '../study-aids/study-aids.model'; + +const ME: StudyAidsEditor = {email: 'me@docchula.com', name: 'Me', role: 'admin', added_at: null}; +const KAN: StudyAidsEditor = {email: 'kan@docchula.com', name: 'Kan', role: 'editor', added_at: null}; + +describe('StudyAidsEditorsComponent', () => { + let fixture: ComponentFixture; + let service: jasmine.SpyObj>; + let alertRole: string; + + const text = () => (fixture.nativeElement as HTMLElement).textContent?.replace(/\s+/g, ' ') ?? ''; + const component = () => fixture.componentInstance as unknown as { + email: string; + add(event: Event): Promise; + remove(editor: StudyAidsEditor): Promise; + }; + + beforeEach(async () => { + service = jasmine.createSpyObj('StudyAidsService', ['editors', 'saveEditor', 'removeEditor', 'refresh']); + service.editors.and.returnValue(of([KAN, ME])); + alertRole = 'destructive'; + TestBed.configureTestingModule({ + imports: [StudyAidsEditorsComponent], + providers: [ + {provide: StudyAidsService, useValue: service}, + {provide: AuthService, useValue: {user: new BehaviorSubject({email: 'Me@docchula.com'})}}, + {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['dismiss'])}, + { + provide: AlertController, + useValue: {create: () => Promise.resolve({present: () => Promise.resolve(), onDidDismiss: () => Promise.resolve({role: alertRole})})}, + }, + ], + }); + fixture = TestBed.createComponent(StudyAidsEditorsComponent); + fixture.componentRef.setInput('videoId', 7); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + it('lists admins first and marks the reader', () => { + const names = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('ion-item h3')).map(h => h.textContent?.trim()); + + expect(names[0]).toContain('Me'); + expect(names[0]).toContain('(you)'); + expect(names[1]).toBe('Kan'); + }); + + it('only adds Docchula addresses', async () => { + component().email = 'someone@gmail.com'; + await component().add(new Event('submit')); + fixture.detectChanges(); + + expect(service.saveEditor).not.toHaveBeenCalled(); + expect(text()).toContain('Enter their @docchula.com address.'); + }); + + it('adds an editor by email', async () => { + service.saveEditor.and.returnValue(of({email: 'new@docchula.com', name: null, role: 'editor', added_at: null})); + component().email = ' New@Docchula.com '; + await component().add(new Event('submit')); + fixture.detectChanges(); + + expect(service.saveEditor).toHaveBeenCalledWith('new@docchula.com', 'editor'); + expect(text()).toContain('new@docchula.com'); + expect(service.refresh).not.toHaveBeenCalled(); + }); + + it('removes an editor once confirmed', async () => { + service.removeEditor.and.returnValue(of(true as const)); + await component().remove(KAN); + fixture.detectChanges(); + + expect(service.removeEditor).toHaveBeenCalledWith('kan@docchula.com'); + expect(text()).not.toContain('Kan'); + }); + + it('keeps an editor when the removal is cancelled', async () => { + alertRole = 'cancel'; + await component().remove(KAN); + + expect(service.removeEditor).not.toHaveBeenCalled(); + }); + + it('reloads the reader\'s own permissions after removing themself', async () => { + service.removeEditor.and.returnValue(of(true as const)); + await component().remove(ME); + + expect(service.refresh).toHaveBeenCalledWith(7); + }); +}); diff --git a/src/app/shared/study-aids-editors.component.ts b/src/app/shared/study-aids-editors.component.ts new file mode 100644 index 0000000..bc8eea4 --- /dev/null +++ b/src/app/shared/study-aids-editors.component.ts @@ -0,0 +1,189 @@ +import {Component, inject, Input, OnInit} from '@angular/core'; +import { + AlertController, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonLabel, + IonList, + IonSelect, + IonSelectOption, + IonSpinner, + IonTitle, + IonToolbar, + ModalController, +} from '@ionic/angular/standalone'; +import {firstValueFrom, Observable} from 'rxjs'; +import {take} from 'rxjs/operators'; +import {AuthService} from '../auth.service'; +import {StudyAidsService} from '../study-aids.service'; +import {describeFailure} from '../study-aids/failures'; +import {EditorRole, SaveFailure, StudyAidsEditor} from '../study-aids/study-aids.model'; + +/** Flick only signs in Docchula Google accounts, so nobody else could use the access. */ +const DOCCHULA_EMAIL = /^[^\s@]+@docchula\.com$/i; + +function eventValue(event: Event): T { + return (event as CustomEvent<{value: T}>).detail.value; +} + +function byRole(a: StudyAidsEditor, b: StudyAidsEditor): number { + return (a.role === b.role ? 0 : a.role === 'admin' ? -1 : 1) || (a.name ?? a.email).localeCompare(b.name ?? b.email); +} + +export async function openEditorsList(modalCtrl: ModalController, videoId: number): Promise { + const modal = await modalCtrl.create({component: StudyAidsEditorsComponent, componentProps: {videoId}}); + await modal.present(); +} + +/** Admins choose here who may edit the summaries and questions of every lecture. */ +@Component({ + selector: 'app-study-aids-editors', + templateUrl: './study-aids-editors.component.html', + styleUrls: ['./study-aids-editors.component.scss'], + imports: [ + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonButton, + IonContent, + IonInput, + IonSelect, + IonSelectOption, + IonList, + IonItem, + IonLabel, + IonSpinner, + ], +}) +export class StudyAidsEditorsComponent implements OnInit { + private service = inject(StudyAidsService); + private modalCtrl = inject(ModalController); + private alertCtrl = inject(AlertController); + + /** The lecture this was opened from, whose permissions are reloaded when the admin's own change. */ + @Input() videoId: number; + + protected editors: StudyAidsEditor[] = []; + protected loading = true; + protected busy = false; + protected problem: string | null = null; + protected email = ''; + protected role: EditorRole = 'editor'; + private myEmail = ''; + + constructor() { + inject(AuthService).user.pipe(take(1)).subscribe(user => this.myEmail = (user?.email ?? '').toLowerCase()); + } + + ngOnInit(): void { + void this.load(); + } + + isMe(editor: StudyAidsEditor): boolean { + return editor.email.toLowerCase() === this.myEmail; + } + + onEmail(event: Event): void { + this.email = eventValue(event) ?? ''; + } + + onRole(event: Event): void { + this.role = eventValue(event); + } + + async add(event: Event): Promise { + event.preventDefault(); + const email = this.email.trim().toLowerCase(); + if (!DOCCHULA_EMAIL.test(email)) { + this.problem = 'Enter their @docchula.com address.'; + + return; + } + await this.change(this.service.saveEditor(email, this.role), email, editor => { + this.editors = [...this.editors.filter(other => other.email !== editor.email), editor].sort(byRole); + this.email = ''; + }); + } + + async changeRole(editor: StudyAidsEditor, event: Event): Promise { + const role = eventValue(event); + if (role === editor.role) { + return; + } + if (this.isMe(editor) && !(await this.confirm('Stop being an admin?', 'You will no longer be able to choose the editors.', 'Stop'))) { + // Redraws the select with the role that still applies. + this.editors = [...this.editors]; + + return; + } + await this.change(this.service.saveEditor(editor.email, role), editor.email, saved => { + this.editors = this.editors.map(other => other.email === saved.email ? saved : other).sort(byRole); + }); + } + + async remove(editor: StudyAidsEditor): Promise { + const confirmed = this.isMe(editor) + ? await this.confirm('Remove yourself?', 'You will no longer be able to edit summaries and questions.', 'Remove') + : await this.confirm(`Remove ${editor.name ?? editor.email}?`, 'They will no longer be able to edit summaries and questions.', 'Remove'); + if (confirmed) { + await this.change(this.service.removeEditor(editor.email), editor.email, () => { + this.editors = this.editors.filter(other => other.email !== editor.email); + }); + } + } + + close(): void { + void this.modalCtrl.dismiss(); + } + + private async load(): Promise { + try { + this.editors = (await firstValueFrom(this.service.editors())).sort(byRole); + } catch (failure) { + this.problem = (failure as SaveFailure).kind === 'forbidden' + ? 'Only admins can see and change the editors.' + : describeFailure(failure as SaveFailure); + } finally { + this.loading = false; + } + } + + private async change(request: Observable, email: string, apply: (result: T) => void): Promise { + this.busy = true; + this.problem = null; + try { + apply(await firstValueFrom(request)); + if (email === this.myEmail) { + // Their own access changed, so the cards behind should show what they can do now. + this.service.refresh(this.videoId); + if (!this.editors.some(editor => this.isMe(editor) && editor.role === 'admin')) { + this.close(); + } + } + } catch (failure) { + this.problem = describeFailure(failure as SaveFailure); + this.editors = [...this.editors]; + } finally { + this.busy = false; + } + } + + private async confirm(header: string, message: string, action: string): Promise { + const alert = await this.alertCtrl.create({ + header, + message, + buttons: [ + {text: 'Cancel', role: 'cancel'}, + {text: action, role: 'destructive'}, + ], + }); + await alert.present(); + + return (await alert.onDidDismiss()).role === 'destructive'; + } +} diff --git a/src/app/shared/summary-editor.component.html b/src/app/shared/summary-editor.component.html index 025f791..653674a 100644 --- a/src/app/shared/summary-editor.component.html +++ b/src/app/shared/summary-editor.component.html @@ -38,6 +38,14 @@ @if (playback) { Insert video time } + + @if (uploading) { + + } @else { + Add image + } + + Formatting help @@ -60,6 +68,8 @@
    A table: a header row, then |---|---|, then one row per line.
    $x^2$
    Maths.
    +
    ![description](address)
    +
    An image. Use Add image to upload one; it is placed at the cursor.
    } -

    - {{ text.length | number }} / {{ limit | number }} characters -

    - -
    - @if (preview.title || preview.sections.length) { - - } @else { -

    The checklist appears here as you write.

    - } -
    - - - - - diff --git a/src/app/shared/summary-editor.component.scss b/src/app/shared/summary-editor.component.scss deleted file mode 100644 index fdff299..0000000 --- a/src/app/shared/summary-editor.component.scss +++ /dev/null @@ -1,112 +0,0 @@ -@use 'study-editor'; - -.summary-editor { - .editor-mode { - margin-bottom: 0.75rem; - } - - .editor-panes { - display: grid; - gap: 1rem; - } - - .editor-panes:not(.showing-preview) .preview-pane, - .editor-panes.showing-preview .write-pane { - display: none; - } - - .format-bar { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - margin-bottom: 0.5rem; - - ion-button { - margin: 0; - } - } - - .format-help { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0.35rem 0.75rem; - margin: 0 0 0.75rem; - padding: 0.75rem; - border-radius: 0.5rem; - background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); - font-size: 0.8rem; - line-height: 1.4; - - dt { - white-space: nowrap; - } - - dd { - margin: 0; - } - } - - .markdown-input { - display: block; - box-sizing: border-box; - width: 100%; - min-height: 55vh; - padding: 0.75rem; - border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.2); - border-radius: 0.5rem; - background: transparent; - color: var(--ion-text-color); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.85rem; - line-height: 1.55; - resize: vertical; - tab-size: 3; - - &:focus { - border-color: var(--ion-color-primary); - outline: none; - } - } - - .char-count { - margin: 0.35rem 0 0; - color: var(--ion-color-medium); - font-size: 0.75rem; - text-align: right; - - &.over-limit { - color: var(--ion-color-danger); - font-weight: 600; - } - } - - .preview-pane { - padding: 0.75rem 1rem; - border-radius: 0.5rem; - background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.03); - } - - // Wide enough to write and see the result side by side. - @media (min-width: 992px) { - .editor-mode { - display: none; - } - - .editor-panes { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: start; - } - - .editor-panes:not(.showing-preview) .preview-pane, - .editor-panes.showing-preview .write-pane { - display: block; - } - - .preview-pane { - position: sticky; - top: 0; - max-height: calc(92vh - 9rem); - overflow-y: auto; - } - } -} diff --git a/src/app/shared/summary-editor.component.spec.ts b/src/app/shared/summary-editor.component.spec.ts deleted file mode 100644 index 94b33de..0000000 --- a/src/app/shared/summary-editor.component.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {AlertController, ModalController} from '@ionic/angular'; -import {BehaviorSubject, of} from 'rxjs'; -import {SummaryEditorComponent} from './summary-editor.component'; -import {StudyAidsService} from '../study-aids.service'; -import {AuthService} from '../auth.service'; -import {DRIVE_IMAGE_CHECK} from '../study-aids/image-link'; - -const ID = '1AbCdEfGhIjKlMnOpQrStUvWxYz012345'; - -describe('SummaryEditorComponent', () => { - let fixture: ComponentFixture; - let service: jasmine.SpyObj>; - let pasted: {link: string; description: string}; - let readersCanSee: boolean; - - const textarea = () => (fixture.nativeElement as HTMLElement).querySelector('textarea') as HTMLTextAreaElement; - const addImage = async () => { - await (fixture.componentInstance as unknown as {addImage(): Promise}).addImage(); - fixture.detectChanges(); - await fixture.whenStable(); - }; - - beforeEach(() => { - service = jasmine.createSpyObj('StudyAidsService', ['saveSummary', 'profile']); - service.profile.and.returnValue(of(null)); - pasted = {link: `https://drive.google.com/file/d/${ID}/view?usp=sharing`, description: 'Golgi'}; - readersCanSee = true; - TestBed.configureTestingModule({ - imports: [SummaryEditorComponent], - providers: [ - {provide: StudyAidsService, useValue: service}, - {provide: AuthService, useValue: {user: new BehaviorSubject(null)}}, - {provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['dismiss'])}, - {provide: AlertController, useValue: {create: () => Promise.resolve({ - present: () => Promise.resolve(), - onDidDismiss: () => Promise.resolve({role: 'confirm', data: {values: pasted}}), - })}}, - {provide: DRIVE_IMAGE_CHECK, useValue: () => Promise.resolve(readersCanSee)}, - ], - }); - fixture = TestBed.createComponent(SummaryEditorComponent); - fixture.componentRef.setInput('videoId', 7); - fixture.componentRef.setInput('summary', { - markdown: '## Golgi\n\n1. Point', revision: 3, origin: 'ai', edited: false, contributors: [], updated_at: null, verified: null, - }); - fixture.detectChanges(); - }); - - it('puts a Google Drive picture where the cursor was', async () => { - // ngModel fills the textarea after the first render, which would move the cursor. - await fixture.whenStable(); - textarea().setSelectionRange(8, 8); - await addImage(); - - expect(textarea().value).toBe(`## Golgi\n![Golgi](https://drive.google.com/file/d/${ID}/view)\n\n\n1. Point`); - }); - - it('says why a picture cannot be added and leaves the text alone', async () => { - readersCanSee = false; - await addImage(); - - expect((fixture.nativeElement as HTMLElement).querySelector('.editor-problem')?.textContent).toContain('Anyone with the link'); - expect(textarea().value).toBe('## Golgi\n\n1. Point'); - }); -}); diff --git a/src/app/shared/summary-editor.component.ts b/src/app/shared/summary-editor.component.ts deleted file mode 100644 index d05e706..0000000 --- a/src/app/shared/summary-editor.component.ts +++ /dev/null @@ -1,244 +0,0 @@ -import {Component, ElementRef, inject, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation} from '@angular/core'; -import {DecimalPipe} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import { - AlertController, - IonButton, - IonButtons, - IonContent, - IonHeader, - IonLabel, - IonSegment, - IonSegmentButton, - IonSpinner, - IonTitle, - IonToolbar, - ModalController, -} from '@ionic/angular'; -import {StudyAidsService} from '../study-aids.service'; -import {ChecklistViewComponent} from './checklist-view.component'; -import {CreditFieldComponent} from './credit-field.component'; -import {buildChecklist, Checklist, formatStamp} from '../study-aids/checklist'; -import {describeFailure} from '../study-aids/failures'; -import {normalizeImageLinks} from '../study-aids/images'; -import {readCredit, writeCredit} from '../study-aids/local-store'; -import {askForDriveImage, DRIVE_IMAGE_CHECK, driveImageMarkdown} from '../study-aids/image-link'; -import {GenerationState, LecturePlayback, LIMITS, SaveFailure, SummaryPart} from '../study-aids/study-aids.model'; - -/** Editors rewrite the summary's Markdown here, with a live preview of the checklist it makes. */ -@Component({ - selector: 'app-summary-editor', - templateUrl: './summary-editor.component.html', - styleUrls: ['./summary-editor.component.scss'], - // Sizes the ion-modal, which sits outside this component (see _study-editor.scss) - encapsulation: ViewEncapsulation.None, - imports: [ - DecimalPipe, - FormsModule, - IonHeader, - IonToolbar, - IonTitle, - IonButtons, - IonButton, - IonContent, - IonSegment, - IonSegmentButton, - IonLabel, - IonSpinner, - ChecklistViewComponent, - CreditFieldComponent, - ], -}) -export class SummaryEditorComponent implements OnInit, OnDestroy { - private service = inject(StudyAidsService); - private modalCtrl = inject(ModalController); - private alertCtrl = inject(AlertController); - private canShowImage = inject(DRIVE_IMAGE_CHECK); - - @Input() videoId: number; - @Input() summary: SummaryPart | null = null; - @Input() generation: GenerationState = 'none'; - @Input() playback: LecturePlayback | null = null; - - @ViewChild('input') input: ElementRef; - - protected readonly limit = LIMITS.summaryChars; - protected text = ''; - protected mode: 'write' | 'preview' = 'write'; - protected preview: Checklist = buildChecklist(''); - protected credit = readCredit(); - protected saving = false; - /** While a picture's Drive link is being checked. */ - protected checkingImage = false; - protected problem: string | null = null; - protected showHelp = false; - - private baseRevision = 0; - private savedText = ''; - private readonly savedCredit = this.credit; - private previewTimer: ReturnType | undefined; - - ngOnInit(): void { - this.text = this.savedText = this.summary?.markdown ?? ''; - this.baseRevision = this.summary?.revision ?? 0; - this.preview = buildChecklist(this.text); - } - - ngOnDestroy(): void { - clearTimeout(this.previewTimer); - } - - get notice(): string | null { - if (!this.summary) { - return this.generation === 'pending' - ? 'The AI is still drafting a summary. Once you save yours, the AI draft will not replace it.' - : null; - } - - return this.summary.origin === 'ai' && !this.summary.edited - ? 'This is the AI draft. Once you save a change, the AI will not rewrite it.' - : null; - } - - get changed(): boolean { - return this.text !== this.savedText - || this.credit !== this.savedCredit; - } - - canSave(): boolean { - return !this.saving && this.changed && !!this.text.trim() && this.text.length <= this.limit; - } - - onTextChange(): void { - this.problem = null; - clearTimeout(this.previewTimer); - this.previewTimer = setTimeout(() => this.preview = buildChecklist(this.text), 300); - } - - onModeChange(event: Event): void { - this.mode = (event as CustomEvent<{value: string}>).detail.value === 'preview' ? 'preview' : 'write'; - if (this.mode === 'preview') { - clearTimeout(this.previewTimer); - this.preview = buildChecklist(this.text); - } - } - - /** Wraps the selection in a marker such as `**`, or inserts an example to type over. */ - wrap(marker: string): void { - const input = this.input.nativeElement; - const start = input.selectionStart; - const selected = input.value.slice(start, input.selectionEnd) || 'text'; - this.insert(marker + selected + marker, start, input.selectionEnd); - input.setSelectionRange(start + marker.length, start + marker.length + selected.length); - } - - /** Puts the video's current time at the cursor, for the end of a section heading. */ - insertTime(): void { - if (!this.playback) { - return; - } - const input = this.input.nativeElement; - const before = input.value.slice(0, input.selectionStart); - const stamp = (before && !/\s$/.test(before) ? ' ' : '') + '[' + formatStamp(this.playback.currentTime()) + ']'; - this.insert(stamp, input.selectionStart, input.selectionEnd); - } - - /** Puts a picture from Google Drive where the cursor was. */ - async addImage(): Promise { - // The cursor may move while the link is asked for and checked, so the place to put it is taken now. - const {selectionStart: start, selectionEnd: end} = this.input.nativeElement; - this.problem = null; - try { - const choice = await askForDriveImage(this.alertCtrl); - if (!choice) { - return; - } - this.checkingImage = true; - this.insert('\n' + await driveImageMarkdown(choice, this.canShowImage) + '\n', start, end); - } catch (error) { - this.problem = (error as Error).message; - } finally { - this.checkingImage = false; - } - } - - save(): void { - if (!this.canSave()) { - return; - } - this.saving = true; - this.problem = null; - this.service.saveSummary(this.videoId, { - markdown: normalizeImageLinks(this.text.trim()), - revision: this.baseRevision, - credit: this.credit, - }).subscribe({ - next: () => { - this.saving = false; - writeCredit(this.credit); - void this.modalCtrl.dismiss(null, 'saved'); - }, - error: (failure: SaveFailure) => { - this.saving = false; - void this.explain(failure); - }, - }); - } - - async cancel(): Promise { - if (this.text !== this.savedText) { - const alert = await this.alertCtrl.create({ - header: 'Discard your changes?', - buttons: [ - {text: 'Keep editing', role: 'cancel'}, - {text: 'Discard', role: 'destructive'}, - ], - }); - await alert.present(); - if ((await alert.onDidDismiss()).role !== 'destructive') { - return; - } - } - await this.modalCtrl.dismiss(null, 'cancel'); - } - - private insert(text: string, start: number, end: number): void { - const input = this.input.nativeElement; - input.setRangeText(text, start, end, 'end'); - input.focus(); - // Lets ngModel see the change. - input.dispatchEvent(new Event('input')); - } - - private async explain(failure: SaveFailure): Promise { - if (failure.kind === 'conflict') { - return this.resolveConflict(failure.current as SummaryPart | null); - } - this.problem = failure.kind === 'forbidden' - ? 'This account can no longer edit summaries. Copy your text if you want to keep it.' - : describeFailure(failure); - } - - private async resolveConflict(current: SummaryPart | null): Promise { - const alert = await this.alertCtrl.create({ - header: 'Someone else saved first', - message: 'Another editor changed this summary while you were editing it.', - backdropDismiss: false, - buttons: [ - {text: 'Keep editing', role: 'cancel'}, - {text: 'Discard mine, load theirs', role: 'theirs'}, - {text: 'Replace theirs with mine', role: 'mine'}, - ], - }); - await alert.present(); - const {role} = await alert.onDidDismiss(); - if (role === 'theirs') { - this.text = this.savedText = current?.markdown ?? ''; - this.baseRevision = current?.revision ?? 0; - this.preview = buildChecklist(this.text); - } else if (role === 'mine') { - this.baseRevision = current?.revision ?? 0; - this.save(); - } - } -} diff --git a/src/app/shared/transcript-body.component.html b/src/app/shared/transcript-body.component.html index 79cc9da..56a8931 100644 --- a/src/app/shared/transcript-body.component.html +++ b/src/app/shared/transcript-body.component.html @@ -22,7 +22,7 @@ Select a time to play the lecture from there. }

    - + diff --git a/src/app/shared/verification.component.html b/src/app/shared/verification.component.html deleted file mode 100644 index da62a9a..0000000 --- a/src/app/shared/verification.component.html +++ /dev/null @@ -1,25 +0,0 @@ -@let state = verifiedState(part); -@if (verifiedLine(part); as line) { -

    - - {{ line }}. -

    -} -@if (canEdit) { - @let profile = myProfile$ | async; -
    - @if (busy) { - - } @else if (verifiedByMe(profile)) { - - } @else { - - - {{ state === 'earlier' ? 'Verify this version' : 'Verify' }} - - } -
    -} -@if (problem) { - -} diff --git a/src/app/shared/verification.component.scss b/src/app/shared/verification.component.scss deleted file mode 100644 index 7fae549..0000000 --- a/src/app/shared/verification.component.scss +++ /dev/null @@ -1,53 +0,0 @@ -@use 'study-shared'; - -:host { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.35rem 0.75rem; - margin: -0.25rem 0 0.75rem; -} - -.verification { - display: inline-flex; - align-items: center; - gap: 0.3rem; - margin: 0; - color: var(--ion-color-medium); - font-size: 0.8rem; - line-height: 1.4; - - ion-icon { - flex: none; - font-size: 1rem; - } - - &.is-verified { - color: var(--ion-color-success-shade, var(--ion-color-success)); - font-weight: 600; - } -} - -.verify-actions { - display: inline-flex; - align-items: center; -} - -.link-button { - padding: 0; - border: 0; - background: none; - color: var(--ion-color-primary); - font: inherit; - font-size: 0.8rem; - text-decoration: underline; - cursor: pointer; -} - -.verify-problem { - flex-basis: 100%; - margin: 0; - color: var(--ion-color-danger); - font-size: 0.8rem; - line-height: 1.4; -} diff --git a/src/app/shared/verification.component.spec.ts b/src/app/shared/verification.component.spec.ts deleted file mode 100644 index a89fda6..0000000 --- a/src/app/shared/verification.component.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {AlertController} from '@ionic/angular'; -import {of} from 'rxjs'; -import {VerificationComponent} from './verification.component'; -import {StudyAidsService} from '../study-aids.service'; -import {PartMeta} from '../study-aids/study-aids.model'; - -const PART: PartMeta = {revision: 2, origin: 'ai', edited: true, contributors: [], updated_at: null, verified: null}; - -describe('VerificationComponent', () => { - let fixture: ComponentFixture; - let service: jasmine.SpyObj>; - let confirm: string; - - const element = () => fixture.nativeElement as HTMLElement; - const text = () => element().textContent?.replace(/\s+/g, ' ').trim() ?? ''; - - function create(part: PartMeta, canEdit: boolean): void { - fixture = TestBed.createComponent(VerificationComponent); - fixture.componentRef.setInput('videoId', 7); - fixture.componentRef.setInput('part', part); - fixture.componentRef.setInput('name', 'summary'); - fixture.componentRef.setInput('canEdit', canEdit); - fixture.detectChanges(); - } - - beforeEach(() => { - service = jasmine.createSpyObj('StudyAidsService', ['verify', 'profile']); - service.verify.and.returnValue(of({...PART, markdown: ''})); - service.profile.and.returnValue(of({name: 'Ajarn Dev', changed_at: null, next_change_at: null})); - confirm = 'confirm'; - TestBed.configureTestingModule({ - imports: [VerificationComponent], - providers: [ - {provide: StudyAidsService, useValue: service}, - {provide: AlertController, useValue: {create: () => Promise.resolve({ - present: () => Promise.resolve(), - onDidDismiss: () => Promise.resolve({role: confirm}), - })}}, - ], - }); - }); - - it('tells students when nobody has checked the AI draft', () => { - create(PART, false); - - expect(text()).toBe('Not verified by an editor yet.'); - expect(service.profile).not.toHaveBeenCalled(); - }); - - it('names who verified the version on show', () => { - create({...PART, verified: {revision: 2, by: [{name: 'Kan'}, {name: 'Chain'}], at: '2026-10-09T16:52:22+00:00'}}, false); - - expect(text()).toBe('Verified by Kan and Chain.'); - expect(element().querySelector('.verification')?.classList).toContain('is-verified'); - }); - - it('says so when the version verified is an older one', () => { - create({...PART, verified: {revision: 1, by: [{name: 'Kan'}], at: null}}, true); - - expect(text()).toContain('Verified by Kan before the latest changes.'); - expect(text()).toContain('Verify this version'); - }); - - it('lets an editor verify after confirming', async () => { - create(PART, true); - element().querySelector('ion-button')?.click(); - await fixture.whenStable(); - - expect(service.verify).toHaveBeenCalledOnceWith(7, 'summary', 2, true); - }); - - it('does nothing when the editor thinks better of it', async () => { - confirm = 'cancel'; - create(PART, true); - element().querySelector('ion-button')?.click(); - await fixture.whenStable(); - - expect(service.verify).not.toHaveBeenCalled(); - }); - - it('lets an editor take back their own verification', async () => { - create({...PART, verified: {revision: 2, by: [{name: 'Ajarn Dev'}], at: null}}, true); - element().querySelector('.link-button')?.click(); - await fixture.whenStable(); - - expect(service.verify).toHaveBeenCalledOnceWith(7, 'summary', 2, false); - }); -}); diff --git a/src/app/shared/verification.component.ts b/src/app/shared/verification.component.ts deleted file mode 100644 index 5c42e97..0000000 --- a/src/app/shared/verification.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {Component, inject, Input} from '@angular/core'; -import {AsyncPipe} from '@angular/common'; -import {AlertController, IonButton, IonIcon, IonSpinner} from '@ionic/angular'; -import {addIcons} from 'ionicons'; -import {shieldCheckmark, shieldOutline} from 'ionicons/icons'; -import {firstValueFrom, Observable} from 'rxjs'; -import {StudyAidsService} from '../study-aids.service'; -import {formatDay, verifiedLine, verifiedState} from '../study-aids/byline'; -import {describeFailure} from '../study-aids/failures'; -import {EditorProfile, PartMeta, PartName, SaveFailure} from '../study-aids/study-aids.model'; - -/** - * Who checked the summary or the quiz against the lecture. Editors and admins mark the version on - * show as verified here, under their display name, or take their own mark back. - */ -@Component({ - selector: 'app-verification', - templateUrl: './verification.component.html', - styleUrls: ['./verification.component.scss'], - imports: [AsyncPipe, IonButton, IonIcon, IonSpinner], -}) -export class VerificationComponent { - private service = inject(StudyAidsService); - private alertCtrl = inject(AlertController); - - @Input({required: true}) videoId: number; - @Input({required: true}) part: PartMeta; - @Input({required: true}) name: PartName; - @Input() canEdit = false; - - protected busy = false; - protected problem: string | null = null; - protected readonly verifiedLine = verifiedLine; - protected readonly verifiedState = verifiedState; - private profile$: Observable | null = null; - - /** Only asked for when the reader is an editor. */ - protected get myProfile$(): Observable { - return this.profile$ ??= this.service.profile(); - } - - constructor() { - addIcons({shieldCheckmark, shieldOutline}); - } - - when(): string | null { - return this.part.verified?.at ? formatDay(this.part.verified.at) : null; - } - - /** Whether this editor is among those who verified the version on show. */ - verifiedByMe(profile: EditorProfile | null): boolean { - return !!profile && verifiedState(this.part) === 'current' - && !!this.part.verified?.by.some(verifier => verifier.name === profile.name); - } - - async verify(profile: EditorProfile | null): Promise { - const alert = await this.alertCtrl.create({ - header: `Verify this ${this.name}?`, - message: `Only verify it once you have checked it against the lecture. Students will see "Verified by ${profile?.name ?? 'you'}".`, - buttons: [ - {text: 'Cancel', role: 'cancel'}, - {text: 'Verify', role: 'confirm'}, - ], - }); - await alert.present(); - if ((await alert.onDidDismiss()).role === 'confirm') { - await this.send(true); - } - } - - async unverify(): Promise { - await this.send(false); - } - - private async send(verified: boolean): Promise { - this.busy = true; - this.problem = null; - try { - await firstValueFrom(this.service.verify(this.videoId, this.name, this.part.revision, verified)); - } catch (failure) { - this.problem = (failure as SaveFailure).kind === 'conflict' - ? `The ${this.name} changed since you opened it. Check the new version before verifying it.` - : describeFailure(failure as SaveFailure); - } finally { - this.busy = false; - } - } -} diff --git a/src/app/shared/version-switch.component.html b/src/app/shared/version-switch.component.html deleted file mode 100644 index 366c6a6..0000000 --- a/src/app/shared/version-switch.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
    - - @if (fromAi) { - - } - -
    diff --git a/src/app/shared/version-switch.component.scss b/src/app/shared/version-switch.component.scss deleted file mode 100644 index b81abfb..0000000 --- a/src/app/shared/version-switch.component.scss +++ /dev/null @@ -1,39 +0,0 @@ -:host { - display: block; - margin: 0 0 0.85rem; -} - -.version-switch { - display: inline-flex; - flex-wrap: wrap; - gap: 0.25rem; - padding: 0.2rem; - border-radius: 1rem; - background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.05); -} - -button { - padding: 0.25rem 0.75rem; - border: 0; - border-radius: 1rem; - background: transparent; - color: var(--ion-text-color); - font: inherit; - font-size: 0.8rem; - cursor: pointer; - - &:hover { - background: rgba(var(--ion-color-primary-rgb), 0.08); - } - - &.active { - background: var(--ion-color-primary); - color: var(--ion-color-primary-contrast); - font-weight: 600; - } - - &:focus-visible { - outline: 2px solid var(--ion-color-primary); - outline-offset: 2px; - } -} diff --git a/src/app/shared/version-switch.component.ts b/src/app/shared/version-switch.component.ts deleted file mode 100644 index b86c31c..0000000 --- a/src/app/shared/version-switch.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {Component, EventEmitter, Input, Output} from '@angular/core'; - -/** `current` is what editors made of the part; `original` the AI's own draft; `changes` the two compared. */ -export type PartView = 'current' | 'original' | 'changes'; - -/** Chooses which version of the summary or the quiz to show. The edited one shows unless the reader asks. */ -@Component({ - selector: 'app-version-switch', - templateUrl: './version-switch.component.html', - styleUrls: ['./version-switch.component.scss'], -}) -export class VersionSwitchComponent { - @Input() view: PartView = 'current'; - @Output() viewChange = new EventEmitter(); - /** Whether the part began as the AI's draft, which can then be shown as the AI wrote it. */ - @Input() fromAi = true; - - choose(view: PartView): void { - if (view !== this.view) { - this.view = view; - this.viewChange.emit(view); - } - } -} diff --git a/src/app/study-aids-progress.service.spec.ts b/src/app/study-aids-progress.service.spec.ts index 8b4b100..1b6ed26 100644 --- a/src/app/study-aids-progress.service.spec.ts +++ b/src/app/study-aids-progress.service.spec.ts @@ -6,7 +6,7 @@ import {StudyAids, StudyAidsState} from './study-aids/study-aids.model'; function lecture(myTicks: string[] | null): StudyAidsState { return {status: 'ready', aids: { - video_id: 7, can_edit: false, can_manage: false, generation: 'ready', has_transcript: false, my_ticks: myTicks, summary: null, quiz: null, + video_id: 7, generation: 'ready', has_transcript: false, my_ticks: myTicks, summary: null, quiz: null, } as StudyAids}; } diff --git a/src/app/study-aids.service.spec.ts b/src/app/study-aids.service.spec.ts index 3c6cf9b..be11d31 100644 --- a/src/app/study-aids.service.spec.ts +++ b/src/app/study-aids.service.spec.ts @@ -3,17 +3,9 @@ import {HttpErrorResponse} from '@angular/common/http'; import {Observable, of, throwError} from 'rxjs'; import {ManService} from './man.service'; import {StudyAidsService} from './study-aids.service'; -import {EditorProfile, QuizPart, SaveFailure, StudyAidsState, SummaryPart, SummaryRevision, TranscriptState} from './study-aids/study-aids.model'; - -const SUMMARY = { - markdown: '## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1', - revision: 1, - origin: 'ai', - edited: false, - contributors: [], - updated_at: '2026-10-10T03:28:25+00:00', - verified: null, -}; +import {StudyAidsState, TranscriptState} from './study-aids/study-aids.model'; + +const SUMMARY = {markdown: '## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1'}; const QUESTION = { id: '01M4D7B2STKMV08W6Z0ZF3G56N', @@ -26,7 +18,7 @@ const QUESTION = { mine: null, }; -const QUIZ = {questions: [QUESTION], revision: 2, origin: 'ai', edited: true, contributors: [{name: 'Kan & Chain'}], updated_at: null, verified: null}; +const QUIZ = {questions: [QUESTION]}; interface Body { operationName: string; @@ -47,7 +39,7 @@ function graphqlError(message: string, extensions: object = {}): Observable { return data({video: { has_transcript: false, - studyAids: {video_id: '7', can_edit: true, can_manage: true, generation: 'ready', summary: SUMMARY, quiz: QUIZ, ...changes}, + studyAids: {video_id: '7', generation: 'ready', summary: SUMMARY, quiz: QUIZ, ...changes}, ...video, }}); } @@ -94,25 +86,18 @@ describe('StudyAidsService', () => { expect(state.status).toBe('ready'); if (state.status === 'ready') { expect(state.aids.video_id).toBe(7); - expect(state.aids.can_edit).toBeTrue(); - expect(state.aids.can_manage).toBeTrue(); + expect(state.aids.generation).toBe('ready'); expect(state.aids.has_transcript).toBeFalse(); - expect(state.aids.summary?.markdown).toBe(SUMMARY.markdown); - expect(state.aids.summary?.verified).toBeNull(); + expect(state.aids.summary).toEqual(SUMMARY); expect(state.aids.quiz?.questions[0].time).toBe(465); - expect(state.aids.quiz?.contributors).toEqual([{name: 'Kan & Chain'}]); } }); - it('reads who verified a part and whether there is a transcript', () => { - handlers['StudyAids'] = () => lecture({ - summary: {...SUMMARY, verified: {revision: 1, by: [{name: 'Ajarn Dev'}], at: '2026-10-10T03:28:39+00:00'}}, - }, {has_transcript: true}); + it('reads whether there is a transcript', () => { + handlers['StudyAids'] = () => lecture({}, {has_transcript: true}); const state = latest(7); expect(state.status === 'ready' && state.aids.has_transcript).toBeTrue(); - expect(state.status === 'ready' && state.aids.summary?.verified) - .toEqual({revision: 1, by: [{name: 'Ajarn Dev'}], at: '2026-10-10T03:28:39+00:00'}); }); it('asks once for both tabs', () => { @@ -156,74 +141,6 @@ describe('StudyAidsService', () => { }); }); - describe('saveSummary', () => { - const change = {markdown: '## New', revision: 1, credit: true}; - - it('sends the change and shows the saved summary', () => { - latest(7); - handlers['SaveStudySummary'] = () => data({saveStudySummary: { - outcome: 'saved', - summary: {...SUMMARY, markdown: '## New', revision: 2, edited: true, contributors: [{name: 'Kan'}]}, - }}); - let saved: SummaryPart | undefined; - service.saveSummary(7, change).subscribe(part => saved = part); - - expect(variablesOf('SaveStudySummary')).toEqual({video_id: '7', markdown: '## New', revision: 1, credit: true}); - expect(saved?.revision).toBe(2); - const state = latest(7); - expect(state.status === 'ready' && state.aids.summary?.markdown).toBe('## New'); - }); - - it('reports a conflict with the version saved first', () => { - latest(7); - handlers['SaveStudySummary'] = () => data({saveStudySummary: {outcome: 'conflict', summary: {...SUMMARY, revision: 5, markdown: '## Theirs'}}}); - let failure: SaveFailure | undefined; - service.saveSummary(7, change).subscribe({error: error => failure = error}); - - expect(failure?.kind).toBe('conflict'); - expect(failure?.kind === 'conflict' && failure.current?.revision).toBe(5); - const state = latest(7); - expect(state.status === 'ready' && state.aids.summary?.markdown).toBe('## Theirs'); - }); - - it('passes on validation messages under their fields', () => { - handlers['SaveStudySummary'] = () => graphqlError('Validation failed for the field [saveStudySummary].', { - validation: {markdown: ['The markdown field is required.']}, - }); - let failure: SaveFailure | undefined; - service.saveSummary(7, change).subscribe({error: error => failure = error}); - - expect(failure).toEqual({kind: 'invalid', message: 'The markdown field is required.', errors: {markdown: ['The markdown field is required.']}}); - }); - - it('tells a refusal, the rate limit and a lost connection apart', () => { - let failure: SaveFailure | undefined; - handlers['SaveStudySummary'] = () => graphqlError('Only study-aid editors can change the summary.', {category: 'authorization'}); - service.saveSummary(7, change).subscribe({error: error => failure = error}); - expect(failure?.kind).toBe('forbidden'); - - handlers['SaveStudySummary'] = () => graphqlError('Rate limit for Mutation.saveStudySummary exceeded. Try again later.'); - service.saveSummary(7, change).subscribe({error: error => failure = error}); - expect(failure?.kind).toBe('rate-limited'); - - handlers['SaveStudySummary'] = () => httpError(0); - service.saveSummary(7, change).subscribe({error: error => failure = error}); - expect(failure?.kind).toBe('network'); - }); - }); - - describe('saveQuiz', () => { - it('sends the questions as FlickMan takes them', () => { - handlers['SaveStudyQuiz'] = () => data({saveStudyQuiz: {outcome: 'unchanged', quiz: QUIZ}}); - const questions = [{id: null, prompt: 'Which?', choices: ['A', 'B'], answer: 1, explanation: null, time: null}]; - let saved: QuizPart | undefined; - service.saveQuiz(7, {questions, revision: 2, credit: false}).subscribe(part => saved = part); - - expect(variablesOf('SaveStudyQuiz')).toEqual({video_id: '7', questions, revision: 2, credit: false}); - expect(saved?.questions.length).toBe(1); - }); - }); - describe('answer', () => { it('records the answer and shows the new class stats', () => { latest(7); @@ -246,75 +163,6 @@ describe('StudyAidsService', () => { }); }); - describe('verify', () => { - it('marks the version on show as verified and shows it', () => { - latest(7); - const verified = {revision: 1, by: [{name: 'Ajarn Dev'}], at: '2026-10-10T03:28:39+00:00'}; - handlers['VerifyStudySummary'] = () => data({verifyStudySummary: {outcome: 'saved', summary: {...SUMMARY, verified}}}); - let part: SummaryPart | QuizPart | undefined; - service.verify(7, 'summary', 1, true).subscribe(saved => part = saved); - - expect(variablesOf('VerifyStudySummary')).toEqual({video_id: '7', revision: 1, verified: true}); - expect(part?.verified).toEqual(verified); - const state = latest(7); - expect(state.status === 'ready' && state.aids.summary?.verified?.by).toEqual([{name: 'Ajarn Dev'}]); - }); - - it('shows the newer version when the quiz changed in the meantime', () => { - latest(7); - handlers['VerifyStudyQuiz'] = () => data({verifyStudyQuiz: {outcome: 'conflict', quiz: {...QUIZ, revision: 3}}}); - let failure: SaveFailure | undefined; - service.verify(7, 'quiz', 2, true).subscribe({error: error => failure = error}); - - expect(failure?.kind).toBe('conflict'); - const state = latest(7); - expect(state.status === 'ready' && state.aids.quiz?.revision).toBe(3); - }); - - it('says so when the part has gone', () => { - handlers['VerifyStudyQuiz'] = () => data({verifyStudyQuiz: {outcome: 'missing', quiz: null}}); - let failure: SaveFailure | undefined; - service.verify(7, 'quiz', 2, true).subscribe({error: error => failure = error}); - - expect(failure).toEqual({kind: 'failed', message: 'This lecture has no quiz yet.'}); - }); - }); - - describe('revisions', () => { - const revisions = [ - {revision: 2, author: {key: 'e1', name: 'Kan & Chain'}, created_at: '2026-10-10T03:28:25+00:00', markdown: '## Mine'}, - {revision: 1, author: null, created_at: '2026-10-10T03:28:25+00:00', markdown: '## AI'}, - ]; - - it('fetches the versions once for the version on show, oldest first', () => { - handlers['StudySummaryRevisions'] = () => data({video: {studyAids: {summary: {revisions}}}}); - let fetched: SummaryRevision[] | undefined; - service.summaryRevisions(7, 2).subscribe(list => fetched = list); - service.summaryRevisions(7, 2).subscribe(); - - expect(calls('StudySummaryRevisions').length).toBe(1); - expect(fetched?.map(revision => [revision.revision, revision.author?.name ?? null, revision.content])) - .toEqual([[1, null, '## AI'], [2, 'Kan & Chain', '## Mine']]); - - service.summaryRevisions(7, 3).subscribe(); - expect(calls('StudySummaryRevisions').length).toBe(2); - }); - - it('asks again after a failure', () => { - handlers['StudyQuizRevisions'] = () => httpError(500); - let failure: SaveFailure | undefined; - service.quizRevisions(7, 2).subscribe({error: error => failure = error}); - handlers['StudyQuizRevisions'] = () => data({video: {studyAids: {quiz: {revisions: [ - {revision: 1, author: null, created_at: null, questions: [QUESTION, {id: 'bad'}]}, - ]}}}}); - let questions: unknown; - service.quizRevisions(7, 2).subscribe(list => questions = list[0].content.map(question => question.id)); - - expect(failure?.kind).toBe('failed'); - expect(questions).toEqual([QUESTION.id]); - }); - }); - describe('transcript', () => { const latestTranscript = (videoId: number): TranscriptState => { let state: TranscriptState = {status: 'loading'}; @@ -356,88 +204,6 @@ describe('StudyAidsService', () => { }); }); - describe('profile', () => { - const profile = {name: 'Local Developer', changed_at: null, next_change_at: null}; - const latestProfile = (): EditorProfile | null => { - let value: EditorProfile | null = null; - service.profile().subscribe(found => value = found).unsubscribe(); - - return value; - }; - - it('reads the editor\'s display name once', () => { - handlers['StudyAidDisplayName'] = () => data({studyAidDisplayName: profile}); - - expect(latestProfile()).toEqual(profile); - latestProfile(); - expect(calls('StudyAidDisplayName').length).toBe(1); - }); - - it('stops asking once the reader turns out not to be an editor', () => { - handlers['StudyAidDisplayName'] = () => graphqlError('Only study-aid editors have a display name.', {category: 'authorization'}); - - expect(latestProfile()).toBeNull(); - latestProfile(); - expect(calls('StudyAidDisplayName').length).toBe(1); - }); - - it('changes the name and shows it on the lectures open', () => { - const watching = service.watch(7).subscribe(); - const renamed = {name: 'Ajarn Dev', changed_at: '2026-10-10T03:28:39+00:00', next_change_at: '2026-11-09T03:28:39+00:00'}; - handlers['ChangeStudyAidDisplayName'] = () => data({changeStudyAidDisplayName: renamed}); - service.saveProfile('Ajarn Dev').subscribe(); - - expect(variablesOf('ChangeStudyAidDisplayName')).toEqual({name: 'Ajarn Dev'}); - expect(latestProfile()).toEqual(renamed); - expect(calls('StudyAids').length).toBe(2); - watching.unsubscribe(); - }); - - it('passes on why a new name was refused', () => { - handlers['ChangeStudyAidDisplayName'] = () => graphqlError('You can change your display name again on 8 Nov 2026.', { - validation: {name: ['You can change your display name again on 8 Nov 2026.']}, - }); - let failure: SaveFailure | undefined; - service.saveProfile('Kan').subscribe({error: error => failure = error}); - - expect(failure?.kind === 'invalid' && failure.message).toBe('You can change your display name again on 8 Nov 2026.'); - }); - }); - - describe('editors', () => { - const editor = {email: 'kan@docchula.com', name: 'Kan', role: 'admin', added_at: '2026-10-10T03:28:24+00:00'}; - - it('lists the editors', () => { - handlers['StudyAidEditors'] = () => data({studyAidEditors: [editor, {email: 'new@docchula.com', name: null, role: 'editor', added_at: null}]}); - let editors: unknown; - service.editors().subscribe(list => editors = list); - - expect(editors).toEqual([editor, {email: 'new@docchula.com', name: null, role: 'editor', added_at: null}]); - }); - - it('adds, changes and removes an editor', () => { - handlers['GrantStudyAidRole'] = () => data({grantStudyAidRole: editor}); - handlers['RemoveStudyAidRole'] = () => data({removeStudyAidRole: true}); - service.saveEditor('kan@docchula.com', 'admin').subscribe(); - let removed = false; - service.removeEditor('kan@docchula.com').subscribe(() => removed = true); - - expect(variablesOf('GrantStudyAidRole')).toEqual({email: 'kan@docchula.com', role: 'admin'}); - expect(variablesOf('RemoveStudyAidRole')).toEqual({email: 'kan@docchula.com'}); - expect(removed).toBeTrue(); - }); - - it('passes on why FlickMan refused', () => { - handlers['GrantStudyAidRole'] = () => graphqlError('Validation failed for the field [grantStudyAidRole].', { - validation: {email: ['Only @docchula.com Google accounts can sign in to FlickPlayer.']}, - }); - let failure: SaveFailure | undefined; - service.saveEditor('kan@gmail.com', 'editor').subscribe({error: error => failure = error}); - - expect(failure?.kind === 'invalid' && failure.errors).toEqual({email: ['Only @docchula.com Google accounts can sign in to FlickPlayer.']}); - }); - }); - describe('ticks', () => { it('reads the ticks kept for this reader', () => { handlers['StudyAids'] = () => lecture({my_ticks: ['k3x9', 'a1-b2']}); diff --git a/src/app/study-aids.service.ts b/src/app/study-aids.service.ts index 7cccaee..702e991 100644 --- a/src/app/study-aids.service.ts +++ b/src/app/study-aids.service.ts @@ -1,64 +1,14 @@ import {inject, Injectable} from '@angular/core'; import {HttpErrorResponse} from '@angular/common/http'; -import {BehaviorSubject, defer, Observable, of, Subscription, throwError} from 'rxjs'; -import {catchError, map, shareReplay, tap} from 'rxjs/operators'; +import {BehaviorSubject, Observable, of, Subscription, throwError} from 'rxjs'; +import {catchError, map, tap} from 'rxjs/operators'; import {ManService} from './man.service'; -import { - ANSWER, - CHANGE_DISPLAY_NAME, - DISPLAY_NAME_QUERY, - EDITORS, - GRANT_ROLE, - Operation, - QUIZ_REVISIONS, - REMOVE_ROLE, - SAVE_QUIZ, - SAVE_SUMMARY, - SAVE_TICKS, - STUDY_AIDS, - SUMMARY_REVISIONS, - TRANSCRIPT, - VERIFY_QUIZ, - VERIFY_SUMMARY, -} from './study-aids/operations'; -import { - isObject, - parseEditor, - parseEditors, - parseErrors, - parseProfile, - parseQuiz, - parseQuizRevisions, - parseStats, - parseStudyAids, - parseSummary, - parseSummaryRevisions, - parseTranscript, - text, -} from './study-aids/parse'; -import { - AnswerStats, - EditorProfile, - EditorRole, - PartName, - QuizChange, - QuizPart, - QuizQuestion, - QuizRevision, - SaveFailure, - StudyAids, - StudyAidsEditor, - StudyAidsState, - SummaryChange, - SummaryPart, - SummaryRevision, - TranscriptState, -} from './study-aids/study-aids.model'; +import {ANSWER, Operation, SAVE_TICKS, STUDY_AIDS, TRANSCRIPT} from './study-aids/operations'; +import {isObject, parseStats, parseStudyAids, parseTranscript, text} from './study-aids/parse'; +import {AnswerStats, QuizQuestion, StudyAids, StudyAidsState, TranscriptState} from './study-aids/study-aids.model'; /** A lecture's study aids are fetched again when a card shows them after this long. */ const FRESH_FOR_MS = 5 * 60 * 1000; -/** Earlier versions are large and only fetched when a reader asks for them, so few are kept. */ -const KEPT_HISTORIES = 4; /** A transcript can be long, and FlickMan only lets a reader fetch a few an hour. */ const KEPT_TRANSCRIPTS = 6; @@ -75,10 +25,10 @@ interface Reply { errors?: unknown[]; } -/** What can go wrong: what the editors are told, or a FlickMan that has no study aids at all. */ -type Failure = SaveFailure | {kind: 'unsupported'}; +/** What can go wrong, including a FlickMan that has no study aids at all. */ +type Failure = {kind: 'forbidden' | 'rate-limited' | 'network' | 'unsupported' | 'failed'}; -const UNEXPECTED: SaveFailure = {kind: 'failed', message: 'FlickMan sent back something unexpected.'}; +const UNEXPECTED: Failure = {kind: 'failed'}; function isFailure(value: unknown): value is Failure { return isObject(value) && typeof value['kind'] === 'string'; @@ -89,19 +39,10 @@ function at(data: unknown, ...keys: string[]): unknown { return keys.reduce((value, key) => isObject(value) ? value[key] : null, data); } -/** FlickMan's GraphQL errors in the terms the editors need. Validation messages keep the argument paths as fields. */ function graphqlFailure(errors: unknown[]): Failure { - const entries = errors.filter(isObject); - const validation: Record = {}; - for (const entry of entries) { - Object.assign(validation, parseErrors(at(entry, 'extensions', 'validation'))); - } - const messages = Object.values(validation).flat(); - if (messages.length) { - return {kind: 'invalid', message: messages[0], errors: validation}; - } - const message = text(entries[0]?.['message']) ?? ''; - if (at(entries[0], 'extensions', 'category') === 'authorization' || message === 'This action is unauthorized.') { + const first = errors.find(isObject); + const message = text(first?.['message']) ?? ''; + if (at(first, 'extensions', 'category') === 'authorization' || message === 'This action is unauthorized.') { return {kind: 'forbidden'}; } if (/^Rate limit for .+ exceeded/.test(message)) { @@ -112,7 +53,7 @@ function graphqlFailure(errors: unknown[]): Failure { return {kind: 'unsupported'}; } - return {kind: 'failed', message: message || 'FlickMan could not do that.'}; + return UNEXPECTED; } function failureOf(error: unknown): Failure { @@ -133,18 +74,13 @@ function failureOf(error: unknown): Failure { case 429: return {kind: 'rate-limited'}; default: - return {kind: 'failed', message: `FlickMan answered with error ${error.status}.`}; + return UNEXPECTED; } } -function forEditors(failure: Failure): SaveFailure { - return failure.kind === 'unsupported' ? {kind: 'failed', message: 'This server has no study aids yet.'} : failure; -} - /** * The AI summary, quiz and transcript FlickMan keeps for each lecture, through its GraphQL API. - * The tabs share one request per lecture, and an edit or an answer updates what they show without - * fetching again. + * The tabs share one request per lecture, and an answer updates what they show without fetching again. */ @Injectable({ providedIn: 'root', @@ -153,12 +89,7 @@ export class StudyAidsService { private man = inject(ManService); private readonly entries = new Map(); - private readonly histories = new Map>(); private readonly transcripts = new Map>(); - private readonly profileState = new BehaviorSubject(null); - private profileRequest: Subscription | null = null; - /** Whether FlickMan has answered for good: with the editor's name, or that this reader is not an editor. */ - private profileKnown = false; /** Turned off for the visit once FlickMan turns out to have no study aids at all. */ private supported = true; @@ -176,42 +107,6 @@ export class StudyAidsService { this.fetch(videoId, this.entry(videoId)); } - saveSummary(videoId: number, change: SummaryChange): Observable { - return this.savePart(videoId, 'summary', SAVE_SUMMARY, 'saveStudySummary', { - video_id: String(videoId), - markdown: change.markdown, - revision: change.revision, - credit: change.credit, - }, parseSummary, (aids, summary) => ({...aids, summary})); - } - - saveQuiz(videoId: number, change: QuizChange): Observable { - return this.savePart(videoId, 'quiz', SAVE_QUIZ, 'saveStudyQuiz', { - video_id: String(videoId), - questions: change.questions, - revision: change.revision, - credit: change.credit, - }, parseQuiz, (aids, quiz) => ({...aids, quiz})); - } - - /** Marks the version an editor has checked against the lecture, or takes their mark back. */ - verify(videoId: number, part: PartName, revision: number, verified: boolean): Observable { - const variables = {video_id: String(videoId), revision, verified}; - - return part === 'summary' - ? this.savePart(videoId, 'summary', VERIFY_SUMMARY, 'verifyStudySummary', variables, parseSummary, (aids, summary) => ({...aids, summary})) - : this.savePart(videoId, 'quiz', VERIFY_QUIZ, 'verifyStudyQuiz', variables, parseQuiz, (aids, quiz) => ({...aids, quiz})); - } - - /** Every saved version of the summary, oldest first. `revision` is the one on show, so a newer one is fetched afresh. */ - summaryRevisions(videoId: number, revision: number): Observable { - return this.history(videoId, 'summary', revision, SUMMARY_REVISIONS, parseSummaryRevisions); - } - - quizRevisions(videoId: number, revision: number): Observable { - return this.history(videoId, 'quiz', revision, QUIZ_REVISIONS, parseQuizRevisions); - } - /** The AI's word-for-word transcript, fetched once a visit; a failure is only tried again on request. */ transcript(videoId: number): Observable { let subject = this.transcripts.get(videoId); @@ -235,49 +130,13 @@ export class StudyAidsService { } } - /** This editor's display name; null for readers who are not editors, and until it arrives. */ - profile(): Observable { - if (!this.profileKnown && (!this.profileRequest || this.profileRequest.closed)) { - this.profileRequest = this.run(DISPLAY_NAME_QUERY).subscribe({ - next: data => { - this.profileKnown = true; - this.profileState.next(parseProfile(data['studyAidDisplayName'])); - }, - error: (failure: Failure) => { - // Anything but "not an editor" is asked about again next time. - this.profileKnown = failure.kind === 'forbidden' || failure.kind === 'unsupported'; - }, - }); - } - - return this.profileState.asObservable(); - } - - /** Changes the name students see, which FlickMan allows once every 30 days. */ - saveProfile(name: string): Observable { - return this.editorCall(this.run(CHANGE_DISPLAY_NAME, {name}), data => parseProfile(data['changeStudyAidDisplayName'])).pipe( - tap(profile => { - this.profileKnown = true; - this.profileState.next(profile); - // Bylines and verifications show the new name. - for (const [videoId, entry] of this.entries) { - entry.fetchedAt = 0; - if (entry.state.observed) { - this.fetch(videoId, entry); - } - } - this.histories.clear(); - }), - ); - } - /** Adds an answer to the class stats. Only a reader's first answer to a question counts. */ answer(videoId: number, questionId: string, choice: number): Observable { return this.run(ANSWER, {video_id: String(videoId), question_id: questionId, choice}).pipe( map(data => { const stats = parseStats(data['answerStudyQuestion']); if (!stats) { - // The question was removed or replaced since the quiz was loaded. + // The AI wrote a new quiz since this one was loaded. this.refresh(videoId); } @@ -294,34 +153,21 @@ export class StudyAidsService { /** Keeps the summary points this reader has ticked on FlickMan, so their other devices show them too. */ saveTicks(videoId: number, ticks: string[]): Observable { - return this.editorCall(this.run(SAVE_TICKS, {video_id: String(videoId), ticks}), data => { - const saved = data['saveStudyTicks']; - - return Array.isArray(saved) ? saved.filter((key): key is string => typeof key === 'string') : null; - }); - } - - /** Everyone allowed to edit study aids. Admins only. */ - editors(): Observable { - return this.editorCall(this.run(EDITORS), data => parseEditors({editors: data['studyAidEditors']})); - } - - /** Adds an editor, or changes their role. Admins only. */ - saveEditor(email: string, role: EditorRole): Observable { - return this.editorCall(this.run(GRANT_ROLE, {email, role}), data => parseEditor(data['grantStudyAidRole'])); - } - - removeEditor(email: string): Observable { - return this.editorCall(this.run(REMOVE_ROLE, {email}), () => true); - } + return this.run(SAVE_TICKS, {video_id: String(videoId), ticks}).pipe( + map(data => { + const saved = data['saveStudyTicks']; + if (!Array.isArray(saved)) { + throw UNEXPECTED; + } - private run(operation: Operation, variables: object = {}): Observable { - return this.send(this.man.post('graphql', {operationName: operation.name, query: operation.query, variables})); + return saved.filter((key): key is string => typeof key === 'string'); + }), + ); } /** The answer's data, or a Failure when FlickMan reports errors or cannot be reached. */ - private send(request: Observable): Observable { - return request.pipe( + private run(operation: Operation, variables: object): Observable { + return this.man.post('graphql', {operationName: operation.name, query: operation.query, variables}).pipe( map(reply => { if (!reply) { throw {kind: 'network'} satisfies Failure; @@ -339,80 +185,6 @@ export class StudyAidsService { ); } - private editorCall(request: Observable, parse: (data: Fields) => T | null): Observable { - return request.pipe( - map(data => { - const result = parse(data); - if (result === null) { - throw UNEXPECTED; - } - - return result; - }), - catchError((error: unknown) => throwError(() => forEditors(failureOf(error)))), - ); - } - - /** A save or a verification. FlickMan answers with what became of it and the part as it now stands. */ - private savePart

    ( - videoId: number, - name: PartName, - operation: Operation, - field: string, - variables: object, - parse: (value: unknown) => P | null, - apply: (aids: StudyAids, saved: P) => StudyAids, - ): Observable

    { - return this.run(operation, variables).pipe( - map(data => { - const outcome = at(data, field, 'outcome'); - const part = parse(at(data, field, name)); - if ((outcome === 'saved' || outcome === 'unchanged') && part) { - return part; - } - if (outcome === 'conflict') { - throw {kind: 'conflict', current: part} satisfies SaveFailure; - } - if (outcome === 'missing') { - this.refresh(videoId); - throw {kind: 'failed', message: `This lecture has no ${name} yet.`} satisfies SaveFailure; - } - throw UNEXPECTED; - }), - tap(saved => this.update(videoId, aids => apply(aids, saved))), - catchError((error: unknown) => { - const failure = forEditors(failureOf(error)); - if (failure.kind === 'conflict' && failure.current) { - // Show everyone's latest version behind the editor straight away. - this.update(videoId, aids => apply(aids, failure.current as P)); - } else if (failure.kind === 'forbidden') { - this.refresh(videoId); - } - - return throwError(() => failure); - }), - ); - } - - private history(videoId: number, name: PartName, revision: number, operation: Operation, parse: (value: unknown) => T[] | null): Observable { - const key = `${videoId}/${name}/${revision}`; - let cached = this.histories.get(key) as Observable | undefined; - if (!cached) { - // A failed request is not kept, so asking again sends a new one. - cached = this.editorCall(defer(() => this.run(operation, {id: String(videoId)})), data => { - const revisions = at(data, 'video', 'studyAids', name, 'revisions'); - - return parse({revisions: Array.isArray(revisions) ? revisions : []}); - }).pipe(shareReplay(1)); - this.histories.set(key, cached); - for (const old of [...this.histories.keys()].slice(0, -KEPT_HISTORIES)) { - this.histories.delete(old); - } - } - - return cached; - } - private fetchTranscript(videoId: number, subject: BehaviorSubject): void { this.run(TRANSCRIPT, {id: String(videoId)}).subscribe({ next: data => { diff --git a/src/app/study-aids/byline.ts b/src/app/study-aids/byline.ts deleted file mode 100644 index e5109b8..0000000 --- a/src/app/study-aids/byline.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {PartMeta} from './study-aids.model'; - -/** `8 Nov 2026`, as FlickMan writes dates in its messages. */ -export function formatDay(iso: string): string { - return new Date(iso).toLocaleDateString('en-GB', {day: 'numeric', month: 'short', year: 'numeric'}); -} - -/** "Kan", "Kan and Chain", "Kan, Chain and Pim". */ -export function joinNames(names: string[]): string { - return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; -} - -export function isAiDraft(part: PartMeta): boolean { - return part.origin === 'ai' && !part.edited; -} - -/** Who made a summary or quiz, as shown under the card's title. */ -export function byline(part: PartMeta): string { - const names = joinNames(part.contributors.map(contributor => contributor.name)); - if (part.origin === 'editor') { - return names ? 'By ' + names : 'Written by an editor'; - } - if (!part.edited) { - return 'AI draft from the lecture recording. Check it against the lecture.'; - } - - return names ? 'AI draft, edited by ' + names : 'AI draft, edited by an editor'; -} - -/** Whether editors checked the version on show, an older one, or none. */ -export function verifiedState(part: PartMeta): 'current' | 'earlier' | null { - if (!part.verified) { - return null; - } - - return part.verified.revision >= part.revision ? 'current' : 'earlier'; -} - -/** "Verified by Kan and Chain", as students read it. */ -export function verifiedLine(part: PartMeta): string | null { - const names = joinNames(part.verified?.by.map(verifier => verifier.name) ?? []); - switch (verifiedState(part)) { - case 'current': - return 'Verified by ' + names; - case 'earlier': - return `Verified by ${names} before the latest changes`; - default: - return part.origin === 'ai' ? 'Not verified by an editor yet' : null; - } -} diff --git a/src/app/study-aids/captions.spec.ts b/src/app/study-aids/captions.spec.ts deleted file mode 100644 index ba2c4d5..0000000 --- a/src/app/study-aids/captions.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {buildCues, splitCaptions, toWebVtt} from './captions'; - -describe('captions', () => { - it('cuts a paragraph between words into captions of about two lines', () => { - const text = 'Nucleus เก็บ genetic material ของเซลล์ Barr body คือ X chromosome ที่ถูก inactivate จนเป็น facultative heterochromatin ' - + 'จำนวน Barr body เท่ากับจำนวน X chromosome ลบหนึ่ง'; - const captions = splitCaptions(text); - - expect(captions.length).toBeGreaterThan(1); - expect(captions.join(' ')).toBe(text); - // Thai vowel and tone marks take no room, so a full caption can hold a few more characters. - expect(captions.every(caption => caption.length <= 94)).toBeTrue(); - }); - - it('breaks a long run of Thai with no spaces between its words', () => { - const run = 'ไมโทคอนเดรียเป็นแหล่งสร้างพลังงานของเซลล์โดยสร้างเอทีพีผ่านกระบวนการหายใจระดับเซลล์ซึ่งต้องใช้ออกซิเจนเป็นตัวรับอิเล็กตรอนตัวสุดท้าย'; - - expect(splitCaptions(run).length).toBeGreaterThan(1); - expect(splitCaptions(run).join('')).toBe(run); - }); - - it('spreads a paragraph\'s captions over its time, by how much each says', () => { - const cues = buildCues([ - {time: 10, text: 'The smooth ER stores calcium and makes steroid hormones. '.repeat(6).trim()}, - {time: 40, text: 'Next topic.'}, - ], 60); - const first = cues.filter(cue => cue.start < 40); - - expect(first[0].start).toBe(10); - expect(first[first.length - 1].end).toBeCloseTo(40, 5); - first.slice(1).forEach((cue, i) => expect(cue.start).toBeCloseTo(first[i].end, 5)); - }); - - it('leaves a long pause after a short paragraph without a caption', () => { - const cues = buildCues([{time: 0, text: 'เริ่มกันเลย'}, {time: 50, text: 'Lysosome'}], null); - - expect(cues[0].end).toBeLessThan(10); - expect(cues[1].start).toBe(50); - }); - - it('runs on a paragraph whose stamp goes back in time', () => { - const cues = buildCues([{time: 100, text: 'one'}, {time: 95, text: 'two'}, {time: 130, text: 'three'}], null); - - expect(cues.map(cue => cue.text)).toEqual(['one two', 'three']); - expect(cues[0].start).toBe(100); - }); - - it('writes WebVTT a player can read', () => { - expect(toWebVtt([{start: 3661.5, end: 3663, text: 'pH < 5 & Ca²⁺'}])) - .toBe('WEBVTT\n\n01:01:01.500 --> 01:01:03.000\npH < 5 & Ca²⁺\n'); - }); -}); diff --git a/src/app/study-aids/captions.ts b/src/app/study-aids/captions.ts deleted file mode 100644 index 148971d..0000000 --- a/src/app/study-aids/captions.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type Player from 'video.js/dist/types/player'; -import type Button from 'video.js/dist/types/button'; -import type Component from 'video.js/dist/types/component'; -import {Subscription} from 'rxjs'; -import {filter, take} from 'rxjs/operators'; -import {StudyAidsService} from '../study-aids.service'; -import {words} from './diff'; -import {readCaptions, writeCaptions} from './local-store'; -import {TranscriptParagraph} from './study-aids.model'; - -/* - * Captions from the AI transcript. FlickMan only knows when each paragraph starts (they are at most - * a minute apart), so a paragraph is cut into two-line captions shown one after another over its - * time, each for as long as its share of the words. They follow the lecture closely but not exactly. - */ - -export interface Cue { - start: number; - end: number; - text: string; -} - -/** About two lines across the player on a phone held sideways. */ -const CAPTION_LENGTH = 84; -/** Roughly how fast lecturers speak, to tell when a paragraph is followed by a pause. */ -const LETTERS_PER_SECOND = 14; -const MIN_PARAGRAPH_SECONDS = 2; - -/** Thai vowel and tone marks sit above or below a letter and take no room of their own. */ -const THAI_MARKS = /[ัิ-ฺ็-๎]/g; - -function width(text: string): number { - return text.replace(THAI_MARKS, '').length; -} - -function weight(text: string): number { - return width(text.replace(/\s+/g, '')); -} - -/** A run of Thai with no spaces, longer than a caption, broken between words. */ -function splitRun(run: string): string[] { - const parts: string[] = []; - let current = ''; - for (const word of words(run)) { - if (current && width(current + word) > CAPTION_LENGTH) { - parts.push(current); - current = ''; - } - current += word; - } - - return current ? [...parts, current] : parts; -} - -/** A paragraph cut into captions between words, ending one at a full stop when it is already half full. */ -export function splitCaptions(text: string): string[] { - const pieces = text.replace(/\s+/g, ' ').trim().split(' ') - .filter(Boolean) - .flatMap(piece => width(piece) > CAPTION_LENGTH ? splitRun(piece) : [piece]); - const captions: string[] = []; - let current = ''; - for (const piece of pieces) { - const joined = current ? current + ' ' + piece : piece; - if (current && width(joined) > CAPTION_LENGTH) { - captions.push(current); - current = piece; - } else { - current = joined; - } - if (/[.!?]$/.test(current) && width(current) >= CAPTION_LENGTH / 2) { - captions.push(current); - current = ''; - } - } - - return current ? [...captions, current] : captions; -} - -export function buildCues(paragraphs: readonly TranscriptParagraph[], duration: number | null): Cue[] { - // Where two recorded pieces overlap, a stamp can go back in time: such a paragraph runs on from the one before. - const spans: TranscriptParagraph[] = []; - for (const paragraph of paragraphs) { - const last = spans[spans.length - 1]; - if (last && paragraph.time <= last.time) { - last.text += ' ' + paragraph.text; - } else { - spans.push({...paragraph}); - } - } - - return spans.flatMap((span, i) => { - const captions = splitCaptions(span.text); - const total = captions.reduce((sum, caption) => sum + weight(caption), 0) || 1; - const next = spans[i + 1]?.time ?? (duration && duration > span.time ? duration : null); - // Speech fills its paragraph unless a long pause follows, which is left without a caption. - const spoken = Math.max(MIN_PARAGRAPH_SECONDS, total / LETTERS_PER_SECOND * 2); - const length = next === null ? spoken : Math.min(next - span.time, spoken); - let start = span.time; - - return captions.map(caption => { - const end = start + weight(caption) / total * length; - const cue = {start, end, text: caption}; - start = end; - - return cue; - }); - }); -} - -function vttTime(seconds: number): string { - const total = Math.max(0, Math.round(seconds * 1000)); - const parts = [Math.floor(total / 3_600_000), Math.floor(total % 3_600_000 / 60_000), Math.floor(total % 60_000 / 1000)]; - - return parts.map(part => String(part).padStart(2, '0')).join(':') + '.' + String(total % 1000).padStart(3, '0'); -} - -export function toWebVtt(cues: readonly Cue[]): string { - const escape = (text: string) => text.replace(/&/g, '&').replace(//g, '>'); - - return 'WEBVTT\n\n' + cues.map(cue => `${vttTime(cue.start)} --> ${vttTime(cue.end)}\n${escape(cue.text)}`).join('\n\n') + '\n'; -} - -/** The captions button in the player's control bar, and the captions it turns on and off. */ -export class LectureCaptions { - private readonly button: Button; - private readonly icon: HTMLElement | null; - private readonly underline: HTMLElement; - private on = readCaptions(); - private videoId: number | null = null; - private duration: number | null = null; - /** Why captions that are on are not showing. */ - private problem: 'none' | 'failed' | null = null; - private request: Subscription | null = null; - private track: object | null = null; - private url: string | null = null; - - constructor(private readonly player: Player, private readonly service: StudyAidsService) { - const controlBar = player.getChild('ControlBar') as Component; - // video.js would add its own captions menu as soon as there are captions: this button takes its place. - for (const name of ['SubsCapsButton', 'CaptionsButton', 'SubtitlesButton']) { - const builtIn = controlBar.getChild(name); - if (builtIn) { - controlBar.removeChild(builtIn); - builtIn.dispose(); - } - } - const fullscreen = controlBar.getChild('FullscreenToggle'); - this.button = controlBar.addChild('button', { - className: 'vjs-captions-toggle', - clickHandler: () => this.toggle(), - }, fullscreen ? controlBar.children().indexOf(fullscreen) : undefined) as Button; - // video.js's own captions glyph, so the button looks like the player's other controls. - this.icon = this.button.el().querySelector('.vjs-icon-placeholder'); - this.icon?.classList.add('vjs-icon-captions'); - // The bar under the glyph while captions are on, as YouTube's captions button has. - this.underline = this.button.el().ownerDocument.createElement('span'); - this.underline.setAttribute('aria-hidden', 'true'); - Object.assign(this.underline.style, { - position: 'absolute', - left: '32%', - right: '32%', - bottom: '22%', - height: '0.2em', - borderRadius: '0.1em', - background: 'var(--ion-color-primary, currentColor)', - }); - this.button.el().append(this.underline); - this.button.hide(); - this.render(); - } - - /** Offers captions for the lecture now playing, when it has a transcript, and shows them if they are on. */ - show(videoId: number | null, hasTranscript: boolean, duration: number | null): void { - this.clear(); - this.videoId = videoId && hasTranscript ? videoId : null; - this.duration = duration; - this.problem = null; - if (this.videoId) { - this.button.show(); - if (this.on) { - this.load(); - } - } else { - this.button.hide(); - } - this.render(); - } - - dispose(): void { - this.clear(); - } - - private toggle(): void { - // Captions that failed to load are tried again rather than turned off. - this.on = this.problem === 'failed' || !this.on; - writeCaptions(this.on); - this.problem = null; - if (this.on) { - this.load(); - } else { - this.clear(); - } - this.render(); - } - - private load(): void { - const videoId = this.videoId; - if (!videoId || this.track || this.request) { - return; - } - this.service.retryTranscript(videoId); - this.request = this.service.transcript(videoId).pipe( - filter(state => state.status !== 'loading'), - take(1), - ).subscribe(state => { - this.request = null; - if (videoId !== this.videoId || !this.on) { - return; - } - if (state.status === 'ready') { - this.attach(buildCues(state.paragraphs, this.duration ?? (this.player.duration() || null))); - } else { - this.problem = state.status === 'none' ? 'none' : 'failed'; - } - this.render(); - }); - } - - private attach(cues: Cue[]): void { - this.url = URL.createObjectURL(new Blob([toWebVtt(cues)], {type: 'text/vtt'})); - // Removed by hand, so a lecture's captions never outlive it whatever the player does with sources. - const element = this.player.addRemoteTextTrack({kind: 'captions', label: 'AI captions', srclang: 'th', src: this.url}, true); - (element as unknown as {track: TextTrack}).track.mode = 'showing'; - this.track = element; - } - - private clear(): void { - this.request?.unsubscribe(); - this.request = null; - if (this.track) { - this.player.removeRemoteTextTrack(this.track); - this.track = null; - } - if (this.url) { - URL.revokeObjectURL(this.url); - this.url = null; - } - } - - private render(): void { - const showing = this.on && !this.problem; - this.button.el().setAttribute('aria-pressed', String(showing)); - // Dimmed while off, underlined while on. - if (this.icon) { - this.icon.style.opacity = showing ? '' : '0.5'; - } - this.underline.style.display = showing ? '' : 'none'; - this.button.controlText( - this.problem === 'none' ? 'This lecture has no captions' - : this.problem === 'failed' ? 'Captions could not be loaded. Try again' - : showing ? 'Turn captions off' : 'Turn captions on', - ); - } -} diff --git a/src/app/study-aids/changes.spec.ts b/src/app/study-aids/changes.spec.ts deleted file mode 100644 index 3ad4526..0000000 --- a/src/app/study-aids/changes.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {aiOriginal, authorLabel, ChangeRun, describeChange, quizChanges, summaryChanges} from './changes'; -import {QuizRevision, RevisionQuestion, SummaryRevision} from './study-aids.model'; - -const KAN = {key: 'e1', name: 'Kan'}; -const CHAIN = {key: 'e2', name: 'Chain'}; -const HIDDEN = {key: 'e3', name: null}; - -function summary(revision: number, author: SummaryRevision['author'], markdown: string): SummaryRevision { - return {revision, author, created_at: '2026-10-0' + revision + 'T10:00:00+07:00', content: markdown}; -} - -function marked(runs: ChangeRun[]): string { - return runs.map(run => run.change === 'added' ? `[+${run.text}]` : run.change === 'removed' ? `[-${run.text}]` : run.text).join(''); -} - -const AI = '# Organelles\n\n## Nucleus [00:01:10]\n\n1. **Barr body** : X − 1\n2. **Nucleolus** : makes rRNA\n\n## Lysosome [00:21:40]\n\n1. **Lysosome** : pH 5\n'; - -describe('summaryChanges', () => { - it('counts from the AI draft and credits each editor with their own changes', () => { - const changes = summaryChanges([ - summary(1, null, AI), - summary(2, KAN, AI.replace('pH 5', 'pH 4.5')), - summary(3, CHAIN, AI.replace('pH 5', 'pH 4.5').replace('2. **Nucleolus** : makes rRNA\n', '') - + '2. **Gaucher disease** : glucocerebrosidase\n'), - ])!; - - expect(changes.base.author).toBeNull(); - expect(changes.authors.map(author => [author.name, author.added, author.removed])).toEqual([['Kan', 1, 1], ['Chain', 3, 3]]); - const lysosome = changes.blocks.find(block => block.kind === 'point' && block.change === 'edited')!; - expect(marked(lysosome.runs)).toBe('Lysosome : pH [-5][+4.5]'); - expect(changes.versions[lysosome.runs[1].version!].author).toBe(0); - - const removed = changes.blocks.find(block => block.change === 'removed')!; - expect(removed.html).toContain('Nucleolus'); - expect(removed.number).toBeNull(); - expect(changes.versions[removed.version!].author).toBe(1); - - const added = changes.blocks.find(block => block.change === 'added')!; - expect(added.html).toContain('Gaucher disease'); - expect(added.number).toBe(3); - }); - - it('shows a corrected spelling letter by letter', () => { - const changes = summaryChanges([ - summary(1, null, '1. N-linked glycosilation ใน RER'), - summary(2, KAN, '1. N-linked glycosylation ใน RER'), - ])!; - - expect(marked(changes.blocks[0].runs)).toBe('N-linked glycos[-i][+y]lation ใน RER'); - }); - - it('marks a changed section time in its heading', () => { - const changes = summaryChanges([ - summary(1, null, '## Nucleus [00:01:10]\n\n1. Point'), - summary(2, KAN, '## Nucleus [00:01:30]\n\n1. Point'), - ])!; - - const runs = changes.blocks[0].runs; - expect(runs.filter(run => run.change === 'removed').map(run => run.text).join('')).toMatch(/10$/); - expect(runs.filter(run => run.change === 'added').map(run => run.text).join('')).toMatch(/30$/); - }); - - it('keeps the lines and spacing of an edited diagram', () => { - const drawn = '1. **Face** : veins\n\n```\n Facial v.\n │\n ▼\n[ Internal jugular v. ]\n```\n'; - const changes = summaryChanges([summary(1, null, drawn), summary(2, KAN, drawn.replace('Facial v.', 'Facial vein'))])!; - const diagram = changes.blocks.find(block => block.kind === 'code')!; - const shown = (change: ChangeRun['change']) => diagram.runs.filter(run => run.change !== change).map(run => run.text).join(''); - - expect(diagram.change).toBe('edited'); - expect(shown('removed')).toBe(' Facial vein\n │\n ▼\n[ Internal jugular v. ]'); - expect(shown('added')).toBe(' Facial v.\n │\n ▼\n[ Internal jugular v. ]'); - }); - - it('counts a summary an editor wrote from its first version', () => { - const changes = summaryChanges([summary(1, KAN, '1. One'), summary(2, CHAIN, '1. One\n2. Two')])!; - - expect(changes.base.author).toEqual(KAN); - expect(changes.blocks.map(block => block.change)).toEqual([null, 'added']); - }); - - it('says nothing changed when nothing did', () => { - expect(summaryChanges([summary(1, null, AI)])!.changed).toBeFalse(); - expect(summaryChanges([])).toBeNull(); - }); - - it('finds the AI\'s own version, and none for an editor\'s summary', () => { - expect(aiOriginal([summary(1, null, 'first'), summary(2, null, 'second'), summary(3, KAN, 'third')])?.content).toBe('second'); - expect(aiOriginal([summary(1, KAN, 'mine')])).toBeNull(); - }); - - it('names editors whose names are hidden by which one they are', () => { - const changes = summaryChanges([ - summary(1, null, '1. One'), - summary(2, HIDDEN, '1. One two'), - summary(3, {key: 'e4', name: null}, '1. One two three'), - ])!; - - expect(authorLabel(changes, 0)).toBe('Unnamed editor 1'); - expect(authorLabel(changes, 1)).toBe('Unnamed editor 2'); - expect(describeChange(changes, 'added', 1)).toBe('Added by unnamed editor 1 on 2 Oct 2026'); - }); -}); - -describe('quizChanges', () => { - const question = (id: string, changes: Partial = {}): RevisionQuestion => ({ - id, - prompt: 'Which cell has the most smooth ER?', - choices: ['Plasma cell', 'Leydig cell', 'Neutrophil'], - answer: 1, - explanation: 'SER makes steroid hormones.', - time: 465, - ...changes, - }); - const quiz = (revision: number, author: QuizRevision['author'], questions: RevisionQuestion[]): QuizRevision => - ({revision, author, created_at: null, content: questions}); - - it('marks what changed inside a question, and questions added or removed', () => { - const changes = quizChanges([ - quiz(1, null, [question('a'), question('b', {prompt: 'Where is rRNA made?'})]), - quiz(2, KAN, [question('a', {answer: 2, choices: ['Plasma cell', 'Leydig cell', 'Neutrophil', 'Hepatocyte']}), question('c')]), - ])!; - - const [edited, added, removed] = changes.questions; - expect([edited.id, edited.change, edited.number]).toEqual(['a', 'edited', 1]); - const answer = edited.fields.find(field => field.kind === 'answer')!.runs; - expect(answer.filter(run => run.change === 'removed').map(run => run.text)).toEqual(['B']); - expect(answer.filter(run => run.change === 'added').map(run => run.text)).toEqual(['C']); - const newChoice = edited.fields.filter(field => field.kind === 'choice')[3]; - expect([newChoice.change, newChoice.letter]).toEqual(['added', 'D']); - - expect([added.id, added.change, added.number]).toEqual(['c', 'added', 2]); - expect([removed.id, removed.change, removed.number]).toEqual(['b', 'removed', null]); - expect(changes.authors[0]).toEqual(jasmine.objectContaining({name: 'Kan'})); - }); - - it('leaves out a question added and taken out again', () => { - const changes = quizChanges([ - quiz(1, null, [question('a')]), - quiz(2, KAN, [question('a'), question('b')]), - quiz(3, CHAIN, [question('a')]), - ])!; - - expect(changes.questions.map(item => [item.id, item.change])).toEqual([['a', null]]); - expect(changes.changed).toBeFalse(); - }); -}); diff --git a/src/app/study-aids/changes.ts b/src/app/study-aids/changes.ts deleted file mode 100644 index 25be826..0000000 --- a/src/app/study-aids/changes.ts +++ /dev/null @@ -1,426 +0,0 @@ -import {formatDay} from './byline'; -import {buildChecklist, formatClock, renderBlock, renderInline} from './checklist'; -import {BLOCK_START, isWord, letters, netChanges, track, Tracked, words} from './diff'; -import {DRIVE_IMAGE_CLASS} from './images'; -import {CHOICE_LETTERS} from './quiz'; -import {QuizRevision, Revision, RevisionAuthor, RevisionQuestion, SummaryRevision} from './study-aids.model'; - -/* - * What the Changes view shows: how the summary or the quiz differs from the AI's draft (or, for a - * part an editor wrote, from its first version), with every word added or removed credited to the - * editor whose save did it. - */ - -export interface ChangeAuthor { - key: string; - /** Null when this reader may not see the editor's name. */ - name: string | null; - /** Words this editor's saves added and removed, as they stand now. */ - added: number; - removed: number; -} - -export interface ChangeVersion { - revision: number; - /** Index into the authors; null for the version the changes are counted from. */ - author: number | null; - created_at: string | null; -} - -/** A stretch of text one save added or removed, or that nobody changed. */ -export interface ChangeRun { - text: string; - change: 'added' | 'removed' | null; - /** Index into the versions. */ - version: number | null; -} - -/** A block or a question added or removed whole, partly changed, or left alone. */ -export type Change = 'added' | 'removed' | 'edited' | null; - -export type SummaryBlockKind = 'title' | 'heading' | 'label' | 'point' | 'code' | 'html'; - -export interface ChangedBlock { - kind: SummaryBlockKind; - /** The block as last saved, drawn as usual unless only some of its words changed. */ - html: string; - time: number | null; - change: Change; - /** The version that added or removed the whole block. */ - version: number | null; - /** Its words, when only some of them changed. */ - runs: ChangeRun[]; - /** The point's number in the summary as it is now. */ - number: number | null; -} - -export type QuestionFieldKind = 'prompt' | 'choice' | 'answer' | 'time' | 'explanation'; - -export interface ChangedField { - kind: QuestionFieldKind; - change: Change; - version: number | null; - runs: ChangeRun[]; - /** A choice's letter in the question as it is now; null for a removed choice. */ - letter: string | null; -} - -export interface ChangedQuestion { - id: string; - change: Change; - version: number | null; - /** Its number in the quiz as it is now; null for a removed question. */ - number: number | null; - /** The question as last saved. */ - question: RevisionQuestion; - fields: ChangedField[]; -} - -export interface History { - /** The version the changes are counted from. */ - base: {revision: number; author: RevisionAuthor | null; created_at: string | null}; - authors: ChangeAuthor[]; - /** Index 0 is the base. */ - versions: ChangeVersion[]; - /** Whether anything differs from the base. */ - changed: boolean; -} - -/** Earlier versions as fetched for the AI original and Changes views. */ -export type HistoryState = - | {status: 'loading'} - | {status: 'ready'; revisions: Revision[]} - | {status: 'error'; message: string}; - -export interface SummaryChanges extends History { - blocks: ChangedBlock[]; -} - -export interface QuizChanges extends History { - questions: ChangedQuestion[]; -} - -interface Token { - text: string; - /** On the token that starts a summary block. */ - block?: {kind: SummaryBlockKind; html: string; time: number | null}; -} - -/** The AI's last draft, which editors started from; the first version when an editor wrote the part. */ -function baseIndex(revisions: readonly Revision[]): number { - let base = 0; - revisions.forEach((revision, i) => { - if (revision.author === null) { - base = i; - } - }); - - return base; -} - -/** The AI's own version before any editor changed it, or null when an editor wrote the part. */ -export function aiOriginal(revisions: readonly Revision[]): Revision | null { - return [...revisions].reverse().find(revision => revision.author === null) ?? null; -} - -let inertDocument: Document | null = null; - -/** - * The words of rendered HTML, with pictures named so that adding or removing one shows. A code - * block keeps its line breaks and spacing, which draw its diagram. - */ -function textOf(html: string, preformatted = false): string { - inertDocument ??= document.implementation.createHTMLDocument(''); - const holder = inertDocument.createElement('div'); - holder.innerHTML = html; - holder.querySelectorAll(`a.${DRIVE_IMAGE_CLASS}`).forEach(picture => picture.replaceWith(`[${picture.textContent || 'image'}]`)); - const text = holder.textContent ?? ''; - - return preformatted ? text.replace(/\n+$/, '') : text.replace(/\s+/g, ' ').trim(); -} - -function escapeHtml(text: string): string { - return text.replace(/[&<>"]/g, character => ({'&': '&', '<': '<', '>': '>', '"': '"'})[character] as string); -} - -function blockTokens(kind: string, text: string, block?: Token['block']): Token[] { - return [{text: BLOCK_START + kind, block}, ...words(text).map(word => ({text: word}))]; -} - -function summaryTokens(markdown: string): Token[] { - const checklist = buildChecklist(markdown); - const tokens: Token[] = []; - if (checklist.title) { - tokens.push(...blockTokens('title', checklist.title, {kind: 'title', html: escapeHtml(checklist.title), time: null})); - } - for (const section of checklist.sections) { - if (section.headingHtml || section.time !== null) { - const text = [section.headingText ?? '', section.time === null ? '' : `[${formatClock(section.time)}]`].join(' ').trim(); - tokens.push(...blockTokens('heading', text, {kind: 'heading', html: section.headingHtml ?? '', time: section.time})); - } - for (const block of section.blocks) { - tokens.push(...blockTokens(block.kind, textOf(block.html, block.kind === 'code'), {kind: block.kind, html: block.html, time: null})); - } - } - - return tokens; -} - -function questionTokens(question: RevisionQuestion): Token[] { - return [ - ...blockTokens('prompt', textOf(renderBlock(question.prompt))), - ...question.choices.flatMap(choice => blockTokens('choice', textOf(renderInline(choice)))), - ...blockTokens('answer', CHOICE_LETTERS[question.answer] ?? String(question.answer + 1)), - ...(question.time === null ? [] : blockTokens('time', formatClock(question.time))), - ...(question.explanation ? blockTokens('explanation', textOf(renderBlock(question.explanation))) : []), - ]; -} - -interface Group { - start: Tracked; - words: Tracked[]; -} - -function groups(tracked: readonly Tracked[]): Group[] { - const result: Group[] = []; - for (const entry of tracked) { - if (entry.token.text.startsWith(BLOCK_START)) { - result.push({start: entry, words: []}); - } else { - result[result.length - 1]?.words.push(entry); - } - } - - return result; -} - -function changeOf(entries: readonly Tracked[]): {change: Change; version: number | null} { - if (entries.every(entry => entry.added === null && entry.removed === null)) { - return {change: null, version: null}; - } - const added = entries[0].added; - if (added !== null && entries.every(entry => entry.added === added && entry.removed === null)) { - return {change: 'added', version: added}; - } - const removed = entries[0].removed; - if (removed !== null && entries.every(entry => entry.removed === removed)) { - return {change: 'removed', version: removed}; - } - - return {change: 'edited', version: null}; -} - -/** A word one save swapped for a near copy, such as a corrected spelling: only the letters that differ. */ -function letterRuns(first: Tracked, second: Tracked): ChangeRun[] | null { - const [gone, come] = first.removed !== null ? [first, second] : [second, first]; - const version = gone.removed; - if (version === null || gone.added !== null || come.added !== version || come.removed !== null - || !isWord(gone.token.text) || !isWord(come.token.text)) { - return null; - } - const before = letters(gone.token.text); - const after = letters(come.token.text); - const shorter = Math.min(before.length, after.length); - let prefix = 0; - while (prefix < shorter && before[prefix] === after[prefix]) { - prefix++; - } - let suffix = 0; - while (suffix < shorter - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) { - suffix++; - } - if (shorter < 3 || prefix + suffix < shorter / 2) { - return null; - } - - return [ - {text: before.slice(0, prefix).join(''), change: null, version: null}, - {text: before.slice(prefix, before.length - suffix).join(''), change: 'removed', version}, - {text: after.slice(prefix, after.length - suffix).join(''), change: 'added', version}, - {text: before.slice(before.length - suffix).join(''), change: null, version: null}, - ]; -} - -function runsOf(entries: readonly Tracked[]): ChangeRun[] { - const runs: ChangeRun[] = []; - const push = (run: ChangeRun) => { - const last = runs[runs.length - 1]; - if (!run.text) { - return; - } - if (last && last.change === run.change && last.version === run.version) { - last.text += run.text; - } else { - runs.push({...run}); - } - }; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - const refined = i + 1 < entries.length ? letterRuns(entry, entries[i + 1]) : null; - if (refined) { - refined.forEach(push); - i++; - } else { - push({ - text: entry.token.text, - change: entry.removed !== null ? 'removed' : entry.added !== null ? 'added' : null, - version: entry.removed ?? entry.added, - }); - } - } - - return runs; -} - -function historyOf(used: readonly Revision[], tracked: readonly Tracked[]): History { - const authors: ChangeAuthor[] = []; - const versions: ChangeVersion[] = used.map((revision, i) => { - if (i === 0) { - return {revision: revision.revision, author: null, created_at: revision.created_at}; - } - const key = revision.author?.key || (revision.author ? 'editor' : 'ai'); - let author = authors.findIndex(known => known.key === key); - if (author < 0) { - author = authors.push({key, name: revision.author ? revision.author.name : 'AI', added: 0, removed: 0}) - 1; - } - - return {revision: revision.revision, author, created_at: revision.created_at}; - }); - let changed = false; - for (const entry of tracked) { - const version = entry.removed ?? entry.added; - if (version === null) { - continue; - } - changed = true; - const author = versions[version]?.author; - if (author !== null && author !== undefined && !entry.token.text.startsWith(BLOCK_START) && isWord(entry.token.text)) { - authors[author][entry.removed !== null ? 'removed' : 'added']++; - } - } - - return { - base: {revision: used[0].revision, author: used[0].author, created_at: used[0].created_at}, - authors, - versions, - changed, - }; -} - -export function summaryChanges(revisions: readonly SummaryRevision[]): SummaryChanges | null { - if (!revisions.length) { - return null; - } - const used = revisions.slice(baseIndex(revisions)); - const tracked = netChanges(track(used.map(revision => summaryTokens(revision.content)))); - let number = 0; - const blocks = groups(tracked).map((group): ChangedBlock => { - const block = group.start.token.block ?? {kind: 'html' as const, html: '', time: null}; - const {change, version} = changeOf([group.start, ...group.words]); - const shown = group.start.removed === null; - - return { - kind: block.kind, - html: block.html, - time: block.time, - change, - version, - runs: change === 'edited' ? runsOf(group.words) : [], - number: block.kind === 'point' && shown ? ++number : null, - }; - }); - - return {...historyOf(used, tracked), blocks}; -} - -export function quizChanges(revisions: readonly QuizRevision[]): QuizChanges | null { - if (!revisions.length) { - return null; - } - const used = revisions.slice(baseIndex(revisions)); - const latest = used[used.length - 1].content; - const ids = new Set(latest.map(question => question.id)); - used.forEach(version => version.content.forEach(question => ids.add(question.id))); - - const everything: Tracked[] = []; - const questions: ChangedQuestion[] = []; - for (const id of ids) { - const present = used.map(version => version.content.find(question => question.id === id) ?? null); - const kept = present.map((question, i) => question ? i : -1).filter(i => i >= 0); - const first = kept[0]; - const last = kept[kept.length - 1]; - const removedIn = last < used.length - 1 ? last + 1 : null; - // Added and taken out again: nothing to show. - if (first > 0 && removedIn !== null) { - continue; - } - const tracked = netChanges(track(kept.map(i => questionTokens(present[i] as RevisionQuestion))).map(entry => ({ - token: entry.token, - added: entry.added === null ? (first > 0 ? first : null) : kept[entry.added], - removed: entry.removed === null ? removedIn : kept[entry.removed], - }))); - everything.push(...tracked); - - const {change, version} = changeOf(tracked); - const number = latest.findIndex(question => question.id === id); - let letter = 0; - questions.push({ - id, - change, - version, - number: number < 0 ? null : number + 1, - question: present[last] as RevisionQuestion, - fields: groups(tracked).map((group): ChangedField => { - const kind = group.start.token.text.slice(BLOCK_START.length) as QuestionFieldKind; - const field = changeOf([group.start, ...group.words]); - - return { - kind, - change: field.change, - version: field.version, - runs: runsOf(group.words), - letter: kind === 'choice' && group.start.removed === null ? CHOICE_LETTERS[letter++] ?? String(letter) : null, - }; - }), - }); - } - // The quiz's own order, then whatever was taken out. - questions.sort((a, b) => (a.number ?? Infinity) - (b.number ?? Infinity)); - - return {...historyOf(used, everything), questions}; -} - -/** How many theme colours editors are told apart by; see _changes.scss. */ -export const EDITOR_COLOURS = 6; - -/** An editor's display name, or for one whose name is hidden from this reader, which one they are. */ -export function authorLabel(history: History, author: number): string { - const known = history.authors[author]; - if (known?.name) { - return known.name; - } - const unnamed = history.authors.filter(other => !other.name); - - return known && unnamed.length > 1 ? `Unnamed editor ${unnamed.indexOf(known) + 1}` : 'Unnamed editor'; -} - -/** The class that colours a change by the editor who made it. */ -export function changeClass(history: History, version: number | null): string { - const author = version === null ? null : history.versions[version]?.author ?? null; - - return author === null ? '' : 'by-' + author % EDITOR_COLOURS; -} - -/** "Added by Kan on 9 Oct 2026". */ -export function describeChange(history: History, change: 'added' | 'removed', version: number | null): string { - const saved = version === null ? null : history.versions[version]; - const verb = change === 'added' ? 'Added' : 'Removed'; - if (!saved || saved.author === null) { - return verb; - } - - const label = authorLabel(history, saved.author); - const who = history.authors[saved.author]?.name ? label : label === 'Unnamed editor' ? 'an unnamed editor' : label.toLowerCase(); - - return `${verb} by ${who}${saved.created_at ? ' on ' + formatDay(saved.created_at) : ''}`; -} diff --git a/src/app/study-aids/checklist.spec.ts b/src/app/study-aids/checklist.spec.ts index c59aa99..e90467e 100644 --- a/src/app/study-aids/checklist.spec.ts +++ b/src/app/study-aids/checklist.spec.ts @@ -77,7 +77,7 @@ describe('buildChecklist', () => { expect(new DOMParser().parseFromString(blocks[1].html, 'text/html').querySelector('pre')?.textContent).toBe(diagram + '\n'); }); - it('keys a point by its text, so editing another point keeps its tick', () => { + it('keys a point by its text, so a change to another point keeps its tick', () => { const edited = buildChecklist(SUMMARY.replace('**SER**', '**SER (smooth ER)**')); expect(edited.pointKeys[0]).toBe(checklist.pointKeys[0]); @@ -110,20 +110,11 @@ describe('buildChecklist', () => { expect(renderInline('[notes](https://example.com)')).toContain('target="_blank"'); }); - it('marks Google Drive pictures to be drawn once shown', () => { - const html = renderInline('![ER](https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345/view)'); - - expect(html).not.toContain('ER'); - }); - - it('turns any other image into a link, so nothing loads from elsewhere', () => { - const markdown = '![pixel](https://tracker.example/p.gif) raw'; + it('turns an image into a link, so nothing loads from elsewhere', () => { + const markdown = '![pixel](https://tracker.example/p.gif) raw'; const html = renderInline(markdown); expect(html).not.toContain('pixel'); expect(html).toContain('>raw'); }); diff --git a/src/app/study-aids/checklist.ts b/src/app/study-aids/checklist.ts index 4e47f2c..51239e8 100644 --- a/src/app/study-aids/checklist.ts +++ b/src/app/study-aids/checklist.ts @@ -1,6 +1,5 @@ import {Marked, MarkedExtension} from 'marked'; import {mathExtension} from '../home/course/markdown-math'; -import {DRIVE_IMAGE_CLASS, driveLink, savedDriveId} from './images'; /* * A summary is Markdown written to these conventions (FlickMan's AI follows them too): @@ -14,7 +13,7 @@ import {DRIVE_IMAGE_CLASS, driveLink, savedDriveId} from './images'; * **key term**, ==exam point==, tables and $maths$ * * Points are numbered here, continuously through the whole summary, so the numbers written in - * the Markdown never matter and an editor can add a point anywhere without renumbering. + * the Markdown never matter. */ export interface ChecklistBlock { @@ -142,10 +141,8 @@ function openInNewTab(link: Element): void { } /** - * Links open in a new tab, so following one never takes the reader away from the lecture. A Drive - * picture becomes a link to it, which the page turns into the picture once it is shown (Angular's - * sanitizer would drop the lazy loading an needs). Any other image is only a link: loading - * it would tell a third party who is reading. + * Links open in a new tab, so following one never takes the reader away from the lecture. An image + * is only a link: FlickMan never serves one, and loading it would tell a third party who is reading. */ function finishHtml(html: string): string { const body = new DOMParser().parseFromString(html, 'text/html').body; @@ -155,13 +152,9 @@ function finishHtml(html: string): string { image.remove(); continue; } - const driveId = savedDriveId(src); const link = body.ownerDocument.createElement('a'); - link.setAttribute('href', driveId ? driveLink(driveId) : src); + link.setAttribute('href', src); link.textContent = image.getAttribute('alt') || 'Image'; - if (driveId) { - link.className = DRIVE_IMAGE_CLASS; - } image.replaceWith(link); } body.querySelectorAll('a[href]').forEach(openInNewTab); diff --git a/src/app/study-aids/diff.spec.ts b/src/app/study-aids/diff.spec.ts deleted file mode 100644 index bd4b878..0000000 --- a/src/app/study-aids/diff.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {BLOCK_START, diffTokens, Edit, netChanges, shortestEdit, track, words} from './diff'; - -/** Applies edits to `a` and returns what they make, to check they really turn it into `b`. */ -function apply(a: string[], b: string[], edits: Edit[]): string[] { - return edits.flatMap(edit => edit.op === 'same' ? [a[edit.a]] : edit.op === 'added' ? [b[edit.b]] : []); -} - -function versions(...texts: string[]): {text: string}[][] { - return texts.map(text => words(text).map(word => ({text: word}))); -} - -describe('diff', () => { - it('splits Thai written without spaces into words', () => { - const tokens = words('ไมโทคอนเดรียสร้าง ATP'); - - expect(tokens.join('')).toBe('ไมโทคอนเดรียสร้าง ATP'); - expect(tokens.length).toBeGreaterThan(3); - }); - - it('finds the fewest edits between two lists', () => { - const a = ['the', ' ', 'smooth', ' ', 'ER', ' ', 'stores', ' ', 'calcium']; - const b = ['the', ' ', 'rough', ' ', 'ER', ' ', 'stores', ' ', 'protein', ' ', 'too']; - const edits = shortestEdit(a, b, 100)!; - - expect(apply(a, b, edits)).toEqual(b); - expect(edits.filter(edit => edit.op !== 'same').length).toBe(6); - }); - - it('gives up when two lists differ too much to compare quickly', () => { - expect(shortestEdit(['a', 'b', 'c'], ['x', 'y', 'z'], 2)).toBeNull(); - }); - - it('compares blocks whole before their words', () => { - const a = [BLOCK_START + 'point', 'Golgi', BLOCK_START + 'point', 'Lysosome', ' ', 'pH', ' ', '5']; - const b = [BLOCK_START + 'point', 'Golgi', BLOCK_START + 'point', 'Lysosome', ' ', 'pH', ' ', '4.5', BLOCK_START + 'point', 'New']; - const edits = diffTokens(a, b); - - expect(apply(a, b, edits)).toEqual(b); - expect(edits.filter(edit => edit.op === 'removed').map(edit => a[(edit as {a: number}).a])).toEqual(['5']); - }); - - it('credits each change to the version that made it', () => { - const tracked = track(versions('Lysosome pH 5', 'Lysosome pH 4.5', 'Lysosome pH 4.5 to 5')); - const changes = tracked - .filter(entry => entry.added !== null || entry.removed !== null) - .map(entry => [entry.token.text, entry.added, entry.removed]); - - expect(changes).toEqual([['5', null, 1], ['4.5', 1, null], [' ', 2, null], ['to', 2, null], [' ', 2, null], ['5', 2, null]]); - }); - - it('leaves out words added and removed again', () => { - const tracked = netChanges(track(versions('Nucleus', 'Nucleus nucleolus', 'Nucleus'))); - - expect(tracked.map(entry => entry.token.text)).toEqual(['Nucleus']); - expect(tracked.every(entry => entry.added === null && entry.removed === null)).toBeTrue(); - }); -}); diff --git a/src/app/study-aids/diff.ts b/src/app/study-aids/diff.ts deleted file mode 100644 index a7f2f9b..0000000 --- a/src/app/study-aids/diff.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Word-by-word comparison of saved versions, for showing what editors changed and who changed it. - * - * Text is split into words, spaces and marks with the browser's word segmenter, which also splits - * Thai, written without spaces. A token starting with BLOCK_START begins a block (a point, a - * heading, a choice): blocks are compared whole first, so only the blocks that changed are - * compared word by word, which keeps a long summary quick. - */ - -export const BLOCK_START = '\u0000'; - -export type Edit = - | {op: 'same'; a: number; b: number} - | {op: 'removed'; a: number} - | {op: 'added'; b: number}; - -/** A token as it stands after every version: who added it, and who removed it, by version index. */ -export interface Tracked { - token: T; - /** Null for a token already in the first version. */ - added: number | null; - /** Null while the token is still there in the last version. */ - removed: number | null; -} - -/** Beyond this many differences two blocks are shown as rewritten rather than word by word. */ -const WORD_EDIT_LIMIT = 1500; -const BLOCK_EDIT_LIMIT = 2000; - -const wordSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter('th', {granularity: 'word'}) : null; -const letterSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter('th', {granularity: 'grapheme'}) : null; - -/** Words, spaces and marks, which joined give back the text. */ -export function words(text: string): string[] { - const clean = text.replaceAll(BLOCK_START, ''); - if (wordSegmenter) { - return Array.from(wordSegmenter.segment(clean), segment => segment.segment); - } - - return clean.match(/\s+|[\p{L}\p{M}\p{N}]+|[^\s\p{L}\p{M}\p{N}]/gu) ?? []; -} - -/** Letters as readers see them: a Thai consonant with its vowel and tone marks is one. */ -export function letters(text: string): string[] { - return letterSegmenter ? Array.from(letterSegmenter.segment(text), segment => segment.segment) : Array.from(text); -} - -export function isWord(token: string): boolean { - return /[\p{L}\p{N}]/u.test(token); -} - -/** - * Myers' shortest edit script between two lists of tokens, or null when they differ by more than - * `limit` edits, which would take too long to work out. - */ -export function shortestEdit(a: readonly string[], b: readonly string[], limit: number): Edit[] | null { - let start = 0; - while (start < a.length && start < b.length && a[start] === b[start]) { - start++; - } - let endA = a.length; - let endB = b.length; - while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { - endA--; - endB--; - } - - const n = endA - start; - const m = endB - start; - const max = Math.min(n + m, limit); - const offset = max + 1; - const v = new Int32Array(2 * max + 3); - // Before each round, the furthest points reached in the one before, for tracing the path back. - const trace: Int32Array[] = []; - let rounds = -1; - search: for (let d = 0; d <= max; d++) { - trace.push(v.slice(offset - d - 1, offset + d + 2)); - for (let k = -d; k <= d; k += 2) { - let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1; - let y = x - k; - while (x < n && y < m && a[start + x] === b[start + y]) { - x++; - y++; - } - v[offset + k] = x; - if (x >= n && y >= m) { - rounds = d; - break search; - } - } - } - if (rounds < 0) { - return null; - } - - const edits: Edit[] = []; - let x = n; - let y = m; - for (let d = rounds; d > 0; d--) { - const before = trace[d]; - const at = (k: number) => before[k + d + 1]; - const k = x - y; - const previousK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1; - const previousX = at(previousK); - const previousY = previousX - previousK; - while (x > previousX && y > previousY) { - edits.push({op: 'same', a: start + --x, b: start + --y}); - } - edits.push(x === previousX ? {op: 'added', b: start + --y} : {op: 'removed', a: start + --x}); - } - while (x > 0 && y > 0) { - edits.push({op: 'same', a: start + --x, b: start + --y}); - } - edits.reverse(); - - return [ - ...Array.from({length: start}, (_, i): Edit => ({op: 'same', a: i, b: i})), - ...edits, - ...Array.from({length: a.length - endA}, (_, i): Edit => ({op: 'same', a: endA + i, b: endB + i})), - ]; -} - -function replaced(a: readonly number[], b: readonly number[]): Edit[] { - return [...a.map((i): Edit => ({op: 'removed', a: i})), ...b.map((j): Edit => ({op: 'added', b: j}))]; -} - -interface Span { - start: number; - end: number; - key: string; -} - -function blocks(tokens: readonly string[]): Span[] { - const spans: Span[] = []; - tokens.forEach((token, i) => { - if (i === 0 || token.startsWith(BLOCK_START)) { - spans.push({start: i, end: i + 1, key: token}); - } else { - spans[spans.length - 1].end = i + 1; - spans[spans.length - 1].key += token; - } - }); - - return spans; -} - -function range(span: Span): number[] { - return Array.from({length: span.end - span.start}, (_, i) => span.start + i); -} - -/** The edits that turn `a` into `b`: unchanged blocks first, then word by word inside the rest. */ -export function diffTokens(a: readonly string[], b: readonly string[]): Edit[] { - const blocksA = blocks(a); - const blocksB = blocks(b); - const blockEdits = shortestEdit(blocksA.map(span => span.key), blocksB.map(span => span.key), BLOCK_EDIT_LIMIT) - ?? replaced(blocksA.map((_, i) => i), blocksB.map((_, j) => j)); - const edits: Edit[] = []; - let changedA: number[] = []; - let changedB: number[] = []; - const compareChanged = () => { - if (changedA.length || changedB.length) { - const inner = shortestEdit(changedA.map(i => a[i]), changedB.map(j => b[j]), WORD_EDIT_LIMIT); - edits.push(...(inner - ? inner.map((edit): Edit => edit.op === 'same' - ? {op: 'same', a: changedA[edit.a], b: changedB[edit.b]} - : edit.op === 'removed' ? {op: 'removed', a: changedA[edit.a]} : {op: 'added', b: changedB[edit.b]}) - : replaced(changedA, changedB))); - changedA = []; - changedB = []; - } - }; - for (const edit of blockEdits) { - const sameLength = edit.op === 'same' - && blocksA[edit.a].end - blocksA[edit.a].start === blocksB[edit.b].end - blocksB[edit.b].start; - if (edit.op === 'same' && sameLength) { - compareChanged(); - const spanA = blocksA[edit.a]; - const spanB = blocksB[edit.b]; - for (let i = 0; i < spanA.end - spanA.start; i++) { - edits.push({op: 'same', a: spanA.start + i, b: spanB.start + i}); - } - } else if (edit.op === 'same') { - // The same text split differently: compared word by word with the rest. - changedA.push(...range(blocksA[edit.a])); - changedB.push(...range(blocksB[edit.b])); - } else if (edit.op === 'removed') { - changedA.push(...range(blocksA[edit.a])); - } else { - changedB.push(...range(blocksB[edit.b])); - } - } - compareChanged(); - - return edits; -} - -/** - * Goes through the versions in order and notes, for every token, which version added it and which - * removed it. Removed tokens stay where they were, so the result reads as the last version with - * everything taken out along the way struck through in place. - */ -export function track(versions: readonly T[][]): Tracked[] { - let state: Tracked[] = (versions[0] ?? []).map(token => ({token, added: null, removed: null})); - for (let version = 1; version < versions.length; version++) { - const tokens = versions[version]; - const shown: number[] = []; - state.forEach((entry, i) => { - if (entry.removed === null) { - shown.push(i); - } - }); - const edits = diffTokens(shown.map(i => state[i].token.text), tokens.map(token => token.text)); - const next: Tracked[] = []; - let kept = 0; - const keepUpTo = (index: number) => { - while (kept < index) { - next.push(state[kept++]); - } - }; - for (const edit of edits) { - if (edit.op === 'added') { - next.push({token: tokens[edit.b], added: version, removed: null}); - } else { - const index = shown[edit.a]; - keepUpTo(index); - kept = index + 1; - next.push(edit.op === 'same' ? {...state[index], token: tokens[edit.b]} : {...state[index], removed: version}); - } - } - keepUpTo(state.length); - state = next; - } - - return state; -} - -/** What differs between the first version and the last: words added and then removed again are left out. */ -export function netChanges(tracked: readonly Tracked[]): Tracked[] { - return tracked.filter(entry => entry.added === null || entry.removed === null); -} diff --git a/src/app/study-aids/failures.ts b/src/app/study-aids/failures.ts deleted file mode 100644 index d80cd36..0000000 --- a/src/app/study-aids/failures.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {SaveFailure} from './study-aids.model'; - -/** What to tell an editor when FlickMan refuses a change, other than a conflict. */ -export function describeFailure(failure: SaveFailure): string { - switch (failure.kind) { - case 'invalid': { - const details = Object.values(failure.errors).flat(); - - return details.length ? details.join(' ') : failure.message; - } - case 'forbidden': - return 'This account is no longer allowed to do that.'; - case 'rate-limited': - return 'Too many changes in a short time. Wait a minute, then try again.'; - case 'network': - return 'Flick could not reach the server. Check your connection, then try again.'; - case 'conflict': - return 'Someone else changed this at the same time. Reload it and try again.'; - default: - return failure.message; - } -} diff --git a/src/app/study-aids/image-link.ts b/src/app/study-aids/image-link.ts deleted file mode 100644 index 874c81d..0000000 --- a/src/app/study-aids/image-link.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {InjectionToken} from '@angular/core'; -import {AlertController} from '@ionic/angular'; -import {canShowDriveImage, driveFileId, imageMarkdown} from './images'; - -/** Whether readers can see a Drive picture. A token, so that tests need not reach Google. */ -export const DRIVE_IMAGE_CHECK = new InjectionToken<(id: string) => Promise>('DriveImageCheck', { - providedIn: 'root', - factory: () => canShowDriveImage, -}); - -export interface DriveImageChoice { - id: string; - description: string; -} - -/** Asks the editor for a Google Drive link to a picture; null when they cancel. Throws the message to show for a link that is not one. */ -export async function askForDriveImage(alertCtrl: AlertController): Promise { - const alert = await alertCtrl.create({ - header: 'Add an image from Google Drive', - message: 'Put the picture in your Google Drive, share it as "Anyone with the link", then paste its link here.', - inputs: [ - {name: 'link', type: 'url', placeholder: 'https://drive.google.com/file/d/…'}, - {name: 'description', type: 'text', placeholder: 'What it shows (optional)'}, - ], - buttons: [ - {text: 'Cancel', role: 'cancel'}, - {text: 'Add', role: 'confirm'}, - ], - }); - await alert.present(); - const {role, data} = await alert.onDidDismiss<{values?: {link?: string; description?: string}}>(); - if (role !== 'confirm') { - return null; - } - const id = driveFileId(data?.values?.link ?? ''); - if (!id) { - throw new Error('That is not a Google Drive link. In Drive, choose Share, then Copy link, and paste it here.'); - } - - return {id, description: data?.values?.description ?? ''}; -} - -/** The Markdown for a chosen picture, once it is clear readers can see it. Throws the message to show when they cannot. */ -export async function driveImageMarkdown(choice: DriveImageChoice, canShow: (id: string) => Promise): Promise { - if (!(await canShow(choice.id))) { - throw new Error('Readers would not see that picture. In Google Drive, share it as "Anyone with the link", then add it again.'); - } - - return imageMarkdown(choice.id, choice.description); -} diff --git a/src/app/study-aids/images.spec.ts b/src/app/study-aids/images.spec.ts deleted file mode 100644 index 3cd777d..0000000 --- a/src/app/study-aids/images.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {drawDriveImages, driveFileId, imageMarkdown, normalizeImageLinks, savedDriveId} from './images'; - -const ID = '1AbCdEfGhIjKlMnOpQrStUvWxYz012345'; -const SAVED = `https://drive.google.com/file/d/${ID}/view`; - -describe('images', () => { - it('reads the file from any link Google Drive gives out', () => { - for (const link of [ - `https://drive.google.com/file/d/${ID}/view?usp=sharing`, - `https://drive.google.com/file/d/${ID}/view?usp=drive_link`, - `https://drive.google.com/file/u/1/d/${ID}/edit`, - `https://drive.google.com/file/d/${ID}`, - `https://drive.google.com/open?id=${ID}`, - `https://drive.google.com/uc?export=view&id=${ID}`, - `https://drive.google.com/thumbnail?id=${ID}&sz=w1000`, - `https://lh3.googleusercontent.com/d/${ID}=w800`, - ` ${SAVED} `, - ]) { - expect(driveFileId(link)).withContext(link).toBe(ID); - } - }); - - it('refuses links that only look like Drive', () => { - for (const link of [ - `http://drive.google.com/file/d/${ID}/view`, - `https://drive.google.com.example.com/file/d/${ID}/view`, - `https://example.com/?u=https://drive.google.com/file/d/${ID}/view`, - 'https://drive.google.com/file/d/short/view', - 'https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOpQrStUvWxYz012345', - ]) { - expect(driveFileId(link)).withContext(link).toBeNull(); - } - }); - - it('only draws the saved form, the one FlickMan accepts', () => { - expect(savedDriveId(SAVED)).toBe(ID); - expect(savedDriveId(SAVED + '?usp=sharing')).toBeNull(); - expect(savedDriveId(`https://drive.google.com/open?id=${ID}`)).toBeNull(); - }); - - it('writes pasted Drive links in the saved form', () => { - expect(normalizeImageLinks(`Before ![ER](https://drive.google.com/open?id=${ID}) after [notes](https://example.com)`)) - .toBe(`Before ![ER](${SAVED}) after [notes](https://example.com)`); - expect(normalizeImageLinks('![pixel](https://tracker.example/p.gif)')).toBe('![pixel](https://tracker.example/p.gif)'); - }); - - it('describes a picture with what the editor wrote', () => { - expect(imageMarkdown(ID, ' Cell [1] ')).toBe(`![Cell 1](${SAVED})`); - expect(imageMarkdown(ID, '')).toBe(`![image](${SAVED})`); - }); - - it('draws a Drive picture lazily, from Google, inside its link', () => { - const root = document.createElement('div'); - root.innerHTML = `ER`; - drawDriveImages(root); - const image = root.querySelector('img') as HTMLImageElement; - - expect(image.loading).toBe('lazy'); - expect(image.alt).toBe('ER'); - expect(image.src).toBe(`https://drive.google.com/thumbnail?id=${ID}&sz=w1600`); - expect(root.querySelector('a')?.getAttribute('href')).toBe(SAVED); - - image.dispatchEvent(new Event('error')); - expect(root.querySelector('img')).toBeNull(); - expect(root.textContent).toBe('ER (open in Google Drive)'); - }); -}); diff --git a/src/app/study-aids/images.ts b/src/app/study-aids/images.ts deleted file mode 100644 index 6bd3343..0000000 --- a/src/app/study-aids/images.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Pictures in summaries and questions live in the editors' own Google Drive. Flick keeps only the - * link, always as https://drive.google.com/file/d/{id}/view, and draws the picture straight from - * Google when a reader gets to it, so FlickMan neither stores nor serves any images. An image from - * anywhere else is shown as a link, so a pasted picture cannot tell another site who is reading. - */ - -const DRIVE_ID = '[A-Za-z0-9_-]{20,100}'; - -/** The ways Drive writes a link to a file: file/d/{id}/…, open?id=, uc?id=, thumbnail?id= and its image host. */ -const DRIVE_LINKS = [ - new RegExp(`^https://(?:drive|docs)\\.google\\.com/file/(?:u/\\d+/)?d/(${DRIVE_ID})(?:[/?#]|$)`), - new RegExp(`^https://(?:drive|docs)\\.google\\.com/(?:open|uc|thumbnail)\\?(?:[^#]*&)?id=(${DRIVE_ID})(?:[&#]|$)`), - new RegExp(`^https://lh3\\.googleusercontent\\.com/d/(${DRIVE_ID})(?:[=/?#]|$)`), -]; - -/** The one form saved in the Markdown, the only one FlickMan accepts. */ -const SAVED_LINK = new RegExp(`^https://drive\\.google\\.com/file/d/(${DRIVE_ID})/view$`); - -/** Wide enough for the reading window; Google sends a smaller picture when the file is smaller. */ -const DRAWN_WIDTH = 1600; - -/** The class of the link that stands for a Drive picture until the page draws it (see RichTextComponent). */ -export const DRIVE_IMAGE_CLASS = 'drive-image'; - -/** The Drive file a link points at, in any form Drive gives out, or null. */ -export function driveFileId(link: string): string | null { - const trimmed = link.trim(); - for (const pattern of DRIVE_LINKS) { - const match = pattern.exec(trimmed); - if (match) { - return match[1]; - } - } - - return null; -} - -export function driveLink(id: string): string { - return `https://drive.google.com/file/d/${id}/view`; -} - -/** The Drive file of a saved image link, or null for any other address. */ -export function savedDriveId(src: string): string | null { - return SAVED_LINK.exec(src.trim())?.[1] ?? null; -} - -/** Where a Drive picture is drawn from. */ -export function driveImageUrl(id: string, width = DRAWN_WIDTH): string { - return `https://drive.google.com/thumbnail?id=${id}&sz=w${width}`; -} - -/** Markdown for a picture, described by what the editor wrote, else by the word "image". */ -export function imageMarkdown(id: string, description: string): string { - return `![${description.replace(/[[\]\\]/g, '').trim() || 'image'}](${driveLink(id)})`; -} - -const MARKDOWN_IMAGE = /(!\[[^\]]*]\(\s*)(]+)(>?)/g; - -/** The same Markdown with every Drive picture written in the saved form, whichever link was pasted. */ -export function normalizeImageLinks(markdown: string): string { - return markdown.replace(MARKDOWN_IMAGE, (whole, start: string, open: string, url: string, close: string) => { - const id = driveFileId(url); - - return id ? start + driveLink(id) : start + open + url + close; - }); -} - -/** - * Whether readers will be able to see the picture: it loads only when the file is shared as - * "Anyone with the link". Gives up after `timeout` milliseconds. - */ -export function canShowDriveImage(id: string, timeout = 15_000): Promise { - return new Promise(resolve => { - const image = new Image(); - const timer = setTimeout(() => finish(false), timeout); - const finish = (shown: boolean) => { - clearTimeout(timer); - image.onload = image.onerror = null; - resolve(shown); - }; - image.referrerPolicy = 'no-referrer'; - image.onload = () => finish(image.naturalWidth > 0); - image.onerror = () => finish(false); - image.src = driveImageUrl(id, 200); - }); -} - -/** - * Turns the links that stand for Drive pictures into the pictures, each loaded only when it comes - * into view. A file that is not shared with everyone stays a link to it. - */ -export function drawDriveImages(root: HTMLElement): void { - root.querySelectorAll(`a.${DRIVE_IMAGE_CLASS}:not(.drawn)`).forEach(link => { - const id = savedDriveId(link.getAttribute('href') ?? ''); - if (!id) { - return; - } - const description = link.textContent ?? ''; - const image = root.ownerDocument.createElement('img'); - image.loading = 'lazy'; - image.decoding = 'async'; - image.referrerPolicy = 'no-referrer'; - image.alt = description; - image.addEventListener('error', () => { - link.textContent = `${description || 'Image'} (open in Google Drive)`; - link.classList.add('unavailable'); - }, {once: true}); - image.src = driveImageUrl(id); - link.classList.add('drawn'); - link.title = 'Open in Google Drive'; - link.replaceChildren(image); - }); -} diff --git a/src/app/study-aids/local-store.ts b/src/app/study-aids/local-store.ts index 3156d5f..102adc9 100644 --- a/src/app/study-aids/local-store.ts +++ b/src/app/study-aids/local-store.ts @@ -2,7 +2,7 @@ import {isStudyTab, StudyTab} from './tabs'; /* * What a reader does with the summary and quiz stays on the device: ticks, quiz answers in - * progress, whether the card is folded away, which tab is open and whether captions are on. + * progress, whether the card is folded away and which tab is open. * Storage may be unavailable (private browsing), in which case everything still works for the visit. */ @@ -12,8 +12,6 @@ const UNSYNCED_TICKS_KEY = 'flickSummaryTicksUnsynced'; const ANSWERS_PREFIX = 'flickQuizAnswers:'; const COLLAPSED_KEY = 'flickStudyAidsCollapsed'; const TAB_KEY = 'flickStudyAidsTab'; -const CREDIT_KEY = 'flickStudyAidsCredit'; -const CAPTIONS_KEY = 'flickCaptions'; function read(key: string): unknown { try { @@ -92,20 +90,3 @@ export function readTab(): StudyTab { export function writeTab(tab: StudyTab): void { write(TAB_KEY, tab); } - -/** The co-author box starts as the editor last left it. */ -export function readCredit(): boolean { - return read(CREDIT_KEY) === true; -} - -export function writeCredit(credit: boolean): void { - write(CREDIT_KEY, credit); -} - -export function readCaptions(): boolean { - return read(CAPTIONS_KEY) === true; -} - -export function writeCaptions(on: boolean): void { - write(CAPTIONS_KEY, on); -} diff --git a/src/app/study-aids/operations.ts b/src/app/study-aids/operations.ts index 148f87c..ec3d777 100644 --- a/src/app/study-aids/operations.ts +++ b/src/app/study-aids/operations.ts @@ -1,15 +1,9 @@ /* * The GraphQL operations the study aids use. Field names follow FlickMan's schema, which keeps the - * names of the old REST answers. Variables a person types are nullable, so a blank one comes back - * as a validation message rather than a GraphQL type error. + * names of the old REST answers. */ -const PART = 'revision origin edited contributors { name } updated_at verified { revision by { name } at }'; -const SUMMARY = `markdown ${PART}`; -const QUIZ = `questions { id prompt choices answer explanation time stats { answered correct } mine } ${PART}`; -const AUTHOR = 'revision author { key name } created_at'; -const EDITOR = 'email name role added_at'; -const DISPLAY_NAME = 'name changed_at next_change_at'; +const QUIZ = 'questions { id prompt choices answer explanation time stats { answered correct } mine }'; export interface Operation { name: string; @@ -23,38 +17,14 @@ function operation(name: string, query: string): Operation { export const STUDY_AIDS = operation('StudyAids', `query StudyAids($id: ID!) { video(id: $id) { has_transcript - studyAids { video_id can_edit can_manage generation my_ticks summary { ${SUMMARY} } quiz { ${QUIZ} } } + studyAids { video_id generation my_ticks summary { markdown } quiz { ${QUIZ} } } } }`); -export const SUMMARY_REVISIONS = operation('StudySummaryRevisions', `query StudySummaryRevisions($id: ID!) { - video(id: $id) { studyAids { summary { revisions { ${AUTHOR} markdown } } } } -}`); - -export const QUIZ_REVISIONS = operation('StudyQuizRevisions', `query StudyQuizRevisions($id: ID!) { - video(id: $id) { studyAids { quiz { revisions { ${AUTHOR} questions { id prompt choices answer explanation time } } } } } -}`); - export const TRANSCRIPT = operation('LectureTranscript', `query LectureTranscript($id: ID!) { video(id: $id) { transcript { paragraphs { time text } } } }`); -export const SAVE_SUMMARY = operation('SaveStudySummary', `mutation SaveStudySummary($video_id: ID!, $markdown: String, $revision: Int!, $credit: Boolean!) { - saveStudySummary(video_id: $video_id, markdown: $markdown, revision: $revision, credit: $credit) { outcome summary { ${SUMMARY} } } -}`); - -export const SAVE_QUIZ = operation('SaveStudyQuiz', `mutation SaveStudyQuiz($video_id: ID!, $questions: [StudyQuestionInput!]!, $revision: Int!, $credit: Boolean!) { - saveStudyQuiz(video_id: $video_id, questions: $questions, revision: $revision, credit: $credit) { outcome quiz { ${QUIZ} } } -}`); - -export const VERIFY_SUMMARY = operation('VerifyStudySummary', `mutation VerifyStudySummary($video_id: ID!, $revision: Int!, $verified: Boolean!) { - verifyStudySummary(video_id: $video_id, revision: $revision, verified: $verified) { outcome summary { ${SUMMARY} } } -}`); - -export const VERIFY_QUIZ = operation('VerifyStudyQuiz', `mutation VerifyStudyQuiz($video_id: ID!, $revision: Int!, $verified: Boolean!) { - verifyStudyQuiz(video_id: $video_id, revision: $revision, verified: $verified) { outcome quiz { ${QUIZ} } } -}`); - export const ANSWER = operation('AnswerStudyQuestion', `mutation AnswerStudyQuestion($video_id: ID!, $question_id: ID!, $choice: Int!) { answerStudyQuestion(video_id: $video_id, question_id: $question_id, choice: $choice) { answered correct } }`); @@ -62,19 +32,3 @@ export const ANSWER = operation('AnswerStudyQuestion', `mutation AnswerStudyQues export const SAVE_TICKS = operation('SaveStudyTicks', `mutation SaveStudyTicks($video_id: ID!, $ticks: [String!]!) { saveStudyTicks(video_id: $video_id, ticks: $ticks) }`); - -export const EDITORS = operation('StudyAidEditors', `query StudyAidEditors { studyAidEditors { ${EDITOR} } }`); - -export const GRANT_ROLE = operation('GrantStudyAidRole', `mutation GrantStudyAidRole($email: String, $role: String!) { - grantStudyAidRole(email: $email, role: $role) { ${EDITOR} } -}`); - -export const REMOVE_ROLE = operation('RemoveStudyAidRole', `mutation RemoveStudyAidRole($email: String) { - removeStudyAidRole(email: $email) -}`); - -export const DISPLAY_NAME_QUERY = operation('StudyAidDisplayName', `query StudyAidDisplayName { studyAidDisplayName { ${DISPLAY_NAME} } }`); - -export const CHANGE_DISPLAY_NAME = operation('ChangeStudyAidDisplayName', `mutation ChangeStudyAidDisplayName($name: String) { - changeStudyAidDisplayName(name: $name) { ${DISPLAY_NAME} } -}`); diff --git a/src/app/study-aids/parse.ts b/src/app/study-aids/parse.ts index 6742031..4999b19 100644 --- a/src/app/study-aids/parse.ts +++ b/src/app/study-aids/parse.ts @@ -1,22 +1,4 @@ -import { - AnswerStats, - Contributor, - EditorProfile, - GenerationState, - PartMeta, - QuizPart, - QuizQuestion, - QuizRevision, - Revision, - RevisionAuthor, - RevisionQuestion, - StudyAids, - StudyAidsEditor, - SummaryPart, - SummaryRevision, - TranscriptParagraph, - Verification, -} from './study-aids.model'; +import {AnswerStats, GenerationState, QuizPart, QuizQuestion, StudyAids, SummaryPart, TranscriptParagraph} from './study-aids.model'; /* * FlickMan's answers are checked field by field rather than trusted, so a malformed question is @@ -48,38 +30,6 @@ function seconds(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; } -function contributors(value: unknown): Contributor[] { - if (!Array.isArray(value)) { - return []; - } - - return value - .map(entry => isObject(entry) ? text(entry['name'])?.trim() : null) - .filter((name): name is string => !!name) - .map(name => ({name})); -} - -function verification(value: unknown): Verification | null { - if (!isObject(value)) { - return null; - } - const revision = count(value['revision']); - const by = contributors(value['by']); - - return revision !== null && by.length ? {revision, by, at: text(value['at'])} : null; -} - -function meta(fields: Fields): PartMeta { - return { - revision: count(fields['revision']) ?? 0, - origin: fields['origin'] === 'editor' ? 'editor' : 'ai', - edited: fields['edited'] === true, - contributors: contributors(fields['contributors']), - updated_at: text(fields['updated_at']), - verified: verification(fields['verified']), - }; -} - export function parseStats(value: unknown): AnswerStats | null { if (!isObject(value)) { return null; @@ -90,7 +40,7 @@ export function parseStats(value: unknown): AnswerStats | null { return answered !== null && correct !== null && correct <= answered ? {answered, correct} : null; } -function parseRevisionQuestion(value: unknown): RevisionQuestion | null { +function parseQuestion(value: unknown): QuizQuestion | null { if (!isObject(value)) { return null; } @@ -103,31 +53,24 @@ function parseRevisionQuestion(value: unknown): RevisionQuestion | null { if (!id || prompt === null || !choices || choices.length < 2 || answer === null || answer >= choices.length) { return null; } - - return {id, prompt, choices, answer, explanation: text(value['explanation']) || null, time: seconds(value['time'])}; -} - -function parseQuestion(value: unknown): QuizQuestion | null { - const question = parseRevisionQuestion(value); - if (!question || !isObject(value)) { - return null; - } const mine = count(value['mine']); return { - ...question, + id, + prompt, + choices, + answer, + explanation: text(value['explanation']) || null, + time: seconds(value['time']), stats: parseStats(value['stats']), - mine: mine !== null && mine < question.choices.length ? mine : null, + mine: mine !== null && mine < choices.length ? mine : null, }; } export function parseSummary(value: unknown): SummaryPart | null { - if (!isObject(value)) { - return null; - } - const markdown = text(value['markdown']); + const markdown = isObject(value) ? text(value['markdown']) : null; - return markdown === null ? null : {...meta(value), markdown}; + return markdown === null ? null : {markdown}; } export function parseQuiz(value: unknown): QuizPart | null { @@ -135,10 +78,7 @@ export function parseQuiz(value: unknown): QuizPart | null { return null; } - return { - ...meta(value), - questions: value['questions'].map(parseQuestion).filter((question): question is QuizQuestion => question !== null), - }; + return {questions: value['questions'].map(parseQuestion).filter((question): question is QuizQuestion => question !== null)}; } export function parseStudyAids(value: unknown): StudyAids | null { @@ -152,8 +92,6 @@ export function parseStudyAids(value: unknown): StudyAids | null { return { video_id: videoId, - can_edit: value['can_edit'] === true, - can_manage: value['can_manage'] === true, generation: GENERATION_STATES.find(state => state === value['generation']) ?? 'none', has_transcript: value['has_transcript'] === true, my_ticks: Array.isArray(value['my_ticks']) ? value['my_ticks'].filter((key): key is string => typeof key === 'string') : null, @@ -162,65 +100,6 @@ export function parseStudyAids(value: unknown): StudyAids | null { }; } -export function parseEditor(value: unknown): StudyAidsEditor | null { - if (!isObject(value)) { - return null; - } - const email = text(value['email']); - - return email ? { - email, - name: text(value['name']) || null, - role: value['role'] === 'admin' ? 'admin' : 'editor', - added_at: text(value['added_at']), - } : null; -} - -export function parseEditors(value: unknown): StudyAidsEditor[] | null { - if (!isObject(value) || !Array.isArray(value['editors'])) { - return null; - } - - return value['editors'].map(parseEditor).filter((editor): editor is StudyAidsEditor => editor !== null); -} - -function author(value: unknown): RevisionAuthor | null { - if (!isObject(value)) { - return null; - } - - return {key: text(value['key']) ?? '', name: text(value['name'])?.trim() || null}; -} - -function revisions(value: unknown, content: (fields: Fields) => T | null): Revision[] | null { - if (!isObject(value) || !Array.isArray(value['revisions'])) { - return null; - } - const parsed: Revision[] = []; - for (const entry of value['revisions']) { - if (!isObject(entry)) { - continue; - } - const revision = count(entry['revision']); - const saved = content(entry); - if (revision !== null && saved !== null) { - parsed.push({revision, author: author(entry['author']), created_at: text(entry['created_at']), content: saved}); - } - } - - return parsed.sort((a, b) => a.revision - b.revision); -} - -export function parseSummaryRevisions(value: unknown): SummaryRevision[] | null { - return revisions(value, fields => text(fields['markdown'])); -} - -export function parseQuizRevisions(value: unknown): QuizRevision[] | null { - return revisions(value, fields => Array.isArray(fields['questions']) - ? fields['questions'].map(parseRevisionQuestion).filter((question): question is RevisionQuestion => question !== null) - : null); -} - export function parseTranscript(value: unknown): TranscriptParagraph[] | null { if (!isObject(value) || !Array.isArray(value['paragraphs'])) { return null; @@ -231,26 +110,3 @@ export function parseTranscript(value: unknown): TranscriptParagraph[] | null { .map(paragraph => isObject(paragraph) ? {time: seconds(paragraph['time']), text: text(paragraph['text'])?.trim() ?? ''} : null) .filter((paragraph): paragraph is TranscriptParagraph => !!paragraph && paragraph.time !== null && !!paragraph.text); } - -export function parseProfile(value: unknown): EditorProfile | null { - if (!isObject(value)) { - return null; - } - const name = text(value['name'])?.trim(); - - return name ? {name, changed_at: text(value['changed_at']), next_change_at: text(value['next_change_at'])} : null; -} - -/** Laravel's validation errors: field path to messages. */ -export function parseErrors(value: unknown): Record { - const errors: Record = {}; - if (isObject(value)) { - for (const [field, messages] of Object.entries(value)) { - if (Array.isArray(messages)) { - errors[field] = messages.filter((message): message is string => typeof message === 'string'); - } - } - } - - return errors; -} diff --git a/src/app/study-aids/quiz.spec.ts b/src/app/study-aids/quiz.spec.ts index 535ec29..16a7432 100644 --- a/src/app/study-aids/quiz.spec.ts +++ b/src/app/study-aids/quiz.spec.ts @@ -1,4 +1,4 @@ -import {blankQuestionForm, errorsByQuestion, questionChange, questionForm, questionProblems, resetsStats, score} from './quiz'; +import {effectiveAnswers, score} from './quiz'; import {QuizQuestion} from './study-aids.model'; const QUESTION: QuizQuestion = { @@ -12,94 +12,25 @@ const QUESTION: QuizQuestion = { mine: null, }; -describe('questionProblems', () => { - it('accepts a complete question', () => { - expect(questionProblems(questionForm(QUESTION, 0), 3000)).toEqual([]); - }); - - it('asks for everything a new question is missing', () => { - const problems = questionProblems(blankQuestionForm(0), 3000); - - expect(problems).toContain('Write the question.'); - expect(problems).toContain('Fill in or remove the empty choice.'); - expect(problems).toContain('Mark the correct answer.'); - }); - - it('refuses choices that differ only in case or spacing', () => { - const form = {...questionForm(QUESTION, 0), choices: ['Leydig cell', ' leydig CELL ', 'Neutrophil']}; - - expect(questionProblems(form, 3000)).toContain('Two choices say the same thing.'); - }); - - it('checks the video time against the lecture', () => { - expect(questionProblems({...questionForm(QUESTION, 0), time: '7:4'}, 3000)) - .toContain('Write the video time as m:ss or h:mm:ss.'); - expect(questionProblems({...questionForm(QUESTION, 0), time: '51:00'}, 3000)) - .toContain('The video time is after the end of the lecture.'); - }); - - it('refuses an answer beyond the choices', () => { - const form = {...questionForm(QUESTION, 0), choices: ['A', 'B'], answer: 2}; - - expect(questionProblems(form, 3000)).toContain('Mark the correct answer.'); - }); -}); - -describe('questionChange', () => { - it('trims what is sent and leaves out empty optional fields', () => { - const form = {...questionForm(QUESTION, 0), prompt: ' Which? ', explanation: ' ', time: ''}; - - expect(questionChange(form)).toEqual({ - id: QUESTION.id, - prompt: 'Which?', - choices: QUESTION.choices, - answer: 2, - explanation: null, - time: null, - }); - }); - - it('sends a new question without an id', () => { - expect(questionChange(blankQuestionForm(3)).id).toBeNull(); - }); -}); - -describe('resetsStats', () => { - it('is false for a changed explanation or time', () => { - expect(resetsStats({...questionForm(QUESTION, 0), explanation: 'Other', time: '1:00'})).toBeFalse(); - }); - - it('is true for a changed prompt, choice or answer', () => { - expect(resetsStats({...questionForm(QUESTION, 0), prompt: 'Changed'})).toBeTrue(); - expect(resetsStats({...questionForm(QUESTION, 0), answer: 1})).toBeTrue(); - const form = questionForm(QUESTION, 0); - form.choices[4] = 'Platelet'; - - expect(resetsStats(form)).toBeTrue(); - }); +describe('score', () => { + it('counts right answers only', () => { + const other = {...QUESTION, id: 'other', answer: 0}; - it('is false for a question nobody has answered', () => { - expect(resetsStats({...questionForm(QUESTION, 0), stats: null, prompt: 'Changed'})).toBeFalse(); + expect(score([QUESTION, other], {[QUESTION.id]: 2, other: 1})).toBe(1); }); }); -describe('errorsByQuestion', () => { - it('sorts errors by question', () => { - const {byQuestion, general} = errorsByQuestion({ - 'questions.1.choices.2': ['Choices must be distinct.'], - 'questions.1.answer': ['The answer is out of range.'], - 'revision': ['The revision is required.'], - }); +describe('effectiveAnswers', () => { + it('takes this device\'s answers, even when it has cleared them', () => { + const answered = {...QUESTION, mine: 1}; - expect(byQuestion.get(1)).toEqual(['Choices must be distinct.', 'The answer is out of range.']); - expect(general).toEqual(['The revision is required.']); + expect(effectiveAnswers({[QUESTION.id]: 2}, [answered])).toEqual({[QUESTION.id]: 2}); + expect(effectiveAnswers({}, [answered])).toEqual({}); }); -}); -describe('score', () => { - it('counts right answers only', () => { - const other = {...QUESTION, id: 'other', answer: 0}; + it('falls back to the first answers the reader gave on any device', () => { + const other = {...QUESTION, id: 'other', mine: 3}; - expect(score([QUESTION, other], {[QUESTION.id]: 2, other: 1})).toBe(1); + expect(effectiveAnswers(null, [QUESTION, other])).toEqual({other: 3}); }); }); diff --git a/src/app/study-aids/quiz.ts b/src/app/study-aids/quiz.ts index 64894f4..5f26a00 100644 --- a/src/app/study-aids/quiz.ts +++ b/src/app/study-aids/quiz.ts @@ -1,140 +1,7 @@ -import {formatStamp, parseTimestamp} from './checklist'; -import {normalizeImageLinks} from './images'; -import {AnswerStats, LIMITS, QuestionChange, QuizQuestion} from './study-aids.model'; +import {QuizQuestion} from './study-aids.model'; export const CHOICE_LETTERS = 'ABCDEFGH'; -/** One question as the quiz editor holds it while it is being changed. */ -export interface QuestionForm { - /** Tracks the question in the editor's list; unrelated to the server id. */ - key: number; - id: string | null; - prompt: string; - choices: string[]; - answer: number | null; - explanation: string; - /** As typed, `m:ss` or `h:mm:ss`, or empty. */ - time: string; - stats: AnswerStats | null; - /** What the question asked when the editor opened, to tell whether saving resets its stats. */ - original: {prompt: string; choices: string[]; answer: number} | null; -} - -/** New questions get five choices, the A–E single-best-answer format FlickMan's AI writes. */ -export const NEW_QUESTION_CHOICES = 5; - -export function questionForm(question: QuizQuestion, key: number): QuestionForm { - return { - key, - id: question.id, - prompt: question.prompt, - choices: [...question.choices], - answer: question.answer, - explanation: question.explanation ?? '', - time: question.time === null ? '' : formatStamp(question.time), - stats: question.stats, - original: {prompt: question.prompt, choices: [...question.choices], answer: question.answer}, - }; -} - -export function blankQuestionForm(key: number): QuestionForm { - return { - key, - id: null, - prompt: '', - choices: Array.from({length: NEW_QUESTION_CHOICES}, () => ''), - answer: null, - explanation: '', - time: '', - stats: null, - original: null, - }; -} - -/** What needs fixing before FlickMan would accept the question. */ -export function questionProblems(form: QuestionForm, maxTime: number): string[] { - const problems: string[] = []; - const prompt = form.prompt.trim(); - if (!prompt) { - problems.push('Write the question.'); - } else if (prompt.length > LIMITS.promptChars) { - problems.push(`Shorten the question to ${LIMITS.promptChars} characters.`); - } - - const choices = form.choices.map(choice => choice.trim()); - if (choices.length < LIMITS.minChoices) { - problems.push(`Give at least ${LIMITS.minChoices} choices.`); - } - if (choices.some(choice => !choice)) { - problems.push('Fill in or remove the empty choice.'); - } - if (choices.some(choice => choice.length > LIMITS.choiceChars)) { - problems.push(`Shorten each choice to ${LIMITS.choiceChars} characters.`); - } - const filled = choices.filter(Boolean).map(choice => choice.toLowerCase()); - if (new Set(filled).size !== filled.length) { - problems.push('Two choices say the same thing.'); - } - if (form.answer === null || form.answer >= choices.length) { - problems.push('Mark the correct answer.'); - } - - if (form.explanation.trim().length > LIMITS.explanationChars) { - problems.push(`Shorten the explanation to ${LIMITS.explanationChars} characters.`); - } - if (form.time.trim()) { - const time = parseTimestamp(form.time); - if (time === null) { - problems.push('Write the video time as m:ss or h:mm:ss.'); - } else if (time > maxTime) { - problems.push('The video time is after the end of the lecture.'); - } - } - - return problems; -} - -export function questionChange(form: QuestionForm): QuestionChange { - return { - id: form.id, - prompt: normalizeImageLinks(form.prompt.trim()), - choices: form.choices.map(choice => normalizeImageLinks(choice.trim())), - answer: form.answer ?? 0, - explanation: normalizeImageLinks(form.explanation.trim()) || null, - time: form.time.trim() ? parseTimestamp(form.time) : null, - }; -} - -/** FlickMan restarts a question's class stats when what it asks or its answer changes. */ -export function resetsStats(form: QuestionForm): boolean { - if (!form.original || !form.stats?.answered) { - return false; - } - const change = questionChange(form); - - return change.prompt !== form.original.prompt.trim() - || change.answer !== form.original.answer - || change.choices.length !== form.original.choices.length - || change.choices.some((choice, i) => choice !== form.original!.choices[i].trim()); -} - -/** Sorts Laravel's `questions.3.choices.1`-style errors by question, and the rest. */ -export function errorsByQuestion(errors: Record): {byQuestion: Map; general: string[]} { - const byQuestion = new Map(); - const general: string[] = []; - for (const [field, messages] of Object.entries(errors)) { - const match = /^questions\.(\d+)(?:\.|$)/.exec(field); - if (match) { - const index = Number(match[1]); - byQuestion.set(index, [...(byQuestion.get(index) ?? []), ...messages]); - } else { - general.push(...messages); - } - } - - return {byQuestion, general}; -} - /** How many of the given answers are right. */ export function score(questions: QuizQuestion[], answers: Record): number { return questions.filter(question => answers[question.id] === question.answer).length; diff --git a/src/app/study-aids/study-aids.model.ts b/src/app/study-aids/study-aids.model.ts index 12f4845..04d3424 100644 --- a/src/app/study-aids/study-aids.model.ts +++ b/src/app/study-aids/study-aids.model.ts @@ -1,13 +1,9 @@ /** - * The summary and quiz FlickMan keeps for each lecture, as served by `Video.studyAids` in its GraphQL API. + * The summary and quiz FlickMan's AI writes for each lecture, as served by `Video.studyAids` in its GraphQL API. * Field names follow the API, like the rest of the ManService models. */ export interface StudyAids { video_id: number; - /** Whether this user may edit the summary and the questions. */ - can_edit: boolean; - /** Whether this user is an admin, who also chooses who the editors are. */ - can_manage: boolean; generation: GenerationState; /** Whether FlickMan has the AI's word-for-word transcript of the lecture. */ has_transcript: boolean; @@ -19,42 +15,15 @@ export interface StudyAids { /** * `none`: no lecture document to work from. `pending`: the AI is writing the parts, which can take - * up to a day. A part an editor wrote stays as it is whatever the state. + * up to a day. */ export type GenerationState = 'none' | 'pending' | 'ready' | 'failed'; -/** `ai` parts began as an AI draft and stay `ai` after editing; `editor` parts were written from scratch. */ -export type PartOrigin = 'ai' | 'editor'; - -export interface Contributor { - name: string; -} - -export interface PartMeta { - /** The version an edit starts from; a save based on an older one is refused. */ - revision: number; - origin: PartOrigin; - /** False while the part is an untouched AI draft. */ - edited: boolean; - /** Editors who chose to be named, in the order they first contributed. */ - contributors: Contributor[]; - updated_at: string | null; - /** The latest version editors checked against the lecture, which may be older than this one. */ - verified: Verification | null; -} - -export interface Verification { - revision: number; - /** Everyone who checked that version, by display name, in the order they did. */ - by: Contributor[]; - at: string | null; -} - -export interface SummaryPart extends PartMeta { +export interface SummaryPart { markdown: string; } -export interface QuizPart extends PartMeta { +export interface QuizPart { questions: QuizQuestion[]; } @@ -77,48 +46,6 @@ export interface AnswerStats { correct: number; } -export type EditorRole = 'editor' | 'admin'; - -/** Someone allowed to edit study aids, as admins see them in the editors list. */ -export interface StudyAidsEditor { - email: string; - /** Null until they have signed in to Flick. */ - name: string | null; - role: EditorRole; - added_at: string | null; -} - -/** The name students see for an editor, which can only be changed once every 30 days. */ -export interface EditorProfile { - name: string; - changed_at: string | null; - /** Null when the name can be changed now. */ - next_change_at: string | null; -} - -/** Who saved a version: null for the AI. */ -export interface RevisionAuthor { - /** Tells editors of the same part apart, even when their names are hidden. */ - key: string; - /** Students only see the names of editors who chose to be credited. */ - name: string | null; -} - -export interface Revision { - revision: number; - author: RevisionAuthor | null; - created_at: string | null; - content: T; -} - -/** A question as it was saved in a version, without anyone's answers. */ -export type RevisionQuestion = Omit; - -export type SummaryRevision = Revision; -export type QuizRevision = Revision; - -export type PartName = 'summary' | 'quiz'; - export interface TranscriptParagraph { /** Seconds into the lecture where the paragraph starts. */ time: number; @@ -133,29 +60,6 @@ export type TranscriptState = /** `limited` when this reader has opened too many transcripts for now. */ | {status: 'error'; limited: boolean}; -export interface SummaryChange { - markdown: string; - revision: number; - /** Whether students see this editor's display name with the summary. */ - credit: boolean; -} - -export interface QuestionChange { - /** Null for a new question; FlickMan assigns the id. */ - id: string | null; - prompt: string; - choices: string[]; - answer: number; - explanation: string | null; - time: number | null; -} - -export interface QuizChange { - questions: QuestionChange[]; - revision: number; - credit: boolean; -} - /** Lets the cards read and move the lecture video without depending on the player. */ export interface LecturePlayback { currentTime(): number; @@ -168,26 +72,3 @@ export type StudyAidsState = /** Nothing to show for this lecture, or no study aids on this server. */ | {status: 'unavailable'} | {status: 'error'}; - -/** Why a save was refused, in the terms the editors need to explain it. */ -export type SaveFailure = - | {kind: 'conflict'; current: SummaryPart | QuizPart | null} - | {kind: 'invalid'; message: string; errors: Record} - | {kind: 'forbidden'} - | {kind: 'rate-limited'} - | {kind: 'network'} - | {kind: 'failed'; message: string}; - -/** The limits FlickMan enforces, checked here first so editors see the problem before saving. */ -export const LIMITS = { - summaryChars: 100_000, - questions: 50, - promptChars: 2000, - minChoices: 2, - maxChoices: 6, - choiceChars: 500, - explanationChars: 4000, - displayNameChars: 50, - /** For lectures whose duration is not known. */ - maxTime: 86_400, -}; diff --git a/src/app/study-aids/tabs.ts b/src/app/study-aids/tabs.ts index d10af6e..b21c55d 100644 --- a/src/app/study-aids/tabs.ts +++ b/src/app/study-aids/tabs.ts @@ -8,16 +8,16 @@ export const TAB_LABELS: Record = { quiz: 'Quiz', }; -/** The tabs with something in them for this reader: editors also get the ones they can fill. */ +/** The tabs with something in them, or that the AI is still writing. */ export function studyTabs(aids: StudyAids): StudyTab[] { const tabs: StudyTab[] = []; - if (aids.summary || aids.can_edit || aids.generation === 'pending') { + if (aids.summary || aids.generation === 'pending') { tabs.push('summary'); } if (aids.has_transcript) { tabs.push('transcript'); } - if (aids.quiz?.questions.length || aids.can_edit || (aids.generation === 'pending' && !aids.quiz)) { + if (aids.quiz?.questions.length || (aids.generation === 'pending' && !aids.quiz)) { tabs.push('quiz'); }