wicker-study-mcp 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ import { mkdir, open, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
3
+
4
+ export const CANVAS_IMPORT_LIMITS = Object.freeze({
5
+ // A single Canvas page can legitimately reference a large historical past-paper
6
+ // archive. Keep a high, explicit guardrail rather than silently omitting the
7
+ // useful part of a course snapshot.
8
+ maxResources: 2_000,
9
+ maxFileBytes: 1024 * 1024 * 1024,
10
+ timeoutMs: 30_000,
11
+ downloadTimeoutMs: 10 * 60_000
12
+ })
13
+
14
+ export class CanvasCourseImportError extends Error {}
15
+
16
+ function text(value, max = 500) {
17
+ return String(value ?? '').replace(/\0/g, '').replace(/\s+/g, ' ').trim().slice(0, max)
18
+ }
19
+
20
+ function number(value, fallback = 0) {
21
+ const parsed = Number(value)
22
+ return Number.isFinite(parsed) ? parsed : fallback
23
+ }
24
+
25
+ function prefix(value) {
26
+ return String(Math.max(0, number(value))).padStart(3, '0')
27
+ }
28
+
29
+ function safeSegment(value, fallback = 'untitled') {
30
+ const cleaned = text(value, 120).normalize('NFKD').replace(/[\\/:*?"<>|]/g, '-').replace(/[^\p{L}\p{N}._ -]/gu, '').replace(/\s+/g, ' ').replace(/[. ]+$/g, '').trim()
31
+ return cleaned || fallback
32
+ }
33
+
34
+ function filename(value, fallback = 'material') {
35
+ const raw = basename(text(value, 180)).replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').replace(/[. ]+$/g, '').trim()
36
+ return raw || fallback
37
+ }
38
+
39
+ function fileCategory(value) {
40
+ const name = text(value, 240).toLowerCase()
41
+ if (/(syllabus|course manual|course outline|study guide|course information)/.test(name)) return 'course-information'
42
+ if (/(slide|lecture|deck|presentation)/.test(name)) return 'slides'
43
+ if (/(exam|mock|past.?paper|resit|quiz)/.test(name)) return 'assessments'
44
+ if (/(assignment|project|tutorial|practice|exercise|worksheet)/.test(name)) return 'activities'
45
+ if (/(read|article|paper|book|chapter)/.test(name)) return 'readings'
46
+ return 'materials'
47
+ }
48
+
49
+ function escapeHtml(value) {
50
+ return String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
51
+ }
52
+
53
+ export function sanitizeCanvasHtml(value) {
54
+ return String(value || '')
55
+ .replace(/<\s*(script|style|iframe|object|embed|form)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
56
+ .replace(/<\s*(script|style|iframe|object|embed|form)\b[^>]*\/?>/gi, '')
57
+ .replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
58
+ .replace(/\s+(href|src)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, '')
59
+ }
60
+
61
+ function htmlRecord({ title, url, body, details = [] }) {
62
+ const rows = details.filter(([label, value]) => text(value)).map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join('')
63
+ return `<!doctype html>
64
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body>
65
+ <article><header><p>Imported privately from Canvas. Review rights before publication.</p><h1>${escapeHtml(title)}</h1>${url ? `<p>Canvas source: <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>` : ''}${rows ? `<dl>${rows}</dl>` : ''}</header>
66
+ ${sanitizeCanvasHtml(body) || '<p>No Canvas description was provided for this item.</p>'}
67
+ </article></body></html>
68
+ `
69
+ }
70
+
71
+ function markdownLinkRecord({ title, url, details = [] }) {
72
+ const facts = details.filter(([label, value]) => text(value)).map(([label, value]) => `- **${label}:** ${value}`).join('\n')
73
+ return `# ${title}\n\nCanvas reference: ${url}\n\n${facts ? `${facts}\n` : ''}\nThis is an external link reference. It was not fetched by Wicker Study.\n`
74
+ }
75
+
76
+ function markdownLinkIndex({ title, pageUrl, links }) {
77
+ const entries = links.map((link) => `- [${link.url}](${link.url})${link.kind === 'canvas-page' ? ' — Canvas page' : link.kind === 'canvas-file' ? ' — Canvas file' : ''}`).join('\n')
78
+ return `# Links from ${title}\n\nCanvas page: ${pageUrl}\n\n${entries || 'No links were found in this page.'}\n\nExternal references are recorded for review, not fetched. Canvas pages and files in this course are followed recursively when they can be accessed with the local Canvas account.\n`
79
+ }
80
+
81
+ function decodeSegment(value) {
82
+ try { return decodeURIComponent(value) } catch { return value }
83
+ }
84
+
85
+ function uniqueLinks(value, baseUrl, origin, courseId) {
86
+ const raw = String(value || '')
87
+ const candidates = [
88
+ ...raw.matchAll(/\b(?:href|src|data-api-endpoint)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi),
89
+ ...raw.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)
90
+ ].map((match) => match[1] || match[2] || match[3] || match[0]).filter(Boolean)
91
+ const links = []
92
+ const seen = new Set()
93
+ for (const candidate of candidates) {
94
+ let url
95
+ try { url = new URL(candidate, baseUrl) } catch { continue }
96
+ if (!['http:', 'https:'].includes(url.protocol)) continue
97
+ url.hash = ''
98
+ const key = url.toString()
99
+ if (seen.has(key)) continue
100
+ seen.add(key)
101
+ const pageMatch = url.origin === origin && url.pathname.match(new RegExp(`^/courses/${courseId}/pages/([^/]+)$`))
102
+ const fileMatch = url.origin === origin && url.pathname.match(new RegExp(`^/(?:courses/${courseId}/)?files/(\\d+)(?:/|$)`))
103
+ links.push({
104
+ url: key,
105
+ kind: pageMatch ? 'canvas-page' : fileMatch ? 'canvas-file' : 'external',
106
+ ...(pageMatch ? { pageSlug: decodeSegment(pageMatch[1]) } : {}),
107
+ ...(fileMatch ? { fileId: fileMatch[1] } : {})
108
+ })
109
+ }
110
+ return links
111
+ }
112
+
113
+ function pagePath(base, position, title, id, extension = '.html') {
114
+ return join(base, `${prefix(position)} ${safeSegment(title)}--${safeSegment(id, 'item')}${extension}`)
115
+ }
116
+
117
+ function nextPageUrl(link, origin) {
118
+ if (!link) return null
119
+ const match = String(link).match(/<([^>]+)>\s*;\s*rel="?next"?/i)
120
+ if (!match) return null
121
+ const url = new URL(match[1], origin)
122
+ if (url.origin !== origin) throw new CanvasCourseImportError('Canvas pagination pointed to another origin.')
123
+ return url
124
+ }
125
+
126
+ export function parseCanvasCourseUrl(value) {
127
+ let url
128
+ try { url = new URL(String(value)) } catch { throw new CanvasCourseImportError('Provide a valid Canvas course URL.') }
129
+ if (url.protocol !== 'https:') throw new CanvasCourseImportError('Canvas course URLs must use HTTPS.')
130
+ if (url.username || url.password) throw new CanvasCourseImportError('Do not put credentials in a Canvas course URL.')
131
+ const match = url.pathname.match(/^\/courses\/(\d+)(?:\/|$)/)
132
+ if (!match) throw new CanvasCourseImportError('Use a Canvas course URL such as https://canvas.example.edu/courses/123/modules.')
133
+ return { origin: url.origin, courseId: match[1], courseUrl: url.toString() }
134
+ }
135
+
136
+ export function parseCanvasOrigin(value) {
137
+ let url
138
+ try { url = new URL(String(value)) } catch { throw new CanvasCourseImportError('Provide a valid Canvas URL.') }
139
+ if (url.protocol !== 'https:') throw new CanvasCourseImportError('Canvas URLs must use HTTPS.')
140
+ if (url.username || url.password) throw new CanvasCourseImportError('Do not put credentials in a Canvas URL.')
141
+ return { origin: url.origin }
142
+ }
143
+
144
+ function initials(value) {
145
+ return String(value || '').match(/[\p{L}\p{N}]+/gu)?.map((word) => word[0]).join('').toLowerCase() || ''
146
+ }
147
+
148
+ function courseSearchText(course) {
149
+ return [course.name, course.courseCode, course.term?.name, initials(course.name), initials(course.term?.name)].filter(Boolean).join(' ').toLocaleLowerCase()
150
+ }
151
+
152
+ export function filterCanvasCourses(courses, query) {
153
+ const needle = text(query, 240).toLocaleLowerCase()
154
+ if (!needle) return [...courses]
155
+ return courses.filter((course) => courseSearchText(course).includes(needle))
156
+ }
157
+
158
+ export function canvasCourseFolderName(course) {
159
+ const period = course.term?.name || course.startAt?.slice(0, 10) || 'undated'
160
+ const identity = course.courseCode || course.name || 'course'
161
+ return `${safeSegment(period, 'undated')}--${safeSegment(identity, 'course')}--canvas-${safeSegment(course.id, 'course')}`
162
+ }
163
+
164
+ async function downloadResponseToFile(response, destinationPath, maxBytes) {
165
+ const declared = number(response.headers?.get?.('content-length'), 0)
166
+ if (declared > maxBytes) throw new CanvasCourseImportError(`Canvas file is larger than the ${Math.round(maxBytes / 1024 / 1024)} MB import limit.`)
167
+ const temporaryPath = `${destinationPath}.partial-${process.pid}-${Date.now()}`
168
+ let total = 0
169
+ await mkdir(dirname(destinationPath), { recursive: true })
170
+ try {
171
+ if (!response.body?.getReader) {
172
+ const bytes = Buffer.from(await response.arrayBuffer())
173
+ if (bytes.length > maxBytes) throw new CanvasCourseImportError(`Canvas file is larger than the ${Math.round(maxBytes / 1024 / 1024)} MB import limit.`)
174
+ await writeFile(temporaryPath, bytes)
175
+ total = bytes.length
176
+ } else {
177
+ const writer = await open(temporaryPath, 'w')
178
+ const reader = response.body.getReader()
179
+ try {
180
+ while (true) {
181
+ const { done, value } = await reader.read()
182
+ if (done) break
183
+ total += value.byteLength
184
+ if (total > maxBytes) throw new CanvasCourseImportError(`Canvas file is larger than the ${Math.round(maxBytes / 1024 / 1024)} MB import limit.`)
185
+ await writer.write(Buffer.from(value))
186
+ }
187
+ } finally {
188
+ reader.releaseLock?.()
189
+ await writer.close()
190
+ }
191
+ }
192
+ await rename(temporaryPath, destinationPath)
193
+ return total
194
+ } catch (error) {
195
+ await unlink(temporaryPath).catch(() => {})
196
+ throw error
197
+ }
198
+ }
199
+
200
+ export function createCanvasApi({ origin, accessToken, fetchImpl = fetch }) {
201
+ async function request(value, { accept = 'application/json' } = {}) {
202
+ const url = new URL(value, origin)
203
+ if (url.origin !== origin) throw new CanvasCourseImportError('Canvas API requests must stay on the supplied Canvas origin.')
204
+ let response
205
+ try {
206
+ response = await fetchImpl(url, { headers: { accept, authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(CANVAS_IMPORT_LIMITS.timeoutMs) })
207
+ } catch (error) {
208
+ throw new CanvasCourseImportError(`Canvas could not be reached: ${error.message}`)
209
+ }
210
+ if (!response.ok) {
211
+ if (response.status === 401) throw new CanvasCourseImportError(`Canvas returned HTTP 401 for ${url.pathname}. The importer sent the PAT correctly, but this Canvas host did not accept it. It may be expired, revoked, from another Canvas host, or Personal Access Token API access may be disabled by the institution.`)
212
+ if (response.status === 403) throw new CanvasCourseImportError(`Canvas returned HTTP 403 for ${url.pathname}. The account or institution denied this API request. This does not by itself mean the PAT is incorrect; verify that the same Canvas account can open this course and that API access is permitted.`)
213
+ if (response.status === 404) throw new CanvasCourseImportError(`Canvas returned HTTP 404 for ${url.pathname}. Confirm that the Modules URL belongs to a course available to the signed-in account.`)
214
+ throw new CanvasCourseImportError(`Canvas API request failed (HTTP ${response.status}) at ${url.pathname}.`)
215
+ }
216
+ return response
217
+ }
218
+
219
+ return {
220
+ async getJson(path) {
221
+ const response = await request(path)
222
+ try { return await response.json() } catch { throw new CanvasCourseImportError('Canvas returned an unreadable API response.') }
223
+ },
224
+ async getPaged(path) {
225
+ const values = []
226
+ let next = new URL(path, origin)
227
+ for (let page = 0; next; page++) {
228
+ if (page > 50) throw new CanvasCourseImportError('Canvas returned too many pagination pages.')
229
+ const response = await request(next)
230
+ let body
231
+ try { body = await response.json() } catch { throw new CanvasCourseImportError('Canvas returned an unreadable paginated response.') }
232
+ if (!Array.isArray(body)) throw new CanvasCourseImportError('Canvas returned an unexpected list response.')
233
+ values.push(...body)
234
+ next = nextPageUrl(response.headers?.get?.('link'), origin)
235
+ }
236
+ return values
237
+ },
238
+ async downloadToFile(url, destinationPath, maxBytes) {
239
+ let downloadUrl
240
+ try { downloadUrl = new URL(url) } catch { throw new CanvasCourseImportError('Canvas returned an invalid file download URL.') }
241
+ if (downloadUrl.protocol !== 'https:') throw new CanvasCourseImportError('Canvas returned a non-HTTPS file download URL.')
242
+ const tryDownload = async (authorization = false) => {
243
+ try {
244
+ return await fetchImpl(downloadUrl, { headers: { accept: 'application/octet-stream, */*;q=0.8', ...(authorization ? { authorization: `Bearer ${accessToken}` } : {}) }, signal: AbortSignal.timeout(CANVAS_IMPORT_LIMITS.downloadTimeoutMs) })
245
+ } catch (error) {
246
+ throw new CanvasCourseImportError(`Canvas file could not be downloaded: ${error.message}`)
247
+ }
248
+ }
249
+ let response = await tryDownload(false)
250
+ if (response.status === 401 && downloadUrl.origin === origin) response = await tryDownload(true)
251
+ if (!response.ok) throw new CanvasCourseImportError(`Canvas file download failed (${response.status}).`)
252
+ return downloadResponseToFile(response, destinationPath, maxBytes)
253
+ }
254
+ }
255
+ }
256
+
257
+ export async function listCanvasCourses({ canvasUrl, accessToken, fetchImpl = fetch } = {}) {
258
+ const canvas = parseCanvasOrigin(canvasUrl)
259
+ if (!text(accessToken, 20)) throw new CanvasCourseImportError('A Canvas Personal Access Token is required. Use the local macOS Keychain; never pass a password or OTP.')
260
+ const api = createCanvasApi({ origin: canvas.origin, accessToken: String(accessToken), fetchImpl })
261
+ const account = await api.getJson('/api/v1/users/self/profile')
262
+ const courses = await api.getPaged('/api/v1/users/self/courses?enrollment_state=all&include[]=term&include[]=enrollments&per_page=100')
263
+ return {
264
+ origin: canvas.origin,
265
+ account: { id: String(account.id || ''), name: text(account.name, 300) || null },
266
+ courses: courses.map((course) => ({
267
+ id: String(course.id || ''),
268
+ name: text(course.name, 300) || `Canvas course ${course.id}`,
269
+ courseCode: text(course.course_code, 160) || null,
270
+ workflowState: text(course.workflow_state, 80) || null,
271
+ startAt: course.start_at || null,
272
+ endAt: course.end_at || null,
273
+ term: course.term ? { id: String(course.term.id || ''), name: text(course.term.name, 300) || null, startAt: course.term.start_at || null, endAt: course.term.end_at || null } : null,
274
+ enrolments: Array.isArray(course.enrollments) ? course.enrollments.map((enrolment) => ({ type: text(enrolment.type, 100) || null, role: text(enrolment.role, 160) || null, state: text(enrolment.enrollment_state, 80) || null })) : [],
275
+ courseUrl: `${canvas.origin}/courses/${encodeURIComponent(course.id)}/modules`
276
+ })).filter((course) => course.id)
277
+ }
278
+ }
279
+
280
+ export async function listCanvasCourseModules({ courseUrl, accessToken, fetchImpl = fetch } = {}) {
281
+ const canvas = parseCanvasCourseUrl(courseUrl)
282
+ if (!text(accessToken, 20)) throw new CanvasCourseImportError('A Canvas Personal Access Token is required. Use the local macOS Keychain; never pass a password or OTP.')
283
+ const api = createCanvasApi({ origin: canvas.origin, accessToken: String(accessToken), fetchImpl })
284
+ await api.getJson('/api/v1/users/self/profile')
285
+ const [course, modules] = await Promise.all([
286
+ // Canvas exposes a course's rich-text syllabus separately from its Files
287
+ // index. Ask for it explicitly: many institutions place the assessment
288
+ // scheme here rather than in a module or PDF.
289
+ api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}?include[]=syllabus_body`),
290
+ api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules?include[]=items&per_page=100`)
291
+ ])
292
+ return {
293
+ origin: canvas.origin,
294
+ course: { id: String(course.id || canvas.courseId), name: text(course.name, 300) || `Canvas course ${canvas.courseId}`, courseCode: text(course.course_code, 160) || null, workflowState: text(course.workflow_state, 80) || null, courseUrl: canvas.courseUrl },
295
+ modules: modules.sort((left, right) => number(left.position) - number(right.position)).map((module) => ({
296
+ id: String(module.id || ''),
297
+ name: text(module.name, 300) || 'Untitled module',
298
+ position: number(module.position),
299
+ items: Array.isArray(module.items) ? module.items.map((item) => ({ id: String(item.id || ''), title: text(item.title, 300) || item.type || 'Untitled item', type: text(item.type, 80) || 'Unknown', indent: number(item.indent), contentId: item.content_id ? String(item.content_id) : null })) : []
300
+ })).filter((module) => module.id)
301
+ }
302
+ }
303
+
304
+ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder, moduleIds, maxResources = CANVAS_IMPORT_LIMITS.maxResources, maxFileBytes = CANVAS_IMPORT_LIMITS.maxFileBytes, fetchImpl = fetch } = {}) {
305
+ const canvas = parseCanvasCourseUrl(courseUrl)
306
+ if (!text(accessToken, 20)) throw new CanvasCourseImportError('A Canvas Personal Access Token is required. Use the local hidden prompt or a local environment variable; never pass a password or OTP to this importer.')
307
+ if (!outputFolder || !String(outputFolder).trim()) throw new CanvasCourseImportError('outputFolder is required and should be a dedicated local course folder.')
308
+ if (moduleIds !== undefined && (!Array.isArray(moduleIds) || !moduleIds.length || moduleIds.length > 500 || moduleIds.some((id) => !text(id, 200)))) throw new CanvasCourseImportError('moduleIds must be a non-empty array of up to 500 Canvas module identifiers.')
309
+ if (!Number.isInteger(maxResources) || maxResources < 1 || maxResources > CANVAS_IMPORT_LIMITS.maxResources) throw new CanvasCourseImportError(`maxResources must be between 1 and ${CANVAS_IMPORT_LIMITS.maxResources}.`)
310
+ if (!Number.isInteger(maxFileBytes) || maxFileBytes < 1 || maxFileBytes > CANVAS_IMPORT_LIMITS.maxFileBytes) throw new CanvasCourseImportError(`maxFileBytes must be between 1 byte and ${Math.round(CANVAS_IMPORT_LIMITS.maxFileBytes / 1024 / 1024)} MB.`)
311
+
312
+ const root = resolve(String(outputFolder))
313
+ await mkdir(root, { recursive: true })
314
+ const manifestPath = join(root, '.wicker-canvas-import.json')
315
+ const entries = await readdir(root)
316
+ let previousManifest = null
317
+ try {
318
+ previousManifest = JSON.parse(await readFile(manifestPath, 'utf8'))
319
+ } catch (error) {
320
+ if (error.code !== 'ENOENT') throw new CanvasCourseImportError('The existing Canvas import manifest could not be read. Choose a new folder or repair that manifest before importing again.')
321
+ }
322
+ const nonImportEntries = entries.filter((entry) => !['.DS_Store', '.wicker-canvas-import.json'].includes(entry))
323
+ if (nonImportEntries.length && !previousManifest) throw new CanvasCourseImportError('Choose a new empty output folder, or a folder created by an earlier Wicker Study Canvas import. This prevents overwriting unrelated files.')
324
+ const api = createCanvasApi({ origin: canvas.origin, accessToken: String(accessToken), fetchImpl })
325
+ // Verify authentication independently before checking course-specific access. This
326
+ // turns an opaque token error into a useful, non-sensitive diagnosis.
327
+ await api.getJson('/api/v1/users/self/profile')
328
+ const [course, modules] = await Promise.all([
329
+ // Canvas does not include the rich-text Syllabus page in a plain course
330
+ // response. Request it explicitly so an import matches what students see
331
+ // under the course's dedicated Syllabus navigation item.
332
+ api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}?include[]=syllabus_body`),
333
+ api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules?include[]=items&per_page=100`)
334
+ ])
335
+
336
+ const requestedModuleIds = moduleIds === undefined ? null : new Set(moduleIds.map((id) => String(id)))
337
+ const selectedModules = requestedModuleIds ? modules.filter((module) => requestedModuleIds.has(String(module.id))) : modules
338
+ if (requestedModuleIds) {
339
+ const missingModuleIds = [...requestedModuleIds].filter((id) => !selectedModules.some((module) => String(module.id) === id))
340
+ if (missingModuleIds.length) throw new CanvasCourseImportError('One or more selected Canvas modules were not found in this course.')
341
+ }
342
+
343
+ const courseName = text(course.name, 300) || `Canvas course ${canvas.courseId}`
344
+ const downloadedFileIds = new Map()
345
+ const records = []
346
+ const skipped = []
347
+ // Canvas installations can allow access to a course's Modules API while denying
348
+ // the course-wide Files index. The latter is a useful supplement, never a reason
349
+ // to discard accessible module material.
350
+ let courseFiles = []
351
+ try {
352
+ courseFiles = await api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/files?per_page=100`)
353
+ } catch (error) {
354
+ if (error instanceof CanvasCourseImportError && (
355
+ /HTTP (403|404) for \/api\/v1\/courses\/.+\/files/.test(error.message) ||
356
+ /Canvas denied access to \/api\/v1\/courses\/.+\/files/.test(error.message)
357
+ )) {
358
+ skipped.push({ label: 'Course-wide Files listing', reason: error.message })
359
+ } else {
360
+ throw error
361
+ }
362
+ }
363
+ let resourceCount = 0
364
+ const claimResource = (label) => {
365
+ if (resourceCount >= maxResources) { skipped.push({ label, reason: `import limit (${maxResources})` }); return false }
366
+ resourceCount++
367
+ return true
368
+ }
369
+ const write = async (path, contents) => {
370
+ await mkdir(resolve(path, '..'), { recursive: true })
371
+ await writeFile(path, contents)
372
+ }
373
+ const courseFileById = new Map(courseFiles.map((file) => [String(file.id), file]))
374
+ const importedPageSlugs = new Set()
375
+ const importedAssignmentIds = new Set()
376
+ const importedQuizIds = new Set()
377
+ const importedDiscussionIds = new Set()
378
+ let linkedPagePosition = 0
379
+ let linkedFilePosition = 0
380
+ let linkedIndexPosition = 0
381
+
382
+ async function importFile(fileId, base, position, source = {}) {
383
+ const id = String(fileId || '')
384
+ if (!id) { skipped.push({ label: source.title || 'Canvas file', reason: 'no file identifier' }); return null }
385
+ const existing = downloadedFileIds.get(id)
386
+ if (existing) {
387
+ const referencePath = pagePath(join(base, 'references'), position, source.title || existing.name, `file-${id}`, '.md')
388
+ const link = relative(dirname(referencePath), join(root, existing.relativePath)).split('\\').join('/').replaceAll(' ', '%20')
389
+ await write(referencePath, `# ${source.title || existing.name}\n\nThis Canvas file is already downloaded at [${existing.name}](${link}).\n`)
390
+ records.push({ kind: 'file-reference', id, source, path: referencePath.slice(root.length + 1), target: existing.relativePath })
391
+ return { ...existing, reused: true }
392
+ }
393
+ if (!claimResource(source.title || `Canvas file ${id}`)) return null
394
+ try {
395
+ const detail = courseFileById.get(id) || await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/files/${encodeURIComponent(id)}`)
396
+ const itemName = filename(detail.display_name || detail.filename || source.title || `file-${id}`)
397
+ const extension = extname(itemName) || '.bin'
398
+ const outputPath = pagePath(join(base, fileCategory(itemName)), position, itemName.replace(new RegExp(`${extension.replace('.', '\\.')}$`, 'i'), ''), `file-${id}`, extension)
399
+ const bytes = await api.downloadToFile(detail.url, outputPath, maxFileBytes)
400
+ const value = { id, name: itemName, relativePath: outputPath.slice(root.length + 1), bytes }
401
+ downloadedFileIds.set(id, value)
402
+ records.push({ kind: 'file', id, source, path: value.relativePath, bytes, mediaType: detail.content_type || null, canvasUrl: detail.url || null })
403
+ return value
404
+ } catch (error) {
405
+ skipped.push({ label: source.title || `Canvas file ${id}`, reason: error.message })
406
+ return null
407
+ }
408
+ }
409
+
410
+ async function importPage({ slug, base, position, title, source = {} }) {
411
+ const pageSlug = text(slug, 300)
412
+ if (!pageSlug) { skipped.push({ label: title || 'Canvas page', reason: 'no page URL' }); return null }
413
+ if (importedPageSlugs.has(pageSlug)) return null
414
+ if (!claimResource(title || 'Canvas page')) return null
415
+ // Register before following page links so a circular page graph cannot loop
416
+ // forever (a common pattern in Canvas navigation pages).
417
+ importedPageSlugs.add(pageSlug)
418
+ try {
419
+ const page = await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/pages/${encodeURIComponent(pageSlug)}`)
420
+ const pageTitle = page.title || title || 'Canvas page'
421
+ const pageUrl = `${canvas.origin}/courses/${canvas.courseId}/pages/${encodeURIComponent(pageSlug)}`
422
+ const outputPath = pagePath(base, position, pageTitle, `page-${pageSlug}`)
423
+ await write(outputPath, htmlRecord({ title: pageTitle, url: pageUrl, body: page.body, details: [['Module', source.moduleName], ['Published', page.published ? 'Yes' : 'No']] }))
424
+ const links = uniqueLinks(page.body, pageUrl, canvas.origin, canvas.courseId)
425
+ const relativePath = outputPath.slice(root.length + 1)
426
+ records.push({ kind: 'page', id: pageSlug, source, path: relativePath, canvasUrl: pageUrl, links })
427
+
428
+ await indexAndFollowLinks({ title: pageTitle, pageUrl, body: page.body, outputPath, source, id: `page-${pageSlug}`, links })
429
+ return outputPath
430
+ } catch (error) {
431
+ skipped.push({ label: title || 'Canvas page', reason: error.message })
432
+ return null
433
+ }
434
+ }
435
+
436
+ // Canvas descriptions frequently contain the actual handout trail: old
437
+ // exams on a page linked from an assignment, the syllabus attached from a
438
+ // quiz, or a later page with a revised rubric. Keep a small, reviewable URL
439
+ // index beside every rich-text record, follow only same-course pages/files,
440
+ // and never crawl a third-party site.
441
+ async function indexAndFollowLinks({ title, pageUrl, body, outputPath, source, id, links = null }) {
442
+ const resolvedLinks = links || uniqueLinks(body, pageUrl, canvas.origin, canvas.courseId)
443
+ if (!resolvedLinks.length) return
444
+ linkedIndexPosition++
445
+ const linksPath = pagePath(join(dirname(outputPath), 'link-index'), linkedIndexPosition, `${title} links`, `links-${id}`, '.md')
446
+ await write(linksPath, markdownLinkIndex({ title, pageUrl, links: resolvedLinks }))
447
+ records.push({ kind: 'link-index', id: `links-${id}`, source, path: linksPath.slice(root.length + 1), page: outputPath.slice(root.length + 1), links: resolvedLinks })
448
+ for (const link of resolvedLinks) {
449
+ if (link.kind === 'canvas-file') {
450
+ linkedFilePosition++
451
+ await importFile(link.fileId, join(root, 'linked-files'), linkedFilePosition, {
452
+ ...source,
453
+ itemId: `${source.itemId || id}-file-${link.fileId}`,
454
+ itemType: 'Canvas link',
455
+ title: `${title} linked file`
456
+ })
457
+ }
458
+ if (link.kind === 'canvas-page') {
459
+ linkedPagePosition++
460
+ await importPage({
461
+ slug: link.pageSlug,
462
+ base: join(root, 'linked-pages'),
463
+ position: linkedPagePosition,
464
+ title: `${title} linked page`,
465
+ source: { ...source, itemId: `${source.itemId || id}-page-${link.pageSlug}`, itemType: 'Canvas link', title, linkedFromPage: id }
466
+ })
467
+ }
468
+ }
469
+ }
470
+
471
+ async function importAssignment({ assignmentId, base, position, source, initial = null }) {
472
+ const id = String(assignmentId || '')
473
+ if (!id) { skipped.push({ label: source.title || 'Canvas assignment', reason: 'no assignment identifier' }); return null }
474
+ if (importedAssignmentIds.has(id)) return null
475
+ importedAssignmentIds.add(id)
476
+ if (!claimResource(source.title || `Canvas assignment ${id}`)) return null
477
+ try {
478
+ const assignment = initial && Object.hasOwn(initial, 'description') ? initial : await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/assignments/${encodeURIComponent(id)}`)
479
+ const title = assignment.name || source.title || 'Canvas assignment'
480
+ const outputPath = pagePath(join(base, 'assignments'), position, title, `assignment-${id}`)
481
+ await write(outputPath, htmlRecord({ title, url: assignment.html_url, body: assignment.description, details: [['Due', assignment.due_at], ['Unlocks', assignment.unlock_at], ['Available until', assignment.lock_at], ['Points possible', assignment.points_possible], ['Submission types', (assignment.submission_types || []).join(', ')], ['Grading type', assignment.grading_type]] }))
482
+ const links = uniqueLinks(assignment.description, assignment.html_url || `${canvas.origin}/courses/${canvas.courseId}/assignments/${id}`, canvas.origin, canvas.courseId)
483
+ records.push({ kind: 'assignment', id, source, path: outputPath.slice(root.length + 1), canvasUrl: assignment.html_url || null, links })
484
+ await indexAndFollowLinks({ title, pageUrl: assignment.html_url || `${canvas.origin}/courses/${canvas.courseId}/assignments/${id}`, body: assignment.description, outputPath, source, id: `assignment-${id}`, links })
485
+ return outputPath
486
+ } catch (error) {
487
+ skipped.push({ label: source.title || `Canvas assignment ${id}`, reason: error.message })
488
+ return null
489
+ }
490
+ }
491
+
492
+ async function importDiscussion({ discussionId, base, position, source, initial = null }) {
493
+ const id = String(discussionId || '')
494
+ if (!id) { skipped.push({ label: source.title || 'Canvas discussion', reason: 'no discussion identifier' }); return null }
495
+ if (importedDiscussionIds.has(id)) return null
496
+ importedDiscussionIds.add(id)
497
+ if (!claimResource(source.title || `Canvas discussion ${id}`)) return null
498
+ try {
499
+ const discussion = initial && Object.hasOwn(initial, 'message') ? initial : await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/discussion_topics/${encodeURIComponent(id)}`)
500
+ const title = discussion.title || source.title || 'Canvas discussion'
501
+ const outputPath = pagePath(join(base, 'discussions'), position, title, `discussion-${id}`)
502
+ await write(outputPath, htmlRecord({ title, url: discussion.html_url, body: discussion.message, details: [['Posted', discussion.posted_at], ['Discussion type', discussion.discussion_type], ['Due', discussion.delayed_post_at]] }))
503
+ const links = uniqueLinks(discussion.message, discussion.html_url || `${canvas.origin}/courses/${canvas.courseId}/discussion_topics/${id}`, canvas.origin, canvas.courseId)
504
+ records.push({ kind: 'discussion', id, source, path: outputPath.slice(root.length + 1), canvasUrl: discussion.html_url || null, links })
505
+ await indexAndFollowLinks({ title, pageUrl: discussion.html_url || `${canvas.origin}/courses/${canvas.courseId}/discussion_topics/${id}`, body: discussion.message, outputPath, source, id: `discussion-${id}`, links })
506
+ return outputPath
507
+ } catch (error) {
508
+ skipped.push({ label: source.title || `Canvas discussion ${id}`, reason: error.message })
509
+ return null
510
+ }
511
+ }
512
+
513
+ async function importQuiz({ quizId, base, position, source, initial = null }) {
514
+ const id = String(quizId || '')
515
+ if (!id) { skipped.push({ label: source.title || 'Canvas quiz', reason: 'no quiz identifier' }); return null }
516
+ if (importedQuizIds.has(id)) return null
517
+ importedQuizIds.add(id)
518
+ if (!claimResource(source.title || `Canvas quiz ${id}`)) return null
519
+ try {
520
+ const quiz = initial && Object.hasOwn(initial, 'description') ? initial : await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/quizzes/${encodeURIComponent(id)}`)
521
+ const title = quiz.title || source.title || 'Canvas quiz'
522
+ const outputPath = pagePath(join(base, 'assessments'), position, title, `quiz-${id}`)
523
+ await write(outputPath, htmlRecord({ title, url: quiz.html_url, body: quiz.description, details: [['Due', quiz.due_at], ['Unlocks', quiz.unlock_at], ['Available until', quiz.lock_at], ['Points possible', quiz.points_possible], ['Time limit', quiz.time_limit ? `${quiz.time_limit} minutes` : null], ['Allowed attempts', quiz.allowed_attempts]] }))
524
+ const links = uniqueLinks(quiz.description, quiz.html_url || `${canvas.origin}/courses/${canvas.courseId}/quizzes/${id}`, canvas.origin, canvas.courseId)
525
+ records.push({ kind: 'quiz', id, source, path: outputPath.slice(root.length + 1), canvasUrl: quiz.html_url || null, links })
526
+ await indexAndFollowLinks({ title, pageUrl: quiz.html_url || `${canvas.origin}/courses/${canvas.courseId}/quizzes/${id}`, body: quiz.description, outputPath, source, id: `quiz-${id}`, links })
527
+
528
+ // Question access differs by Canvas role and by whether a course uses
529
+ // New Quizzes. Capture the question bank when this account may read it;
530
+ // otherwise the quiz overview remains useful and the skip is explicit.
531
+ try {
532
+ const questions = await api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/quizzes/${encodeURIComponent(id)}/questions?per_page=100`)
533
+ if (questions.length && claimResource(`${title} question bank`)) {
534
+ const questionBody = questions.map((question, index) => `<section><h2>${escapeHtml(question.question_name || question.question_type || `Question ${index + 1}`)}</h2><dl><dt>Points possible</dt><dd>${escapeHtml(question.points_possible ?? '')}</dd></dl>${sanitizeCanvasHtml(question.question_text || question.question || '') || '<p>No question text was returned.</p>'}</section>`).join('\n')
535
+ const questionsPath = pagePath(join(dirname(outputPath), 'questions'), position, `${title} questions`, `quiz-${id}-questions`)
536
+ await write(questionsPath, htmlRecord({ title: `${title} — accessible questions`, url: quiz.html_url, body: questionBody, details: [['Questions returned', questions.length]] }))
537
+ records.push({ kind: 'quiz-questions', id: `quiz-${id}-questions`, source, path: questionsPath.slice(root.length + 1), quizId: id, count: questions.length })
538
+ }
539
+ } catch (error) {
540
+ skipped.push({ label: `${title} question bank`, reason: error.message })
541
+ }
542
+ return outputPath
543
+ } catch (error) {
544
+ skipped.push({ label: source.title || `Canvas quiz ${id}`, reason: error.message })
545
+ return null
546
+ }
547
+ }
548
+
549
+ async function importTextItem(item, moduleBase, module) {
550
+ const kind = String(item.type || '').toLowerCase()
551
+ const itemId = String(item.id || item.content_id || `${module?.id || 'course'}-${item.position || 0}`)
552
+ const source = { moduleId: module?.id || null, moduleName: module?.name || null, itemId, itemType: item.type || 'Unknown', title: text(item.title, 300) }
553
+ if (kind === 'file') return importFile(item.content_id || item.content_details?.content_id, moduleBase, item.position, source)
554
+ if (kind === 'subheader') return null
555
+ try {
556
+ if (kind === 'page') {
557
+ const slug = item.page_url || item.url?.split('/').pop()
558
+ return importPage({ slug, base: join(moduleBase, 'pages'), position: item.position, title: item.title, source })
559
+ }
560
+ if (kind === 'assignment') {
561
+ return importAssignment({ assignmentId: item.content_id, base: moduleBase, position: item.position, source })
562
+ }
563
+ if (kind === 'discussion') {
564
+ return importDiscussion({ discussionId: item.content_id, base: moduleBase, position: item.position, source })
565
+ }
566
+ if (kind === 'quiz') {
567
+ return importQuiz({ quizId: item.content_id, base: moduleBase, position: item.position, source })
568
+ }
569
+ if (!claimResource(source.title || item.type || 'Canvas item')) return null
570
+ if (kind === 'externalurl' || kind === 'externaltool') {
571
+ const url = item.external_url || item.html_url || item.url
572
+ if (!url) throw new CanvasCourseImportError('Canvas external item has no URL.')
573
+ const outputPath = pagePath(join(moduleBase, 'external-links'), item.position, item.title || 'External link', `link-${itemId}`, '.md')
574
+ await write(outputPath, markdownLinkRecord({ title: item.title || 'Canvas external link', url, details: [['Module', module?.name], ['Item type', item.type]] }))
575
+ records.push({ kind: 'external-link', id: itemId, source, path: outputPath.slice(root.length + 1), url })
576
+ return outputPath
577
+ }
578
+ const outputPath = pagePath(join(moduleBase, 'other'), item.position, item.title || item.type || 'Canvas item', `item-${itemId}`, '.md')
579
+ await write(outputPath, `# ${item.title || item.type || 'Canvas item'}\n\nCanvas item type: ${item.type || 'Unknown'}\n${item.html_url ? `\nCanvas URL: ${item.html_url}\n` : ''}`)
580
+ records.push({ kind: 'other', id: itemId, source, path: outputPath.slice(root.length + 1) })
581
+ return outputPath
582
+ } catch (error) {
583
+ skipped.push({ label: source.title || item.type || 'Canvas item', reason: error.message })
584
+ return null
585
+ }
586
+ }
587
+
588
+ for (const module of selectedModules.sort((left, right) => number(left.position) - number(right.position))) {
589
+ const moduleBase = join(root, 'modules', `${prefix(module.position)} ${safeSegment(module.name)}--module-${safeSegment(module.id)}`)
590
+ const hierarchy = []
591
+ for (const item of (Array.isArray(module.items) ? module.items : []).sort((left, right) => number(left.position) - number(right.position))) {
592
+ const indent = Math.min(12, Math.max(0, number(item.indent)))
593
+ hierarchy.length = indent
594
+ if (String(item.type || '').toLowerCase() === 'subheader') hierarchy[indent] = `${prefix(item.position)} ${safeSegment(item.title, 'section')}`
595
+ // Canvas is allowed to indent an item without supplying a preceding
596
+ // SubHeader. Omit those missing ancestors instead of passing `undefined`
597
+ // into path.join and aborting an otherwise valid course import.
598
+ const ancestors = hierarchy.filter(Boolean)
599
+ await importTextItem(item, ancestors.length ? join(moduleBase, ...ancestors) : moduleBase, module)
600
+ }
601
+ }
602
+
603
+ // A syllabus commonly sits outside Modules, and Canvas can leave due work
604
+ // ungrouped. Preserve that course-level context even when the learner chose
605
+ // only a handful of modules; it is what lets a later local agent distinguish
606
+ // an old assessment scheme from the current one.
607
+ const courseContextBase = join(root, 'course-information')
608
+ if (claimResource('Course overview and syllabus')) {
609
+ const syllabusUrl = course.syllabus_url || `${canvas.origin}/courses/${canvas.courseId}/assignments/syllabus`
610
+ const overviewPath = pagePath(courseContextBase, 1, course.syllabus_body ? 'Syllabus and course overview' : 'Course overview', `course-${canvas.courseId}`)
611
+ const overviewBody = course.syllabus_body || '<p>Canvas did not return a rich-text syllabus. Check the separately downloaded course files and the Canvas syllabus link.</p>'
612
+ await write(overviewPath, htmlRecord({ title: course.syllabus_body ? `${courseName} — syllabus` : `${courseName} — overview`, url: syllabusUrl, body: overviewBody, details: [['Course code', course.course_code], ['Workflow state', course.workflow_state], ['Starts', course.start_at], ['Ends', course.end_at], ['Public syllabus URL', course.public_syllabus ? syllabusUrl : null]] }))
613
+ const links = uniqueLinks(course.syllabus_body, syllabusUrl, canvas.origin, canvas.courseId)
614
+ records.push({ kind: course.syllabus_body ? 'syllabus' : 'course-overview', id: `course-${canvas.courseId}`, source: { moduleId: null, moduleName: null, itemId: `course-${canvas.courseId}`, itemType: 'Course', title: courseName }, path: overviewPath.slice(root.length + 1), canvasUrl: syllabusUrl, links })
615
+ await indexAndFollowLinks({ title: `${courseName} syllabus`, pageUrl: syllabusUrl, body: course.syllabus_body, outputPath: overviewPath, source: { moduleId: null, moduleName: null, itemId: `course-${canvas.courseId}`, itemType: 'Course', title: courseName }, id: `course-${canvas.courseId}`, links })
616
+ }
617
+
618
+ async function optionalCourseCollection(label, path) {
619
+ try {
620
+ return await api.getPaged(path)
621
+ } catch (error) {
622
+ // These endpoints are often deliberately restricted for students, while
623
+ // module items remain readable. The private snapshot should still finish
624
+ // and make the missing collection visible in its manifest.
625
+ skipped.push({ label, reason: error instanceof Error ? error.message : String(error) })
626
+ return []
627
+ }
628
+ }
629
+
630
+ const [courseAssignments, courseQuizzes, courseDiscussions] = await Promise.all([
631
+ optionalCourseCollection('Course-wide assignments listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/assignments?per_page=100`),
632
+ optionalCourseCollection('Course-wide quizzes listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/quizzes?per_page=100`),
633
+ optionalCourseCollection('Course-wide discussions listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/discussion_topics?per_page=100`)
634
+ ])
635
+ for (const [index, assignment] of courseAssignments.entries()) {
636
+ const id = String(assignment.id || '')
637
+ if (!id || importedAssignmentIds.has(id)) continue
638
+ await importAssignment({ assignmentId: id, base: join(root, 'course-assessments'), position: index + 1, source: { moduleId: null, moduleName: null, itemId: id, itemType: 'Course assignment', title: text(assignment.name, 300) }, initial: assignment })
639
+ }
640
+ for (const [index, quiz] of courseQuizzes.entries()) {
641
+ const id = String(quiz.id || '')
642
+ if (!id || importedQuizIds.has(id)) continue
643
+ await importQuiz({ quizId: id, base: join(root, 'course-assessments'), position: index + 1, source: { moduleId: null, moduleName: null, itemId: id, itemType: 'Course quiz', title: text(quiz.title, 300) }, initial: quiz })
644
+ }
645
+ for (const [index, discussion] of courseDiscussions.entries()) {
646
+ const id = String(discussion.id || '')
647
+ if (!id || importedDiscussionIds.has(id)) continue
648
+ await importDiscussion({ discussionId: id, base: join(root, 'course-communications'), position: index + 1, source: { moduleId: null, moduleName: null, itemId: id, itemType: 'Course discussion', title: text(discussion.title, 300) }, initial: discussion })
649
+ }
650
+
651
+ for (const [index, file] of courseFiles.entries()) {
652
+ if (downloadedFileIds.has(String(file.id))) continue
653
+ await importFile(file.id, join(root, 'unassigned-files'), index + 1, { moduleId: null, moduleName: null, itemId: String(file.id), itemType: 'File', title: file.display_name || file.filename })
654
+ }
655
+
656
+ const priorPaths = new Set(Array.isArray(previousManifest?.resources) ? previousManifest.resources.map((resource) => String(resource.path || '')).filter(Boolean) : [])
657
+ const currentPaths = new Set(records.map((resource) => String(resource.path || '')).filter(Boolean))
658
+ const staleLocalResources = requestedModuleIds ? [] : [...priorPaths].filter((path) => !currentPaths.has(path)).sort((left, right) => left.localeCompare(right, undefined, { numeric: true }))
659
+ const summary = {
660
+ schemaVersion: 2,
661
+ importedAt: new Date().toISOString(),
662
+ source: { origin: canvas.origin, courseId: canvas.courseId, courseUrl: canvas.courseUrl },
663
+ course: { id: String(course.id || canvas.courseId), name: courseName, code: text(course.course_code, 160), workflowState: text(course.workflow_state, 80) },
664
+ modules: selectedModules.map((module) => ({ id: String(module.id), name: text(module.name, 300), position: number(module.position), items: Array.isArray(module.items) ? module.items.length : 0 })),
665
+ selection: requestedModuleIds ? { moduleIds: [...requestedModuleIds] } : { moduleIds: null },
666
+ resources: records,
667
+ skipped,
668
+ staleLocalResources,
669
+ limits: { maxResources, maxFileBytes }
670
+ }
671
+ await writeFile(manifestPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8')
672
+ await writeFile(join(root, 'README.md'), `# ${courseName}\n\nImported privately from Canvas on ${summary.importedAt.slice(0, 10)}.\n\n- Canvas course: ${canvas.courseUrl}\n- Modules included: ${selectedModules.length}${requestedModuleIds ? ' (chosen subset)' : ''}\n- Resources written: ${records.length}\n- Resources skipped: ${skipped.length}\n- Previous imported paths no longer found: ${staleLocalResources.length}\n\nThe snapshot includes the Canvas rich-text syllabus when the account can read it, plus separately uploaded course files (including syllabus/course-manual files), module material, accessible course-wide assignments, quizzes, discussions, and question banks where Canvas permits question access. Canvas pages are followed recursively when they link to another page in this same course. File links in rich-text records are downloaded when accessible; every HTTP(S) reference is compiled into a nearby \`link-index\` file and the hidden manifest. External sites are recorded, never crawled.\n\nThis folder is a source snapshot. Keep it local until the administrator confirms they are authorised to submit the materials for editorial review. The hidden \`.wicker-canvas-import.json\` file records exactly what was found. Re-run the importer into this same folder to refresh changed or newly published Canvas material. Paths no longer returned by Canvas are listed in that manifest for review; they are never deleted automatically.\n`, 'utf8')
673
+
674
+ return {
675
+ root,
676
+ course: summary.course,
677
+ modules: selectedModules.length,
678
+ resources: records.length,
679
+ downloadedFiles: [...downloadedFileIds.values()].length,
680
+ skipped,
681
+ manifestPath,
682
+ staleLocalResources,
683
+ next: `Review the local README and hidden import manifest${staleLocalResources.length ? `, including ${staleLocalResources.length} prior path${staleLocalResources.length === 1 ? '' : 's'} no longer returned by Canvas` : ''}. When authorised, use admin_sync_course_folder to create a candidate editorial source set; review candidates before accepting, extracting, or publishing.`
684
+ }
685
+ }