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.
- package/README.md +78 -0
- package/authorize.mjs +132 -0
- package/config.mjs +109 -0
- package/package.json +35 -0
- package/scripts/macos-keychain.swift +79 -0
- package/server.mjs +621 -0
- package/vendor/canvas-course-export.mjs +60 -0
- package/vendor/canvas-course-import.mjs +685 -0
- package/vendor/local-canvas-prompts.mjs +214 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
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
|
+
}
|