wicker-study-mcp 2.11.0 → 2.12.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 +32 -7
- package/original-download.mjs +50 -0
- package/package.json +2 -1
- package/server.mjs +8 -3
- package/study-tools.mjs +2 -2
- package/vendor/canvas-course-import.mjs +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ the MCP server.
|
|
|
17
17
|
For a manually supplied key, the same secure bootstrap is available as:
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
WICKER_STUDY_URL='https://study.wicker.life' WICKER_STUDY_API_KEY='wsk_…' npx -y wicker-study-mcp@2.
|
|
20
|
+
WICKER_STUDY_URL='https://study.wicker.life' WICKER_STUDY_API_KEY='wsk_…' npx -y wicker-study-mcp@2.12.0 configure
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
```jsonc
|
|
@@ -100,7 +100,13 @@ authoritative list of endpoints and scopes.
|
|
|
100
100
|
|
|
101
101
|
Direct context reads do not call the model. `tutor_ask` uses the student's AI allowance and can prepare attendance changes, assignment/catch-up trackers, group milestones, focused practice, diagnostics and rubric-based draft reviews. Reuse conversation IDs. Approve only the exact proposal the student reviewed; receipts prevent double application. No tool sends email or submits assignments to Canvas. Personal completion is separate from Canvas submission status.
|
|
102
102
|
|
|
103
|
-
Update an installed client to `wicker-study-mcp@2.
|
|
103
|
+
Update an installed client to `wicker-study-mcp@2.12.0` and restart its MCP connection to discover the new tools. The companion skill is served at [SKILL.md](https://study.wicker.life/skills/wicker-study/SKILL.md); re-download it to update an existing copy.
|
|
104
|
+
|
|
105
|
+
### Complete original downloads and remembered context
|
|
106
|
+
|
|
107
|
+
Use `canvas_course_materials` to choose an exact asset/course/year, then call `download_course_original({assetId, courseCode, academicYear, outputFolder})`. The MCP streams the full stored original to a new private local subfolder and returns its path, size and SHA-256 only after verifying the complete file. PDFs, slide decks, images, spreadsheets and archives retain their original bytes. Existing files are never overwritten. Failed or incomplete downloads are removed. The maximum is 1 GB per file; larger files remain available through the authenticated web download. A remote MCP's filesystem may not be accessible to its client. This tool uses read scope and never scrapes Canvas or sends file bytes into the chat.
|
|
108
|
+
|
|
109
|
+
The companion skill now proactively notices lasting preferences, project decisions, constraints and availability during normal discussions. It reads `tutor_sources` to avoid duplicates, prepares the exact new context, and asks for confirmation before saving with `tutor_confirm_update`. The agent reports a successful receipt instead of treating a draft or an ordinary chat reply as saved memory. Transient remarks and speculation are not stored, and tasks/attendance continue using their dedicated workflows.
|
|
104
110
|
|
|
105
111
|
## Licence
|
|
106
112
|
|
|
@@ -146,20 +152,39 @@ Students can follow reports and withdraw evidence at `/app/feedback`; authorized
|
|
|
146
152
|
|
|
147
153
|
Contact sharing is optional per report: use `shareContactEmail:true` only when the student chooses it and show the returned address in the preview. `feedback_withdraw_contact` stops sharing it after fresh confirmation. Reports show receipt, investigation and completion updates with public comments; AI-assisted replies are labeled and reviewed by the team.
|
|
148
154
|
|
|
149
|
-
|
|
155
|
+
## Updating an existing installation
|
|
156
|
+
|
|
157
|
+
The in-app guide at [Docs → Update your MCP](https://study.wicker.life/app/docs#update) has copyable commands for both clients. For the standard installation, reuse your saved credentials; do not generate a new API key or run `configure` again.
|
|
150
158
|
|
|
151
|
-
|
|
159
|
+
### Codex
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
codex mcp remove wicker-study
|
|
163
|
+
codex mcp add wicker-study -- npx -y wicker-study-mcp@2.12.0
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Quit and reopen the Codex app, restart the CLI session, or reload the IDE extension window so the local MCP process restarts.
|
|
167
|
+
|
|
168
|
+
### Claude Code
|
|
152
169
|
|
|
153
170
|
```sh
|
|
154
171
|
claude mcp remove --scope user wicker-study
|
|
155
|
-
claude mcp add --scope user wicker-study -- npx -y wicker-study-mcp@2.
|
|
172
|
+
claude mcp add --scope user wicker-study -- npx -y wicker-study-mcp@2.12.0
|
|
156
173
|
```
|
|
157
174
|
|
|
158
|
-
|
|
175
|
+
Exit and restart Claude Code, then check `/mcp`. `claude mcp add` does not overwrite an existing registration. If you originally installed with `project` or `local` scope, use that same scope in both commands.
|
|
176
|
+
|
|
177
|
+
### Claude Desktop and custom configurations
|
|
178
|
+
|
|
179
|
+
In Claude Desktop, use Settings → Developer → Edit Config. Change only the package argument in the existing `wicker-study` entry to `wicker-study-mcp@2.12.0`, preserve its other settings, then fully quit and reopen Claude Desktop. For custom Codex/Claude Code configurations with environment variables or a server URL, update the package argument in place instead of replacing the registration. Check project overrides if an older version still loads.
|
|
180
|
+
|
|
181
|
+
The standard helper credentials stay in `~/.config/wicker-study/config.json`. These registration commands do not delete that file. After restarting, ask the agent to call `wicker_status` and `study_generation_contract` to verify connectivity and availability of the new tools. Replace any installed [companion skill](https://study.wicker.life/skills/wicker-study/SKILL.md) in its existing location too.
|
|
182
|
+
|
|
183
|
+
Official client references: [Codex MCP configuration](https://developers.openai.com/codex/mcp), [Claude Code MCP configuration](https://code.claude.com/docs/en/mcp).
|
|
159
184
|
|
|
160
185
|
## Local study generation
|
|
161
186
|
|
|
162
|
-
The 2.
|
|
187
|
+
The 2.12.0 source adds `study_generation_contract`, `study_generation_sources`, `study_generation_start`, `study_generation_next`, `study_generation_submit`, `study_generation_refresh`, `study_generation_stop`, and `study_generation_add_notes`. Install `wicker-study-mcp@2.12.0` using the update instructions above.
|
|
163
188
|
|
|
164
189
|
1. Read the current contract and list the course edition’s sources. If needed, download originals with `canvas_import_remote_course`; inspect graphics and preserve page numbers. Add supplementary extraction as explicitly labelled local notes.
|
|
165
190
|
2. Start the student-requested private run with its exact source selection, or continue a version prepared in the web UI.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Stream to a private, unique directory. Never replace an existing user file or
|
|
6
|
+
// expose a partial download as a successful original.
|
|
7
|
+
export async function downloadCourseOriginal({ assetId, courseCode, academicYear, outputFolder }, { api, apiResponse }) {
|
|
8
|
+
const inventory = await api('/api/corpus/materials', { query: { courseCode, academicYear } })
|
|
9
|
+
const material = inventory.materials?.find(item => item.assetId === assetId)
|
|
10
|
+
if (!material) throw new Error('This original is not available in the selected course edition. Read canvas_course_materials again.')
|
|
11
|
+
const size = Number(material.byteSize)
|
|
12
|
+
if (!Number.isSafeInteger(size) || size < 0 || !/^[a-f0-9]{64}$/i.test(material.sha256 || '')) throw new Error('The original has no verifiable size or checksum. Refresh its material record before downloading.')
|
|
13
|
+
if (size > 1024 ** 3) throw new Error('This original exceeds the 1 GB local download limit. Use its authenticated web download instead.')
|
|
14
|
+
const response = await apiResponse(`/api/corpus/assets/${encodeURIComponent(assetId)}`, { query: { download: '1' }, timeoutMs: 300000, redirect: 'error' })
|
|
15
|
+
if (response.status !== 200 || !response.body) {
|
|
16
|
+
await response.body?.cancel()
|
|
17
|
+
throw new Error('The server did not return a complete original file. Retry the download.')
|
|
18
|
+
}
|
|
19
|
+
const root = resolve(outputFolder)
|
|
20
|
+
await mkdir(root, { recursive: true, mode: 0o700 })
|
|
21
|
+
const folder = await mkdtemp(join(root, 'wicker-original-'))
|
|
22
|
+
const filename = String(material.filename || assetId).split(/[\\/]/).pop().replace(/[\x00-\x1f\x7f]/g, '_').slice(0, 200)
|
|
23
|
+
const path = join(folder, !filename || /^\.+$/.test(filename) ? 'original' : filename)
|
|
24
|
+
let file
|
|
25
|
+
try {
|
|
26
|
+
file = await open(path, 'wx', 0o600)
|
|
27
|
+
const hash = createHash('sha256')
|
|
28
|
+
let byteSize = 0
|
|
29
|
+
for await (const chunk of response.body) {
|
|
30
|
+
byteSize += chunk.byteLength
|
|
31
|
+
if (byteSize > size) throw new Error('The downloaded original does not match its recorded size.')
|
|
32
|
+
hash.update(chunk)
|
|
33
|
+
// FileHandle.write may make a partial write; retain every byte.
|
|
34
|
+
let offset = 0
|
|
35
|
+
while (offset < chunk.byteLength) {
|
|
36
|
+
const { bytesWritten } = await file.write(chunk, offset, chunk.byteLength - offset)
|
|
37
|
+
if (!bytesWritten) throw new Error('The original could not be written completely.')
|
|
38
|
+
offset += bytesWritten
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const sha256 = hash.digest('hex')
|
|
42
|
+
if (byteSize !== size || sha256 !== material.sha256.toLowerCase()) throw new Error('The downloaded original failed its size or checksum check. Retry; no partial file was retained.')
|
|
43
|
+
await file.close(); file = null
|
|
44
|
+
return { path, filename: material.filename, assetId, courseCode: material.courseCode, academicYear: material.academicYear, mediaType: material.mediaType, byteSize, sha256, complete: true, location: 'Local filesystem of this MCP server. The complete original bytes are saved; this is not extracted text.' }
|
|
45
|
+
} catch (error) {
|
|
46
|
+
await file?.close().catch(() => {})
|
|
47
|
+
await rm(folder, { recursive: true, force: true })
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicker-study-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "MCP server for Wicker Study: read course material and a student's academic record, study on their behalf, collect a private Canvas course snapshot, and \u2014 with an admin key \u2014 run the editorial workflow.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"server.mjs",
|
|
32
32
|
"study-tools.mjs",
|
|
33
|
+
"original-download.mjs",
|
|
33
34
|
"feedback-tools.mjs",
|
|
34
35
|
"write-confirmation.mjs",
|
|
35
36
|
"config.mjs",
|
package/server.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { registerFeedbackTools } from './feedback-tools.mjs'
|
|
3
3
|
import { installWriteConfirmation, toolRequestContext } from './write-confirmation.mjs'
|
|
4
4
|
import { registerStudyTools } from './study-tools.mjs'
|
|
5
|
+
import { downloadCourseOriginal } from './original-download.mjs'
|
|
5
6
|
// Wicker Study MCP server — a thin stdio wrapper over the HTTP API so agents
|
|
6
7
|
// (Claude Desktop, Claude Code, Codex, Cursor, …) can read course material and
|
|
7
8
|
// a student's record, record study activity, collect a private Canvas course
|
|
@@ -60,13 +61,14 @@ function requireKey() {
|
|
|
60
61
|
return credential.apiKey
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
async function apiResponse(path, { method = 'GET', body, query, timeoutMs } = {}) {
|
|
64
|
+
async function apiResponse(path, { method = 'GET', body, query, timeoutMs, redirect } = {}) {
|
|
64
65
|
const url = new URL(baseUrl + path)
|
|
65
66
|
for (const [key, value] of Object.entries(query || {})) if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value))
|
|
66
67
|
const response = await fetch(url, {
|
|
67
68
|
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
68
69
|
method,
|
|
69
|
-
|
|
70
|
+
...(redirect ? { redirect } : {}),
|
|
71
|
+
headers: { authorization: `Bearer ${requireKey()}`, accept: 'application/json', 'x-wicker-client': 'wicker-study-mcp 2.12.0', ...(toolRequestContext.getStore() ? { 'x-wicker-tool': toolRequestContext.getStore().tool, 'x-wicker-confirmed': String(toolRequestContext.getStore().confirmed) } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
70
72
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
71
73
|
})
|
|
72
74
|
if (!response.ok) {
|
|
@@ -93,12 +95,15 @@ const json = (value) => ({ content: [{ type: 'text', text: typeof value === 'str
|
|
|
93
95
|
const failed = (error) => ({ isError: true, content: [{ type: 'text', text: error.message }] })
|
|
94
96
|
const run = (fn) => async (args) => { try { return json(await fn(args)) } catch (error) { return failed(error) } }
|
|
95
97
|
|
|
96
|
-
const server = new McpServer({ name: 'wicker-study', version: '2.
|
|
98
|
+
const server = new McpServer({ name: 'wicker-study', version: '2.12.0' })
|
|
97
99
|
installWriteConfirmation(server, z)
|
|
98
100
|
const courseId = z.string().describe('Course id (e.g. "sec"). Use list_courses to discover ids.')
|
|
99
101
|
const chapterId = z.string().describe('Chapter id (e.g. "02").')
|
|
100
102
|
registerFeedbackTools(server, { z, run, api })
|
|
101
103
|
registerStudyTools(server, { z, run, api, defaultCanvasUrl: DEFAULT_CANVAS_URL })
|
|
104
|
+
server.tool('download_course_original', 'Download one complete original material file to the local filesystem of this MCP server, with size and SHA-256 verification. First use canvas_course_materials to choose its exact assetId and course/year. Use for PDFs, slide graphics, tables, datasets or any full-file analysis beyond indexed passages. Saves unchanged bytes in a new private subfolder of outputFolder; never overwrites files, executes content, or exposes credentials. This is a read of the platform, not a scrape or context update.', {
|
|
105
|
+
assetId: z.string().min(1).max(160), courseCode: z.string().min(1).max(40), academicYear: z.string().max(20).optional(), outputFolder: z.string().min(1).max(2000)
|
|
106
|
+
}, run(args => downloadCourseOriginal(args, { api, apiResponse })))
|
|
102
107
|
|
|
103
108
|
const COURSE_SOURCE_EXTENSIONS = new Set(['.pdf', '.ppt', '.pptx', '.doc', '.docx', '.txt', '.md', '.csv', '.tex', '.m', '.py', '.r', '.html', '.htm', '.png', '.jpg', '.jpeg', '.webp'])
|
|
104
109
|
const SOURCE_MIME = { '.pdf': 'application/pdf', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.tex': 'text/x-tex', '.m': 'text/x-matlab', '.py': 'text/x-python', '.r': 'text/x-r', '.html': 'text/html', '.htm': 'text/html', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }
|
package/study-tools.mjs
CHANGED
|
@@ -19,14 +19,14 @@ export function registerStudyTools(server, { z, run, api, defaultCanvasUrl }) {
|
|
|
19
19
|
tool('study_generation_stop', 'Pause the student’s local run. Completed steps and published-to-self revisions are preserved; an outstanding local result can no longer be submitted.', { versionId: id }, ({versionId}) => api(`/api/study-versions/${encodeURIComponent(versionId)}/local/stop`, { method: 'POST', body: {} }))
|
|
20
20
|
tool('study_generation_add_notes', 'Save student-authorised local extraction or notes as a clearly labelled private source. Preserve original page numbers and identify source filenames in text. Describe relevant graphs, tables, diagrams and relationships accurately; distinguish inference from source facts. Does not alter the original Canvas document. Use its returned id in sourceKeys.', { ...courseIdentity, title: z.string().max(150), pages: z.array(z.object({page: z.number().int().positive().nullable(), text: z.string().max(300000)})).min(1).max(500), confirmed: z.literal(true) }, body => api('/api/study-versions/local/notes', { method: 'POST', body }))
|
|
21
21
|
tool('read_course_source', 'Read 12 indexed passages from an authorised Canvas document returned by search_course. Follow nextOffset to read beyond the snippet, including later paper-list pages. Retains edition, path and page citations; never widens source access.', { assetId: id, courseCode: z.string().min(1).max(40), offset: z.number().int().min(0).max(100000).optional() }, args => api('/api/retrieve/source', { query: args }))
|
|
22
|
-
tool('canvas_course_materials', 'List indexed originals and source editions for one course. Select an exact academic year for a retake; use read_course_source for extracted text.', { courseCode: z.string().min(1).max(40), academicYear: z.string().max(20).optional() }, args => api('/api/corpus/materials', { query: args }))
|
|
22
|
+
tool('canvas_course_materials', 'List indexed originals and source editions for one course. Select an exact academic year for a retake; use read_course_source for extracted text or download_course_original for the complete unchanged file.', { courseCode: z.string().min(1).max(40), academicYear: z.string().max(20).optional() }, args => api('/api/corpus/materials', { query: args }))
|
|
23
23
|
tool('canvas_groups', 'Read your Canvas group memberships across courses and global groups. Filter by courseCode and academicYear to keep retake teams separate. Pass a groupId from these memberships to read teammates; include its canvasUrl if several Canvas hosts are connected. A failed roster is unknown, not an empty team. Read-only: never joins or changes groups.', {canvasUrl,courseCode:code,academicYear:z.string().max(20).optional(),scope:z.enum(['all','course','global']).optional(),groupId:z.string().regex(/^\d{1,20}$/).optional(),refresh:z.boolean().optional()}, ({refresh,...args}) => api('/api/integrations/canvas/groups',{query:{...args,refresh:refresh?'1':undefined}}))
|
|
24
24
|
tool('canvas_assignment_detail', 'Read the full assignment brief, due/unlock/lock dates, own submission status, grade, rubric and submission comments. Use Canvas numeric course and assignment IDs from canvas_updates. Direct the student to /app/updates?tab=assignments&assignment=COURSE_ID%3AASSIGNMENT_ID; Canvas remains a secondary link.', { canvasUrl, courseId: z.string().regex(/^\d+$/), assignmentId: z.string().regex(/^\d+$/), refresh: z.boolean().optional() }, ({ refresh, ...args }) => api('/api/integrations/canvas/assignment', { query: { ...args, canvasUrl: args.canvasUrl || defaultCanvasUrl, refresh: refresh ? '1' : undefined } }))
|
|
25
25
|
tool('canvas_sync_logs', 'Inspect durable progress for one sync job, optionally filtered by stage or severity. before is the next cursor returned by an earlier page. Last progress and worker checkpoints are distinct; do not claim a task is advancing from its heartbeat alone.', { job: id, before: z.string().max(160).optional(), stage: z.string().max(40).optional(), level: z.enum(['info','warning','error']).optional() }, args => api('/api/account/integrations/canvas/corpus/logs', { query: args }))
|
|
26
26
|
tool('canvas_sync_control', 'Stop or retry one owned course-edition sync, after the student requests that action. Retry resumes saved originals and unfinished stages; stop fences the running worker and pauses future material refreshes for that edition.', { jobId: id, action: z.enum(['stop','retry']) }, ({ jobId, action }) => api(`/api/integrations/canvas/corpus/jobs/${encodeURIComponent(jobId)}`, { method: 'POST', body: { action } }))
|
|
27
27
|
tool('canvas_sync_course', 'Queue a specific accessible Canvas course edition. Current-period latest editions refresh automatically; use this for a missing or historical edition or an explicit refresh. Existing material consent is required; this cannot grant consent. force:false reuses verified unchanged resources.', { canvasUrl, canvasCourseId: z.string().regex(/^\d+$/), force: z.boolean().default(false) }, args => api('/api/integrations/canvas/corpus/course', { method: 'POST', body: { ...args, canvasUrl: args.canvasUrl || defaultCanvasUrl } }))
|
|
28
28
|
tool('tutor_prepare_attendance_update', 'Prepare an exact attendance change without applying it or calling a model. First read get_attendance, then use its exact session IDs and the student’s explicit report. Show the returned proposal detail and ask for confirmation before tutor_confirm_update.', { courseCode: code, from: date, to: date, eventIds: z.array(id).min(1).max(20), status: z.enum(['attended','missed','unknown']), note: z.string().max(500).optional() }, body => api('/api/tutor/updates/prepare', { method: 'POST', body: { ...body, kind: 'attendance' } }))
|
|
29
|
-
tool('tutor_prepare_context', '
|
|
29
|
+
tool('tutor_prepare_context', 'Proactively prepare lasting student preferences, project decisions/roles, constraints or availability learned during a conversation; do not wait for a reminder to save context. Read tutor_sources first to avoid duplicates or detect corrections. This prepares a draft, not saved memory, and calls no model. Preserve student-provided facts; dates bound temporary context. Availability is not proof of absence. Show exact wording/dates/weekdays and obtain confirmation before tutor_confirm_update.', { kind: z.enum(['preference','availability','context']), text: z.string().min(1).max(400), weekdays: z.array(z.enum(['monday','tuesday','wednesday','thursday','friday','saturday','sunday'])).max(7).optional(), startDate: date, endDate: date }, body => api('/api/tutor/updates/prepare', { method: 'POST', body }))
|
|
30
30
|
tool('tutor_confirm_update', 'Apply the exact prepared attendance/context update only after the student explicitly confirms this individual write. Cannot edit the prepared payload. Review expires after 30 minutes; retries return the same receipt. Context becomes visible to future Tutor conversations in this account/programme.', { updateId: id, confirmed: z.literal(true) }, body => api('/api/tutor/updates/confirm', { method: 'POST', body }))
|
|
31
31
|
tool('tutor_forget_context', 'Remove one remembered fact/preference/availability item after explicit confirmation. Read tutor_sources first to identify its exact memory ID.', { memoryId: id, confirmed: z.literal(true) }, ({ memoryId }) => api(`/api/tutor/memory/${encodeURIComponent(memoryId)}`, { method: 'DELETE' }))
|
|
32
32
|
tool('tutor_history', 'List saved Tutor conversations without loading every transcript. Reuse the relevant conversation for follow-ups.', {}, () => api('/api/tutor', { query: { view: 'history' } }))
|
|
@@ -93,7 +93,7 @@ export function sanitizeCanvasHtml(value) {
|
|
|
93
93
|
.replace(/\s+(href|src)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, '')
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function htmlRecord({ title, url, body, details = [] }) {
|
|
96
|
+
export function htmlRecord({ title, url, body, details = [] }) {
|
|
97
97
|
const rows = details.filter(([label, value]) => text(value)).map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join('')
|
|
98
98
|
return `<!doctype html>
|
|
99
99
|
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body>
|
|
@@ -622,7 +622,7 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
|
|
|
622
622
|
const discussion = initial && Object.hasOwn(initial, 'message') ? initial : await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/discussion_topics/${encodeURIComponent(id)}`)
|
|
623
623
|
const title = discussion.title || source.title || 'Canvas discussion'
|
|
624
624
|
const outputPath = pagePath(join(base, 'discussions'), position, title, `discussion-${id}`)
|
|
625
|
-
await write(outputPath, htmlRecord({ title, url: discussion.html_url, body: discussion.message, details: [['Posted', discussion.posted_at], ['Discussion type', discussion.discussion_type], ['
|
|
625
|
+
await write(outputPath, htmlRecord({ title, url: discussion.html_url, body: discussion.message, details: [['Posted', discussion.posted_at], ['Discussion type', discussion.discussion_type], ['Scheduled publication', discussion.delayed_post_at]] }))
|
|
626
626
|
const links = uniqueLinks(discussion.message, discussion.html_url || `${canvas.origin}/courses/${canvas.courseId}/discussion_topics/${id}`, canvas.origin, canvas.courseId)
|
|
627
627
|
records.push({ kind: 'discussion', id, source, path: outputPath.slice(root.length + 1), canvasUrl: discussion.html_url || null, links })
|
|
628
628
|
await indexAndFollowLinks({ title, pageUrl: discussion.html_url || `${canvas.origin}/courses/${canvas.courseId}/discussion_topics/${id}`, body: discussion.message, outputPath, source, id: `discussion-${id}`, links })
|