Sign In

campus-cli

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

campus-cli - npm Package Compare versions

Comparing version
1.0.2
to
1.1.0
+13
-0
CHANGELOG.md

@@ -7,2 +7,15 @@ # Changelog

## [1.1.0] — 2026-07-21
### Removed
- **Breaking**: se eliminaron las tools `blackboard_get_quiz_questions`, `blackboard_save_quiz_answer` y `blackboard_submit_quiz` (auto-resolver y enviar quizzes/evaluaciones). Navegar y descargar archivos adjuntos dentro de un quiz sigue funcionando igual vía `blackboard_list_contents`/`blackboard_list_attachments`.
### Added
- `blackboard_upload_attempt_file` — sube un archivo local (imagen, PDF, etc.) a Blackboard y devuelve un `fileUploadId` para adjuntarlo a una entrega.
- `blackboard_save_attempt_draft` — guarda texto y/o archivos en una entrega de tarea SIN enviarla; el intento queda abierto para seguir editando. No requiere confirmación (a diferencia de `blackboard_submit_attempt`).
- `blackboard_submit_attempt` ahora también acepta `fileUploadIds` para adjuntar archivos al enviar.
### Notes
- Estas tools operan sobre columnas de calificación tipo entrega de archivo/texto/link. En Blackboard Ultra, tareas y quizzes comparten el mismo tipo de contenido (`resource/x-bb-asmt-test-link`); si se usan contra una evaluación con preguntas interactivas, Blackboard responde `400` ("Attempts cannot be created for assessments with non-presentation-only questions"). Un `403 bb-rest-attempt-past-due-exception` es el comportamiento normal una vez vencida la fecha de entrega.
## [1.0.2] — 2026-07-15

@@ -9,0 +22,0 @@

+4
-21

@@ -27,19 +27,2 @@ # campus-cli — Agent Guide

### Quiz workflow
```
1. blackboard_list_contents <courseId> → find the quiz contentId
2. blackboard_get_quiz_questions <url|ids> → load questions + options + attempt policy
3. blackboard_save_quiz_answer (per question) → save each answer individually
4. blackboard_submit_quiz (confirm first!) → finalize and submit the attempt
```
Supported question types in `blackboard_save_quiz_answer`:
| `question.type` | `answer` format |
|-------------------|--------------------------------------------------------------------------------|
| `eitherOr` | boolean (`true` = Verdadero, `false` = Falso) |
| `multipleanswer` | number — 0-based index of the chosen option |
| `fimb` | JSON string `'{"BLANK-1":"value1","BLANK-2":"value2"}'` — read names from `question.blanks` |
### Feedback workflow

@@ -54,3 +37,4 @@

- **Always confirm before submitting** (`blackboard_submit_attempt`, `blackboard_submit_quiz`). Show the user what will be submitted and ask for confirmation. Never submit silently.
- **Always confirm before submitting** (`blackboard_submit_attempt`). Show the user what will be submitted and ask for confirmation. Never submit silently. `blackboard_save_attempt_draft` (saving progress without sending) does not need this confirmation — only the final submit does.
- **`blackboard_save_attempt_draft`/`blackboard_submit_attempt` only work on file/text/link-submission columns** — not on quiz-style columns with interactive questions (both look identical from `blackboard_list_assignments`, since Ultra treats tests and assignments as the same `resource/x-bb-asmt-test-link` content type). If Blackboard returns `400` with a message like "Attempts cannot be created for assessments with non-presentation-only questions", that column is actually a quiz/test — tell the user, don't retry. A `403 bb-rest-attempt-past-due-exception` is expected/normal once the due date has passed and late attempts aren't allowed — same as the web UI would show, not a bug.
- **Show grades in context** — when showing grades, also show the assignment name, max score, and due date if available.

@@ -96,8 +80,7 @@ - **Navigate content recursively** — if the user asks for materials, explore subfolders using `blackboard_list_contents` with `parentId`.

| `blackboard_download_file_url` | Download a bbcswebdav URL directly |
| `blackboard_upload_attempt_file` | Upload a local file (image, PDF, etc.) and get a fileUploadId |
| `blackboard_save_attempt_draft` | Save text/files to an attempt WITHOUT submitting (stays open for editing) |
| `blackboard_submit_attempt` | Submit assignment (confirm first!) |
| `blackboard_get_assignment_feedback` | Scores + instructor comments + feedback files for all assignments in a course |
| `blackboard_download_feedback_file` | **[EXPERIMENTAL]** Download a file the professor attached to a graded attempt |
| `blackboard_get_quiz_questions` | Load quiz questions + options from an attempt (URL or IDs) |
| `blackboard_save_quiz_answer` | Save one answer without submitting |
| `blackboard_submit_quiz` | Finalize and submit a quiz attempt (confirm first!) |
| `blackboard_raw_api` | Any other Blackboard endpoint |
{
"name": "campus-cli",
"version": "1.0.2",
"version": "1.1.0",
"description": "CLI/MCP no oficial para el campus universitario (Blackboard, Canvas, Moodle...) — acceso desde la terminal y MCP para IA",

@@ -5,0 +5,0 @@ "main": "run.js",

@@ -298,6 +298,5 @@ # campus-cli

| `blackboard_download_attachment` | Descargar archivo |
| `blackboard_upload_attempt_file` | Subir un archivo local (imagen, PDF, etc.) y obtener un fileUploadId |
| `blackboard_save_attempt_draft` | Guardar texto/archivos en un intento SIN enviarlo (queda abierto para seguir editando) |
| `blackboard_submit_attempt` | Entregar tarea; confirma antes de enviar |
| `blackboard_get_quiz_questions` | Cargar preguntas de un quiz |
| `blackboard_save_quiz_answer` | Guardar una respuesta sin enviar |
| `blackboard_submit_quiz` | Finalizar y enviar un quiz; confirma antes de enviar |
| `blackboard_get_assignment_feedback` | Comentarios y feedback del profesor |

@@ -321,3 +320,3 @@ | `blackboard_raw_api` | Cualquier endpoint de Blackboard |

- Tarea 1 de Algoritmos, vence el 15/04.
- Quiz de Bases de Datos, vence el 18/04.
- Lectura de Bases de Datos, vence el 18/04.
```

@@ -324,0 +323,0 @@

@@ -15,5 +15,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

blackboard_get_grades → blackboard_list_contents para materiales.
Para entregas: blackboard_upload_attempt_file sube cada archivo/imagen y
devuelve un fileUploadId; blackboard_save_attempt_draft guarda texto y/o
esos fileUploadIds sin enviar (el intento queda abierto para seguir
editando, no requiere confirmación); blackboard_submit_attempt finaliza
y envía — siempre confirma con el usuario qué se va a enviar antes de
llamarla.
blackboard_raw_api cubre cualquier endpoint no expuesto por una tool
dedicada. Antes de blackboard_submit_attempt o blackboard_submit_quiz,
siempre confirma con el usuario qué se va a enviar.
dedicada.

@@ -20,0 +27,0 @@ Futuro: canvas_* y moodle_* para otras universidades (Canvas, Moodle) —

@@ -18,11 +18,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

import { listAssignments, listAttempts, submitAttempt, uploadFile, getAttemptFiles } from './api/assignments.js';
import {
getQuizQuestions,
saveQuizAnswer,
submitQuizAttempt,
getQuizColumnId,
parseQuizUrl,
type QuizQuestion,
} from './api/quiz.js';
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB
async function getClient() {

@@ -301,87 +295,29 @@ const session = await loadOrRefreshSession();

// ── blackboard_submit_attempt ──────────────────────────────────────────────────────────
// ── blackboard_upload_attempt_file ─────────────────────────────────────────────────────
server.registerTool(
'blackboard_submit_attempt',
'blackboard_upload_attempt_file',
{
description: 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
description:
'Upload a local file (image, PDF, doc, etc.) to Blackboard and get back a fileUploadId. ' +
'This only uploads the file — it does NOT attach it to an attempt yet. ' +
'Pass the returned fileUploadId(s) into blackboard_save_attempt_draft or blackboard_submit_attempt via fileUploadIds.',
inputSchema: {
courseId: z.string().describe('Blackboard course ID'),
columnId: z.string().describe('Assignment (gradebook column) ID'),
studentComments: z.string().optional().describe('Comment to the instructor'),
studentSubmission: z.string().optional().describe('Text body of the submission'),
filePath: z.string().describe('Absolute path to the local file to upload'),
},
},
async ({ courseId, columnId, studentComments, studentSubmission }) => {
async ({ filePath }) => {
const { client } = await getClient();
const attempt = await submitAttempt(client, courseId, columnId, {
studentComments,
studentSubmission,
status: 'NeedsGrading',
});
return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] };
}
);
// ── blackboard_get_quiz_questions ──────────────────────────────────────────────────────
server.registerTool(
'blackboard_get_quiz_questions',
{
description:
'Fetch all questions and answer options from a Blackboard Ultra quiz attempt. ' +
'Provide either a full quiz URL, or courseId + contentId + attemptId. ' +
'Returns each question with its type, text, options, and current saved answer.',
inputSchema: {
url: z
.string()
.optional()
.describe(
'Full Ultra quiz URL, e.g. https://aulavirtual.upc.edu.pe/ultra/stream/assessment/_69146765_1/overview/attempt/_94898825_1?courseId=_529533_1'
),
courseId: z.string().optional().describe('Course ID (e.g. _529533_1) — required if url not given'),
contentId: z.string().optional().describe('Quiz content item ID (e.g. _69146765_1) — required if url not given'),
attemptId: z.string().optional().describe('Attempt ID (e.g. _94898825_1) — required if url not given'),
},
},
async ({ url, courseId, contentId, attemptId }) => {
const { client, session } = await getClient();
// Resolve IDs from URL or direct params
let resolvedCourseId = courseId;
let resolvedContentId = contentId;
let resolvedAttemptId = attemptId;
if (url) {
const parsed = parseQuizUrl(url);
resolvedCourseId = resolvedCourseId || parsed.courseId;
resolvedContentId = resolvedContentId || parsed.contentId;
resolvedAttemptId = resolvedAttemptId || parsed.attemptId;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
if (!resolvedCourseId || !resolvedContentId || !resolvedAttemptId) {
throw new Error(
'Provide either a full quiz URL or all three of: courseId, contentId, attemptId'
);
const { size } = fs.statSync(resolved);
if (size > MAX_UPLOAD_BYTES) {
throw new Error(`File too large (${size} bytes). Max is ${MAX_UPLOAD_BYTES} bytes.`);
}
// Get columnId + attempt policy — reuse session already obtained above
const policy = await getQuizColumnId(client, resolvedCourseId, resolvedContentId, session.userId);
if (!policy.canAttempt) {
return {
content: [{
type: 'text',
text: JSON.stringify({
error: 'NO_ATTEMPTS_LEFT',
message: `No quedan intentos para este cuestionario. ${policy.attemptSummary}`,
policy,
}, null, 2),
}],
};
}
const info = await getQuizQuestions(client, resolvedCourseId, policy.columnId, resolvedAttemptId);
const fileUploadId = await uploadFile(client, resolved);
return {
content: [{
type: 'text',
text: JSON.stringify({ attemptPolicy: policy, ...info }, null, 2),
text: JSON.stringify({ fileUploadId, fileName: path.basename(resolved), size }, null, 2),
}],

@@ -392,68 +328,61 @@ };

// ── blackboard_save_quiz_answer ────────────────────────────────────────────────────────
// ── blackboard_save_attempt_draft ──────────────────────────────────────────────────────
server.registerTool(
'blackboard_save_quiz_answer',
'blackboard_save_attempt_draft',
{
description:
'Save a single answer for a quiz question (does NOT submit — use blackboard_submit_quiz to finalize). ' +
'question is the full question object from blackboard_get_quiz_questions. ' +
'answer format depends on question.type:\n' +
' - eitherOr (true/false): boolean (true = Verdadero)\n' +
' - multipleanswer (MC): number (0-based index of the chosen option)\n' +
' - fimb (fill-in-multi-blanks): JSON string of an object mapping blank names to values, ' +
'e.g. \'{"BLANK-1":"1438.62","BLANK-2":"140.62"}\' (read blank names from question.blanks)',
'Save progress on an assignment attempt WITHOUT submitting it — text, attached files, or both. ' +
'The attempt stays open (status InProgress) so the student can keep editing it later. ' +
'This does NOT send it to the instructor for grading — use blackboard_submit_attempt for that, ' +
'and always confirm with the user before calling that one.',
inputSchema: {
courseId: z.string().describe('Course ID'),
attemptId: z.string().describe('Quiz attempt ID (e.g. _94898825_1)'),
question: z.string().describe('JSON string of the question object from blackboard_get_quiz_questions'),
answer: z.union([z.boolean(), z.number(), z.string()]).describe(
'eitherOr: true/false. multipleanswer: 0-based index. ' +
'fimb: JSON string of {blankName: value} (e.g. \'{"BLANK-1":"1438.62"}\').'
courseId: z.string().describe('Blackboard course ID'),
columnId: z.string().describe('Assignment (gradebook column) ID'),
studentComments: z.string().optional().describe('Comment to the instructor'),
studentSubmission: z.string().optional().describe('Text body of the submission'),
fileUploadIds: z.array(z.string()).optional().describe(
'fileUploadId(s) from blackboard_upload_attempt_file to attach to this draft'
),
},
},
async ({ courseId, attemptId, question: questionJson, answer }) => {
async ({ courseId, columnId, studentComments, studentSubmission, fileUploadIds }) => {
const { client } = await getClient();
const question: QuizQuestion = JSON.parse(questionJson);
// For fimb, the MCP transport gives us a JSON string — parse it into the Record<string, string>
// saveQuizAnswer expects. boolean / number pass through as-is.
let parsedAnswer: boolean | number | Record<string, string>;
if (typeof answer === 'string') {
try {
const obj = JSON.parse(answer);
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
throw new Error('fimb answer string must parse to a JSON object');
}
parsedAnswer = obj as Record<string, string>;
} catch (e: any) {
throw new Error(
`Invalid fimb answer: expected a JSON object string like '{"BLANK-1":"value"}'. ${e.message}`
);
}
} else {
parsedAnswer = answer;
}
const result = await saveQuizAnswer(client, courseId, attemptId, question, parsedAnswer);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
const attempt = await submitAttempt(client, courseId, columnId, {
studentComments,
studentSubmission,
fileUploadIds,
status: 'InProgress',
});
return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] };
}
);
// ── blackboard_submit_quiz ─────────────────────────────────────────────────────────────
// ── blackboard_submit_attempt ──────────────────────────────────────────────────────────
server.registerTool(
'blackboard_submit_quiz',
'blackboard_submit_attempt',
{
description:
'Finalize and submit a quiz attempt. ALWAYS confirm with the user before calling this. ' +
'All individual answers should be saved first via blackboard_save_quiz_answer.',
'Submit (finalize) an assignment attempt for grading — text, attached files, or both. ' +
'ALWAYS confirm with the user before submitting, showing exactly what will be sent. ' +
'Once submitted the instructor can grade it; use blackboard_save_attempt_draft instead ' +
'if the student just wants to save progress without sending it yet.',
inputSchema: {
courseId: z.string().describe('Course ID'),
attemptId: z.string().describe('Quiz attempt ID to submit'),
courseId: z.string().describe('Blackboard course ID'),
columnId: z.string().describe('Assignment (gradebook column) ID'),
studentComments: z.string().optional().describe('Comment to the instructor'),
studentSubmission: z.string().optional().describe('Text body of the submission'),
fileUploadIds: z.array(z.string()).optional().describe(
'fileUploadId(s) from blackboard_upload_attempt_file to attach to this submission'
),
},
},
async ({ courseId, attemptId }) => {
async ({ courseId, columnId, studentComments, studentSubmission, fileUploadIds }) => {
const { client } = await getClient();
const result = await submitQuizAttempt(client, courseId, attemptId);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
const attempt = await submitAttempt(client, courseId, columnId, {
studentComments,
studentSubmission,
fileUploadIds,
status: 'NeedsGrading',
});
return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] };
}

@@ -460,0 +389,0 @@ );

/**
* Quiz / Assessment support for Blackboard Ultra.
*
* All endpoints discovered by intercepting the Ultra SPA network traffic:
*
* GET /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
* ?columnId={columnId}&expand=toolAttemptDetail,alignedGoals
* → Returns full question data in toolAttemptDetail["resource/x-bb-assessment"].questionAttempts
*
* PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}/assessment/answers/{questionAttemptId}
* → Saves a single answer (eitherOr or multipleanswer)
*
* PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
* ?autoSubmitted=false&expand=attemptReceipt.lateSubmission
* → Final submit
*/
import type { AxiosInstance } from 'axios';
// ── Types ────────────────────────────────────────────────────────────────────
export interface QuizOption {
id: string;
text: string;
index: number;
}
export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'fimb' | 'presentation' | string;
export interface QuizQuestion {
/** Question attempt ID — used as the URL segment for saving answers */
questionAttemptId: string;
/** Question definition ID */
questionId: string;
/** Position within the quiz (1-based visible number) */
position: number;
/** 'eitherOr' = true/false, 'multipleanswer' = MC, 'fimb' = fill-in-multiple-blanks, 'presentation' = text only */
type: QuizQuestionType;
/** Plain text of the question (HTML stripped) */
text: string;
points: number;
/** Answer options (present for eitherOr and multipleanswer) */
options?: QuizOption[];
/** Blank names in order (present for fimb), e.g. ['BLANK-1', 'BLANK-2'] */
blanks?: string[];
/** Currently saved answer:
* - eitherOr: true | false | null
* - multipleanswer: boolean[] (one per option, in options order)
* - fimb: Record<string, string|null> (one per blank)
*/
currentAnswer?: boolean | boolean[] | Record<string, string | null> | null;
/** Raw question object from API (needed when saving eitherOr answers) */
_raw: any;
}
export interface QuizInfo {
attemptId: string;
courseId: string;
columnId: string;
title: string;
status: string;
totalPoints: number;
questions: QuizQuestion[];
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function stripHtml(html: string): string {
return html
.replace(/<!--[^>]*-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
const q = qa.question || {};
const text = stripHtml(q.questionText?.rawText || q.questionText?.displayText || '');
let options: QuizOption[] | undefined;
let blanks: string[] | undefined;
let currentAnswer: QuizQuestion['currentAnswer'] = qa.givenAnswer ?? null;
if (qa.questionType === 'eitherOr') {
// True/False — always two options
options = [
{ id: 'true', text: 'Verdadero', index: 0 },
{ id: 'false', text: 'Falso', index: 1 },
];
} else if (qa.questionType === 'multipleanswer' && Array.isArray(q.answers)) {
options = q.answers.map((a: any, i: number) => ({
id: a.id,
text: stripHtml(a.answerText?.rawText || a.answerText?.displayText || ''),
index: i,
}));
} else if (qa.questionType === 'fimb' && qa.givenAnswers && typeof qa.givenAnswers === 'object') {
// Fill-in-multiple-blanks — blank names come from givenAnswers keys (e.g. BLANK-1, BLANK-2)
blanks = Object.keys(qa.givenAnswers);
currentAnswer = qa.givenAnswers as Record<string, string | null>;
}
return {
questionAttemptId: qa.id,
questionId: qa.questionId || q.id,
position: qa.visibleQuestionNumber ?? idx + 1,
type: qa.questionType,
text,
points: q.points ?? 0,
options,
blanks,
currentAnswer,
_raw: qa,
};
}
// ── Get quiz questions ────────────────────────────────────────────────────────
/**
* Fetch all questions for a quiz attempt via the internal Blackboard API.
*
* @param courseId e.g. _529533_1
* @param columnId Gradebook column ID — from content item contentHandler.gradeColumnId
* @param attemptId e.g. _94898825_1
*/
export async function getQuizQuestions(
client: AxiosInstance,
courseId: string,
columnId: string,
attemptId: string
): Promise<QuizInfo> {
const r = await client.get(
`/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`,
{ params: { columnId, expand: 'toolAttemptDetail,alignedGoals' } }
);
const data = r.data;
const detail = data.toolAttemptDetail?.['resource/x-bb-assessment'];
if (!detail) {
throw new Error(
`No toolAttemptDetail found for attempt ${attemptId}. ` +
`Make sure the columnId (${columnId}) and courseId are correct.`
);
}
const rawQuestions: any[] = detail.questionAttempts || [];
// Filter out presentation-only questions (no points, no interaction)
const answerableQuestions = rawQuestions.filter(
(qa) => qa.questionType !== 'presentation'
);
const questions = answerableQuestions.map(parseQuestionAttempt);
return {
attemptId,
courseId,
columnId,
title: detail.assessment?.title || 'Quiz',
status: detail.status || data.status || 'IN_PROGRESS',
totalPoints: detail.possiblePoints || detail.assessment?.totalPoints || 0,
questions,
};
}
// ── Save a single answer ──────────────────────────────────────────────────────
/**
* Save one answer for a quiz question.
*
* @param courseId Course ID
* @param attemptId Quiz attempt ID
* @param question The question object from getQuizQuestions
* @param answer
* - eitherOr: boolean (true = Verdadero, false = Falso)
* - multipleanswer: number (0-based index of the selected option)
* OR boolean[] (one per option)
* - fimb: Record<string, string> (one value per blank name, e.g. { "BLANK-1": "1438.62", "BLANK-2": "140.62" })
*/
export async function saveQuizAnswer(
client: AxiosInstance,
courseId: string,
attemptId: string,
question: QuizQuestion,
answer: boolean | number | boolean[] | Record<string, string>
): Promise<any> {
const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}/assessment/answers/${question.questionAttemptId}`;
let body: any;
if (question.type === 'eitherOr') {
const givenAnswer = typeof answer === 'boolean' ? answer : Boolean(answer);
body = {
questionType: 'eitherOr',
givenAnswer,
question: question._raw.question,
};
} else if (question.type === 'multipleanswer') {
const optionCount = question.options?.length ?? 0;
let givenAnswer: boolean[];
if (Array.isArray(answer)) {
givenAnswer = answer as boolean[];
} else {
// Convert index to boolean array
const idx = typeof answer === 'number' ? answer : 0;
givenAnswer = Array.from({ length: optionCount }, (_, i) => i === idx);
}
body = {
questionType: 'multipleanswer',
givenAnswer,
lookupOrder: question._raw.lookupOrder || [],
order: question._raw.order || [],
question: question._raw.question,
};
} else if (question.type === 'fimb') {
if (typeof answer !== 'object' || Array.isArray(answer) || answer === null) {
throw new Error(
`fimb answers must be a Record<string, string> mapping blank name to value. ` +
`Expected blanks: ${question.blanks?.join(', ') || '(unknown)'}`
);
}
// Build givenAnswers using the question's known blank names — preserves order and
// ensures any blank not provided ends up as null (consistent with Blackboard behavior).
const blanks = question.blanks ?? Object.keys(answer);
const givenAnswers: Record<string, string | null> = {};
for (const name of blanks) {
const val = (answer as Record<string, string>)[name];
givenAnswers[name] = val !== undefined ? String(val) : null;
}
body = {
questionType: 'fimb',
givenAnswers,
question: question._raw.question,
};
} else {
throw new Error(`Unsupported question type: ${question.type}`);
}
const r = await client.patch(url, body);
return r.data;
}
// ── Submit the quiz attempt ────────────────────────────────────────────────────
/**
* Finalize and submit a quiz attempt.
* ALWAYS ask the user to confirm before calling this.
*/
export async function submitQuizAttempt(
client: AxiosInstance,
courseId: string,
attemptId: string
): Promise<any> {
const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`;
const r = await client.patch(url, {
toolAttemptDetail: { 'resource/x-bb-assessment': { type: 'Test' } },
status: 'NEEDS_GRADING',
studentSubmission: null,
}, {
params: {
autoSubmitted: 'false',
expand: 'attemptReceipt.lateSubmission',
},
});
return r.data;
}
// ── Get quiz column ID + attempt limits ───────────────────────────────────────
export interface QuizAttemptPolicy {
columnId: string;
assessmentId: string;
title: string;
/** Max attempts allowed. 0 = unlimited. */
attemptsAllowed: number;
/** Attempts left for this student. -1 = unlimited, 0 = none left. */
attemptsLeft: number;
/** Human-readable summary, e.g. "Ilimitados" or "2 de 3 restantes" */
attemptSummary: string;
/** true = safe to proceed, false = no attempts left */
canAttempt: boolean;
}
/**
* Resolve the gradebook column ID for a quiz content item AND fetch
* the attempt policy (max attempts + attempts left) for the current user.
*/
export async function getQuizColumnId(
client: AxiosInstance,
courseId: string,
contentId: string,
userId?: string
): Promise<QuizAttemptPolicy> {
// 1. Content item → columnId + assessmentId
const contentR = await client.get(
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
);
const content = contentR.data;
const handler = content.contentHandler;
if (!handler?.gradeColumnId) {
throw new Error(
`Content item ${contentId} does not have a gradeColumnId. ` +
`Is it a quiz (resource/x-bb-asmt-test-link)?`
);
}
const columnId: string = handler.gradeColumnId;
const assessmentId: string = handler.assessmentId;
const title: string = content.title || 'Quiz';
// 2. Gradebook column → attemptsAllowed (0 = unlimited)
const colR = await client.get(
`/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}`
);
const attemptsAllowed: number = colR.data?.grading?.attemptsAllowed ?? 0;
// 3. Student grade → attemptsLeft (-1 = unlimited)
let attemptsLeft = -1;
if (userId) {
try {
const gradeR = await client.get(
`/learn/api/v1/courses/${courseId}/gradebook/columns/${columnId}/grades`,
{ params: { expand: 'attemptsLeft', userId } }
);
const grade = gradeR.data?.results?.[0];
if (grade?.attemptsLeft !== undefined) {
attemptsLeft = grade.attemptsLeft;
}
} catch {
// non-fatal: fall back to attemptsAllowed logic
}
}
// 4. Derive human-readable summary and canAttempt flag
const unlimited = attemptsAllowed === 0 || attemptsLeft === -1;
const canAttempt = unlimited || attemptsLeft > 0;
let attemptSummary: string;
if (unlimited) {
attemptSummary = 'Ilimitados';
} else if (attemptsLeft === 0) {
attemptSummary = `Sin intentos restantes (máximo: ${attemptsAllowed})`;
} else {
const used = attemptsAllowed - attemptsLeft;
attemptSummary = `${attemptsLeft} de ${attemptsAllowed} restantes (${used} enviados)`;
}
return {
columnId,
assessmentId,
title,
attemptsAllowed,
attemptsLeft,
attemptSummary,
canAttempt,
};
}
// ── Parse URL helper ──────────────────────────────────────────────────────────
/**
* Parse a Blackboard Ultra quiz URL into its component IDs.
*
* Handles:
* /ultra/stream/assessment/{contentId}/overview/attempt/{attemptId}?courseId={courseId}
* /ultra/stream/assessment/{contentId}/take/attempt/{attemptId}?courseId={courseId}
*/
export function parseQuizUrl(urlStr: string): {
contentId?: string;
attemptId?: string;
courseId?: string;
} {
try {
const u = new URL(
urlStr.startsWith('http')
? urlStr
: `https://aulavirtual.upc.edu.pe${urlStr}`
);
const parts = u.pathname.split('/');
const assessmentIdx = parts.indexOf('assessment');
const attemptIdx = parts.indexOf('attempt');
return {
contentId: assessmentIdx >= 0 ? parts[assessmentIdx + 1] : undefined,
attemptId: attemptIdx >= 0 ? parts[attemptIdx + 1] : undefined,
courseId: u.searchParams.get('courseId') ?? undefined,
};
} catch {
return {};
}
}