@falkenslab/moodle-driver 0.0.0-stage → 0.3.1

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.
Files changed (297) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/dist/areas/activities/build.d.ts +98 -0
  4. package/dist/areas/activities/build.js +246 -0
  5. package/dist/areas/activities/build.js.map +1 -0
  6. package/dist/areas/activities/content.d.ts +53 -0
  7. package/dist/areas/activities/content.js +113 -0
  8. package/dist/areas/activities/content.js.map +1 -0
  9. package/dist/areas/assignments/grader.d.ts +68 -0
  10. package/dist/areas/assignments/grader.js +169 -0
  11. package/dist/areas/assignments/grader.js.map +1 -0
  12. package/dist/areas/assignments/grading.d.ts +86 -0
  13. package/dist/areas/assignments/grading.js +162 -0
  14. package/dist/areas/assignments/grading.js.map +1 -0
  15. package/dist/areas/assignments/student.d.ts +57 -0
  16. package/dist/areas/assignments/student.js +115 -0
  17. package/dist/areas/assignments/student.js.map +1 -0
  18. package/dist/areas/calendar/calendar.d.ts +43 -0
  19. package/dist/areas/calendar/calendar.js +84 -0
  20. package/dist/areas/calendar/calendar.js.map +1 -0
  21. package/dist/areas/completion/completion.d.ts +50 -0
  22. package/dist/areas/completion/completion.js +112 -0
  23. package/dist/areas/completion/completion.js.map +1 -0
  24. package/dist/areas/courses/list.d.ts +59 -0
  25. package/dist/areas/courses/list.js +74 -0
  26. package/dist/areas/courses/list.js.map +1 -0
  27. package/dist/areas/courses/overview.d.ts +83 -0
  28. package/dist/areas/courses/overview.js +137 -0
  29. package/dist/areas/courses/overview.js.map +1 -0
  30. package/dist/areas/courses/state.d.ts +51 -0
  31. package/dist/areas/courses/state.js +45 -0
  32. package/dist/areas/courses/state.js.map +1 -0
  33. package/dist/areas/courses/structure.d.ts +89 -0
  34. package/dist/areas/courses/structure.js +104 -0
  35. package/dist/areas/courses/structure.js.map +1 -0
  36. package/dist/areas/forums/discussions.d.ts +57 -0
  37. package/dist/areas/forums/discussions.js +119 -0
  38. package/dist/areas/forums/discussions.js.map +1 -0
  39. package/dist/areas/forums/posts.d.ts +83 -0
  40. package/dist/areas/forums/posts.js +114 -0
  41. package/dist/areas/forums/posts.js.map +1 -0
  42. package/dist/areas/grades/gradebook.d.ts +104 -0
  43. package/dist/areas/grades/gradebook.js +250 -0
  44. package/dist/areas/grades/gradebook.js.map +1 -0
  45. package/dist/areas/grades/user-report.d.ts +26 -0
  46. package/dist/areas/grades/user-report.js +71 -0
  47. package/dist/areas/grades/user-report.js.map +1 -0
  48. package/dist/areas/h5p/h5p.d.ts +33 -0
  49. package/dist/areas/h5p/h5p.js +104 -0
  50. package/dist/areas/h5p/h5p.js.map +1 -0
  51. package/dist/areas/lessons/lessons.d.ts +60 -0
  52. package/dist/areas/lessons/lessons.js +145 -0
  53. package/dist/areas/lessons/lessons.js.map +1 -0
  54. package/dist/areas/messages/messages.d.ts +68 -0
  55. package/dist/areas/messages/messages.js +84 -0
  56. package/dist/areas/messages/messages.js.map +1 -0
  57. package/dist/areas/participants/participants.d.ts +57 -0
  58. package/dist/areas/participants/participants.js +135 -0
  59. package/dist/areas/participants/participants.js.map +1 -0
  60. package/dist/areas/questions/banks.d.ts +83 -0
  61. package/dist/areas/questions/banks.js +239 -0
  62. package/dist/areas/questions/banks.js.map +1 -0
  63. package/dist/areas/quizzes/attempts.d.ts +123 -0
  64. package/dist/areas/quizzes/attempts.js +300 -0
  65. package/dist/areas/quizzes/attempts.js.map +1 -0
  66. package/dist/areas/quizzes/results.d.ts +36 -0
  67. package/dist/areas/quizzes/results.js +93 -0
  68. package/dist/areas/quizzes/results.js.map +1 -0
  69. package/dist/areas/quizzes/structure.d.ts +42 -0
  70. package/dist/areas/quizzes/structure.js +100 -0
  71. package/dist/areas/quizzes/structure.js.map +1 -0
  72. package/dist/areas/site/info.d.ts +25 -0
  73. package/dist/areas/site/info.js +45 -0
  74. package/dist/areas/site/info.js.map +1 -0
  75. package/dist/areas/site/users.d.ts +56 -0
  76. package/dist/areas/site/users.js +89 -0
  77. package/dist/areas/site/users.js.map +1 -0
  78. package/dist/areas/surveys/choice.d.ts +38 -0
  79. package/dist/areas/surveys/choice.js +105 -0
  80. package/dist/areas/surveys/choice.js.map +1 -0
  81. package/dist/areas/surveys/surveys.d.ts +51 -0
  82. package/dist/areas/surveys/surveys.js +201 -0
  83. package/dist/areas/surveys/surveys.js.map +1 -0
  84. package/dist/browser/chromium.d.ts +15 -0
  85. package/dist/browser/chromium.js +53 -0
  86. package/dist/browser/chromium.js.map +1 -0
  87. package/dist/browser/cookies.d.ts +11 -0
  88. package/dist/browser/cookies.js +31 -0
  89. package/dist/browser/cookies.js.map +1 -0
  90. package/dist/browser/manager.d.ts +57 -0
  91. package/dist/browser/manager.js +177 -0
  92. package/dist/browser/manager.js.map +1 -0
  93. package/dist/browser.d.ts +53 -0
  94. package/dist/browser.js +167 -0
  95. package/dist/browser.js.map +1 -0
  96. package/dist/categories.d.ts +28 -0
  97. package/dist/categories.js +222 -0
  98. package/dist/categories.js.map +1 -0
  99. package/dist/chromiumInstaller.d.ts +15 -0
  100. package/dist/chromiumInstaller.js +52 -0
  101. package/dist/chromiumInstaller.js.map +1 -0
  102. package/dist/cli/args.d.ts +33 -0
  103. package/dist/cli/args.js +194 -0
  104. package/dist/cli/args.js.map +1 -0
  105. package/dist/cli/commands.generated.d.ts +4 -0
  106. package/dist/cli/commands.generated.js +2203 -0
  107. package/dist/cli/commands.generated.js.map +1 -0
  108. package/dist/cli/config.d.ts +46 -0
  109. package/dist/cli/config.js +107 -0
  110. package/dist/cli/config.js.map +1 -0
  111. package/dist/cli/main.d.ts +2 -0
  112. package/dist/cli/main.js +19 -0
  113. package/dist/cli/main.js.map +1 -0
  114. package/dist/cli/output.d.ts +4 -0
  115. package/dist/cli/output.js +71 -0
  116. package/dist/cli/output.js.map +1 -0
  117. package/dist/cli/run.d.ts +15 -0
  118. package/dist/cli/run.js +194 -0
  119. package/dist/cli/run.js.map +1 -0
  120. package/dist/cli/spec.d.ts +42 -0
  121. package/dist/cli/spec.js +4 -0
  122. package/dist/cli/spec.js.map +1 -0
  123. package/dist/client.d.ts +217 -0
  124. package/dist/client.js +179 -0
  125. package/dist/client.js.map +1 -0
  126. package/dist/core/ajax.d.ts +25 -0
  127. package/dist/core/ajax.js +85 -0
  128. package/dist/core/ajax.js.map +1 -0
  129. package/dist/core/editor.d.ts +18 -0
  130. package/dist/core/editor.js +51 -0
  131. package/dist/core/editor.js.map +1 -0
  132. package/dist/core/errors.d.ts +12 -0
  133. package/dist/core/errors.js +10 -0
  134. package/dist/core/errors.js.map +1 -0
  135. package/dist/core/files.d.ts +16 -0
  136. package/dist/core/files.js +58 -0
  137. package/dist/core/files.js.map +1 -0
  138. package/dist/core/forms.d.ts +45 -0
  139. package/dist/core/forms.js +200 -0
  140. package/dist/core/forms.js.map +1 -0
  141. package/dist/core/http.d.ts +18 -0
  142. package/dist/core/http.js +22 -0
  143. package/dist/core/http.js.map +1 -0
  144. package/dist/core/inPage.d.ts +8 -0
  145. package/dist/core/inPage.js +10 -0
  146. package/dist/core/inPage.js.map +1 -0
  147. package/dist/core/login.d.ts +40 -0
  148. package/dist/core/login.js +83 -0
  149. package/dist/core/login.js.map +1 -0
  150. package/dist/core/pace.d.ts +4 -0
  151. package/dist/core/pace.js +21 -0
  152. package/dist/core/pace.js.map +1 -0
  153. package/dist/core/session.d.ts +55 -0
  154. package/dist/core/session.js +157 -0
  155. package/dist/core/session.js.map +1 -0
  156. package/dist/core/site.d.ts +8 -0
  157. package/dist/core/site.js +36 -0
  158. package/dist/core/site.js.map +1 -0
  159. package/dist/core/text.d.ts +21 -0
  160. package/dist/core/text.js +113 -0
  161. package/dist/core/text.js.map +1 -0
  162. package/dist/home.d.ts +19 -0
  163. package/dist/home.js +54 -0
  164. package/dist/home.js.map +1 -0
  165. package/dist/index.d.ts +40 -0
  166. package/dist/index.js +51 -0
  167. package/dist/index.js.map +1 -0
  168. package/dist/internals.d.ts +11 -0
  169. package/dist/internals.js +15 -0
  170. package/dist/internals.js.map +1 -0
  171. package/dist/moodle/activities.d.ts +94 -0
  172. package/dist/moodle/activities.js +240 -0
  173. package/dist/moodle/activities.js.map +1 -0
  174. package/dist/moodle/ajax.d.ts +22 -0
  175. package/dist/moodle/ajax.js +91 -0
  176. package/dist/moodle/ajax.js.map +1 -0
  177. package/dist/moodle/assign.d.ts +65 -0
  178. package/dist/moodle/assign.js +162 -0
  179. package/dist/moodle/assign.js.map +1 -0
  180. package/dist/moodle/calendar.d.ts +41 -0
  181. package/dist/moodle/calendar.js +80 -0
  182. package/dist/moodle/calendar.js.map +1 -0
  183. package/dist/moodle/choice.d.ts +33 -0
  184. package/dist/moodle/choice.js +100 -0
  185. package/dist/moodle/choice.js.map +1 -0
  186. package/dist/moodle/completion.d.ts +48 -0
  187. package/dist/moodle/completion.js +108 -0
  188. package/dist/moodle/completion.js.map +1 -0
  189. package/dist/moodle/content.d.ts +43 -0
  190. package/dist/moodle/content.js +105 -0
  191. package/dist/moodle/content.js.map +1 -0
  192. package/dist/moodle/course.d.ts +51 -0
  193. package/dist/moodle/course.js +45 -0
  194. package/dist/moodle/course.js.map +1 -0
  195. package/dist/moodle/courses.d.ts +57 -0
  196. package/dist/moodle/courses.js +70 -0
  197. package/dist/moodle/courses.js.map +1 -0
  198. package/dist/moodle/editor.d.ts +18 -0
  199. package/dist/moodle/editor.js +50 -0
  200. package/dist/moodle/editor.js.map +1 -0
  201. package/dist/moodle/files.d.ts +16 -0
  202. package/dist/moodle/files.js +57 -0
  203. package/dist/moodle/files.js.map +1 -0
  204. package/dist/moodle/forms.d.ts +44 -0
  205. package/dist/moodle/forms.js +199 -0
  206. package/dist/moodle/forms.js.map +1 -0
  207. package/dist/moodle/forum.d.ts +55 -0
  208. package/dist/moodle/forum.js +115 -0
  209. package/dist/moodle/forum.js.map +1 -0
  210. package/dist/moodle/forumPosts.d.ts +83 -0
  211. package/dist/moodle/forumPosts.js +113 -0
  212. package/dist/moodle/forumPosts.js.map +1 -0
  213. package/dist/moodle/gradebook.d.ts +102 -0
  214. package/dist/moodle/gradebook.js +246 -0
  215. package/dist/moodle/gradebook.js.map +1 -0
  216. package/dist/moodle/grades.d.ts +24 -0
  217. package/dist/moodle/grades.js +67 -0
  218. package/dist/moodle/grades.js.map +1 -0
  219. package/dist/moodle/grading.d.ts +92 -0
  220. package/dist/moodle/grading.js +161 -0
  221. package/dist/moodle/grading.js.map +1 -0
  222. package/dist/moodle/h5p.d.ts +31 -0
  223. package/dist/moodle/h5p.js +100 -0
  224. package/dist/moodle/h5p.js.map +1 -0
  225. package/dist/moodle/inPage.d.ts +8 -0
  226. package/dist/moodle/inPage.js +10 -0
  227. package/dist/moodle/inPage.js.map +1 -0
  228. package/dist/moodle/lesson.d.ts +57 -0
  229. package/dist/moodle/lesson.js +140 -0
  230. package/dist/moodle/lesson.js.map +1 -0
  231. package/dist/moodle/login.d.ts +37 -0
  232. package/dist/moodle/login.js +77 -0
  233. package/dist/moodle/login.js.map +1 -0
  234. package/dist/moodle/messages.d.ts +66 -0
  235. package/dist/moodle/messages.js +80 -0
  236. package/dist/moodle/messages.js.map +1 -0
  237. package/dist/moodle/overview.d.ts +83 -0
  238. package/dist/moodle/overview.js +137 -0
  239. package/dist/moodle/overview.js.map +1 -0
  240. package/dist/moodle/pace.d.ts +4 -0
  241. package/dist/moodle/pace.js +19 -0
  242. package/dist/moodle/pace.js.map +1 -0
  243. package/dist/moodle/participants.d.ts +55 -0
  244. package/dist/moodle/participants.js +131 -0
  245. package/dist/moodle/participants.js.map +1 -0
  246. package/dist/moodle/questionBank.d.ts +81 -0
  247. package/dist/moodle/questionBank.js +235 -0
  248. package/dist/moodle/questionBank.js.map +1 -0
  249. package/dist/moodle/quiz.d.ts +117 -0
  250. package/dist/moodle/quiz.js +291 -0
  251. package/dist/moodle/quiz.js.map +1 -0
  252. package/dist/moodle/quizResults.d.ts +36 -0
  253. package/dist/moodle/quizResults.js +93 -0
  254. package/dist/moodle/quizResults.js.map +1 -0
  255. package/dist/moodle/quizStructure.d.ts +42 -0
  256. package/dist/moodle/quizStructure.js +100 -0
  257. package/dist/moodle/quizStructure.js.map +1 -0
  258. package/dist/moodle/session.d.ts +39 -0
  259. package/dist/moodle/session.js +99 -0
  260. package/dist/moodle/session.js.map +1 -0
  261. package/dist/moodle/site.d.ts +8 -0
  262. package/dist/moodle/site.js +36 -0
  263. package/dist/moodle/site.js.map +1 -0
  264. package/dist/moodle/siteInfo.d.ts +25 -0
  265. package/dist/moodle/siteInfo.js +45 -0
  266. package/dist/moodle/siteInfo.js.map +1 -0
  267. package/dist/moodle/structure.d.ts +94 -0
  268. package/dist/moodle/structure.js +103 -0
  269. package/dist/moodle/structure.js.map +1 -0
  270. package/dist/moodle/submission.d.ts +48 -0
  271. package/dist/moodle/submission.js +109 -0
  272. package/dist/moodle/submission.js.map +1 -0
  273. package/dist/moodle/survey.d.ts +47 -0
  274. package/dist/moodle/survey.js +197 -0
  275. package/dist/moodle/survey.js.map +1 -0
  276. package/dist/moodle/text.d.ts +21 -0
  277. package/dist/moodle/text.js +113 -0
  278. package/dist/moodle/text.js.map +1 -0
  279. package/dist/moodle/user.d.ts +54 -0
  280. package/dist/moodle/user.js +78 -0
  281. package/dist/moodle/user.js.map +1 -0
  282. package/dist/options.d.ts +30 -0
  283. package/dist/options.js +35 -0
  284. package/dist/options.js.map +1 -0
  285. package/dist/sessionCookies.d.ts +11 -0
  286. package/dist/sessionCookies.js +31 -0
  287. package/dist/sessionCookies.js.map +1 -0
  288. package/dist/testing/fakeMoodle.d.ts +55 -0
  289. package/dist/testing/fakeMoodle.js +103 -0
  290. package/dist/testing/fakeMoodle.js.map +1 -0
  291. package/dist/testing/gradingPanel.d.ts +6 -0
  292. package/dist/testing/gradingPanel.js +22 -0
  293. package/dist/testing/gradingPanel.js.map +1 -0
  294. package/dist/testing/index.d.ts +5 -0
  295. package/dist/testing/index.js +18 -0
  296. package/dist/testing/index.js.map +1 -0
  297. package/package.json +77 -4
@@ -0,0 +1,131 @@
1
+ import { callService } from "./ajax.js";
2
+ import { openSitePage } from "./session.js";
3
+ /** The person can't see the course's participants. */
4
+ export class NoParticipantsError extends Error {
5
+ name = "NoParticipantsError";
6
+ }
7
+ // The table's join types (core\output\datafilter): a filter's values match any; filters all apply.
8
+ const JOIN_ANY = 1;
9
+ const JOIN_ALL = 2;
10
+ const PAGE_SIZE = 100;
11
+ const MAX_PAGES = 100;
12
+ /** The rows of a participants table fragment: user id and name, read by link and markup, in any language. */
13
+ export function readParticipantRows(page) {
14
+ return page.evaluate(() => {
15
+ const doc = globalThis.document;
16
+ // Moodle pads a page with empty rows up to its size.
17
+ return Array.from(doc.querySelectorAll("tbody tr:not(.emptyrow)")).flatMap((row) => {
18
+ const link = row.querySelector('a[href*="/user/view.php?id="]');
19
+ const userId = Number(new URL(link?.getAttribute("href") ?? "", "http://x/").searchParams.get("id"));
20
+ if (!link || !userId)
21
+ return [];
22
+ // The link holds the picture or initials before the name.
23
+ const copy = link.cloneNode(true);
24
+ for (const extra of Array.from(copy.querySelectorAll(".userinitials, img, .visually-hidden")))
25
+ extra.remove();
26
+ return [{ userId, fullName: (copy.textContent ?? "").replace(/\s+/g, " ").trim() }];
27
+ });
28
+ });
29
+ }
30
+ /** Every participant matching `filters`, page by page, parsed in `parser` (a blank tab). */
31
+ async function fetchAll(context, siteUrl, parser, courseId, filters) {
32
+ const all = new Map();
33
+ for (let pagenumber = 1; pagenumber <= MAX_PAGES; pagenumber++) {
34
+ const { html } = await callService(context, siteUrl, "core_table_get_dynamic_table_content", {
35
+ component: "core_user",
36
+ handler: "participants",
37
+ uniqueid: `user-index-participants-${courseId}`,
38
+ sortdata: [{ sortby: "lastname", sortorder: 4 }],
39
+ // Integer filters take numbers: a string id fails (confirmed empirically, Moodle 5.2).
40
+ filters: [{ name: "courseid", jointype: JOIN_ANY, values: [courseId] }, ...filters],
41
+ jointype: JOIN_ALL,
42
+ firstinitial: "",
43
+ lastinitial: "",
44
+ pagenumber,
45
+ pagesize: PAGE_SIZE,
46
+ hiddencolumns: [],
47
+ resetpreferences: false,
48
+ });
49
+ await parser.setContent(html);
50
+ const rows = await readParticipantRows(parser);
51
+ for (const row of rows)
52
+ all.set(row.userId, row);
53
+ if (rows.length < PAGE_SIZE)
54
+ break;
55
+ }
56
+ return Array.from(all.values());
57
+ }
58
+ /**
59
+ * The roles and groups the participants page filters by in a course (its filters' options), as the
60
+ * person may use them: every group, or only their own in a course with separate groups. "No roles"
61
+ * and "no group" left out.
62
+ */
63
+ export async function participantFilters(context, siteUrl, courseId) {
64
+ const page = await openSitePage(context, siteUrl, `user/index.php?id=${courseId}`);
65
+ try {
66
+ if ((await page.locator("body#page-course-view-participants").count()) === 0) {
67
+ throw new NoParticipantsError(`You can't see the participants of course ${courseId}: it doesn't exist, or you aren't allowed.`);
68
+ }
69
+ return await page.evaluate(() => {
70
+ const doc = globalThis.document;
71
+ // Anonymous callbacks only: see the conventions on page.evaluate.
72
+ const [roles, groups] = ["roles", "groups"].map((field) => Array.from(doc.querySelectorAll(`select[data-field-name="${field}"] option`))
73
+ .map((option) => ({ id: Number(option.value), name: (option.textContent ?? "").trim() }))
74
+ .filter((option) => option.id > 0));
75
+ return { roles: roles ?? [], groups: groups ?? [] };
76
+ });
77
+ }
78
+ finally {
79
+ await page.close().catch(() => undefined);
80
+ }
81
+ }
82
+ /** A course's groups the person can see, with their members' user ids. */
83
+ export async function groups(context, siteUrl, courseId) {
84
+ const { groups: found } = await participantFilters(context, siteUrl, courseId);
85
+ const parser = await context.newPage();
86
+ try {
87
+ const result = [];
88
+ for (const group of found) {
89
+ const members = await fetchAll(context, siteUrl, parser, courseId, [{ name: "groups", jointype: JOIN_ANY, values: [group.id] }]);
90
+ result.push({ ...group, members: members.map((m) => m.userId) });
91
+ }
92
+ return result;
93
+ }
94
+ finally {
95
+ await parser.close().catch(() => undefined);
96
+ }
97
+ }
98
+ /**
99
+ * A course's participants with their roles and groups, as the participants page lists them: only
100
+ * those with a role (its id), in a group, or (`inactiveSince`) who haven't accessed the course since
101
+ * that date, when asked. Each role and group is a filter of its own, so ids come in any language.
102
+ */
103
+ export async function participants(context, siteUrl, courseId, { role, group, inactiveSince } = {}) {
104
+ const { roles, groups: courseGroupList } = await participantFilters(context, siteUrl, courseId);
105
+ const filters = [];
106
+ if (role !== undefined)
107
+ filters.push({ name: "roles", jointype: JOIN_ANY, values: [role] });
108
+ if (group !== undefined)
109
+ filters.push({ name: "groups", jointype: JOIN_ANY, values: [group] });
110
+ // "Inactive for more than": no access to the course since this time (the filter's "any" join).
111
+ if (inactiveSince !== undefined)
112
+ filters.push({ name: "accesssince", jointype: JOIN_ANY, values: [Math.floor(inactiveSince.getTime() / 1000)] });
113
+ const parser = await context.newPage();
114
+ try {
115
+ const people = await fetchAll(context, siteUrl, parser, courseId, filters);
116
+ const byUser = new Map(people.map((p) => [p.userId, { ...p, roles: [], groups: [] }]));
117
+ for (const r of roles) {
118
+ for (const member of await fetchAll(context, siteUrl, parser, courseId, [{ name: "roles", jointype: JOIN_ANY, values: [r.id] }]))
119
+ byUser.get(member.userId)?.roles.push(r);
120
+ }
121
+ for (const g of courseGroupList) {
122
+ for (const member of await fetchAll(context, siteUrl, parser, courseId, [{ name: "groups", jointype: JOIN_ANY, values: [g.id] }]))
123
+ byUser.get(member.userId)?.groups.push(g);
124
+ }
125
+ return Array.from(byUser.values());
126
+ }
127
+ finally {
128
+ await parser.close().catch(() => undefined);
129
+ }
130
+ }
131
+ //# sourceMappingURL=participants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"participants.js","sourceRoot":"","sources":["../../src/moodle/participants.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ import type { BrowserContext, Page } from "playwright-core";
2
+ import { type FieldValue } from "./forms.js";
3
+ /** The bank isn't there, or Moodle refused the questions. */
4
+ export declare class QuestionBankError extends Error {
5
+ name: string;
6
+ }
7
+ export interface QuestionBank {
8
+ /** The bank's course module id: a qbank activity's, or the quiz's whose private bank it is. */
9
+ cmid: number;
10
+ name: string;
11
+ /** The module: "qbank" for a shared bank, "quiz"… for an activity's own. */
12
+ type: string;
13
+ /** Shared banks' questions can be used anywhere in the course; activities' only there. */
14
+ shared: boolean;
15
+ }
16
+ /** A course's question banks: its shared ones and the activities' own, as the banks page lists them. */
17
+ export declare function questionBanks(context: BrowserContext, siteUrl: string, courseId: number): Promise<QuestionBank[]>;
18
+ export interface BankQuestion {
19
+ id: number;
20
+ name: string;
21
+ /** The question type: "multichoice", "truefalse", "shortanswer", "essay"… */
22
+ type: string;
23
+ /** "ready" or "draft". */
24
+ status?: string;
25
+ version?: string;
26
+ categoryId: number;
27
+ }
28
+ export interface BankCategory {
29
+ id: number;
30
+ name: string;
31
+ }
32
+ /** The questions on a bank page: id from their checkbox, type from their icon, name from the in-place editor. */
33
+ export declare function readBankQuestions(page: Page, categoryId: number): Promise<BankQuestion[]>;
34
+ /** A bank's categories and every question in them, category by category. */
35
+ export declare function questions(context: BrowserContext, siteUrl: string, bankCmid: number): Promise<{
36
+ categories: BankCategory[];
37
+ questions: BankQuestion[];
38
+ }>;
39
+ /**
40
+ * Imports questions into a bank from a GIFT or Moodle XML file (a local path) or text, through the
41
+ * bank's import form; returns the questions created. A file Moodle can't parse comes back as a
42
+ * `QuestionBankError` with its messages, and nothing is imported.
43
+ */
44
+ export declare function importQuestions(context: BrowserContext, siteUrl: string, bankCmid: number, { format, file, text, categoryId }: {
45
+ format: "gift" | "xml";
46
+ file?: string;
47
+ text?: string;
48
+ categoryId?: number;
49
+ }): Promise<{
50
+ changed: string;
51
+ questions: BankQuestion[];
52
+ }>;
53
+ export declare const QUESTION_TYPES: readonly ["multichoice", "truefalse", "shortanswer", "numerical", "essay"];
54
+ export type QuestionType = (typeof QUESTION_TYPES)[number];
55
+ export interface QuestionInput {
56
+ type: QuestionType;
57
+ name: string;
58
+ /** The question's text (HTML). */
59
+ text: string;
60
+ /** Multichoice, short answer and numerical answers: a fraction of the mark (1 right, 0 wrong, negatives for penalties), the nearest one Moodle offers. */
61
+ answers?: {
62
+ text: string;
63
+ fraction: number;
64
+ feedback?: string;
65
+ tolerance?: number;
66
+ }[];
67
+ /** True/false: the right answer. */
68
+ correct?: boolean;
69
+ categoryId?: number;
70
+ defaultMark?: number;
71
+ /** Any other field of the type's form, by name. */
72
+ fields?: Record<string, FieldValue>;
73
+ }
74
+ /**
75
+ * Creates one question in a bank through its type's edit form; returns its id. Imports are the way
76
+ * for several; this is for one-offs.
77
+ */
78
+ export declare function createQuestion(context: BrowserContext, siteUrl: string, bankCmid: number, input: QuestionInput): Promise<{
79
+ changed: string;
80
+ questionId?: number;
81
+ }>;
@@ -0,0 +1,235 @@
1
+ // Question banks (Moodle 5: shared banks are qbank activities; quizzes keep private ones), their
2
+ // questions read from the bank page by field names and icons, questions imported from a GIFT or
3
+ // Moodle XML file through the import form, and single questions made through their edit form.
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+ import { checkLocalFiles } from "./files.js";
8
+ import { formValue, openForm, setFields, submitForm, uploadToDraft } from "./forms.js";
9
+ import { NotLoggedInError, openSitePage } from "./session.js";
10
+ /** The bank isn't there, or Moodle refused the questions. */
11
+ export class QuestionBankError extends Error {
12
+ name = "QuestionBankError";
13
+ }
14
+ /** A course's question banks: its shared ones and the activities' own, as the banks page lists them. */
15
+ export async function questionBanks(context, siteUrl, courseId) {
16
+ const page = await openSitePage(context, siteUrl, `question/banks.php?courseid=${courseId}`);
17
+ try {
18
+ if ((await page.locator("body#page-question-banks").count()) === 0) {
19
+ throw new QuestionBankError(`You can't see course ${courseId}'s question banks: the course doesn't exist, or you aren't allowed.`);
20
+ }
21
+ return await page.evaluate(() => {
22
+ const doc = globalThis.document;
23
+ return Array.from(doc.querySelectorAll(".question-banks-list li")).flatMap((item) => {
24
+ const link = item.querySelector('a[href*="/mod/"][href*="view.php"]');
25
+ const match = /\/mod\/([a-z0-9_]+)\/view\.php\?(?:.*&)?id=(\d+)/.exec(link?.getAttribute("href") ?? "");
26
+ if (!match)
27
+ return [];
28
+ return [{ cmid: Number(match[2]), name: (link?.textContent ?? "").replace(/\s+/g, " ").trim(), type: match[1], shared: item.closest(".shared-banks") !== null }];
29
+ });
30
+ });
31
+ }
32
+ finally {
33
+ await page.close().catch(() => undefined);
34
+ }
35
+ }
36
+ /** The bank page's categories (its category filter's options) and its context id. */
37
+ async function bankCategories(page) {
38
+ return page.evaluate(() => {
39
+ const doc = globalThis.document;
40
+ const categories = Array.from(doc.querySelectorAll('[data-filterregion="filtertypedata"] select[data-field-name="category"] option'))
41
+ .filter((option) => option.value !== "")
42
+ // Names come indented, with their question count: " Default for X (6)".
43
+ .map((option) => ({ id: Number(option.value), name: (option.textContent ?? "").replace(/\s+/g, " ").replace(/\s*\(\d+\)$/, "").trim() }));
44
+ return { contextId: Number(doc.querySelector(".questionbankwindow[data-contextid]")?.getAttribute("data-contextid")), categories };
45
+ });
46
+ }
47
+ /** The questions on a bank page: id from their checkbox, type from their icon, name from the in-place editor. */
48
+ export function readBankQuestions(page, categoryId) {
49
+ return page.evaluate((categoryId) => {
50
+ const doc = globalThis.document;
51
+ return Array.from(doc.querySelectorAll("#categoryquestions tbody tr")).flatMap((row) => {
52
+ const id = Number(/^q(\d+)$/.exec(row.querySelector('input[type="checkbox"][name^="q"]')?.getAttribute("name") ?? "")?.[1]);
53
+ if (!id)
54
+ return [];
55
+ const type = /\/qtype_([a-z0-9_]+)\//.exec(row.querySelector('[data-columnid*="question_type_column"] img')?.getAttribute("src") ?? "")?.[1] ?? "";
56
+ const name = row.querySelector('[data-itemtype="questionname"]')?.getAttribute("data-value") ?? "";
57
+ const status = row.querySelector('select[name="question_status_dropdown"]')?.value;
58
+ const version = (row.querySelector('[data-columnid*="version_number_column"]')?.textContent ?? "").trim();
59
+ return [{ id, name, type, ...(status ? { status } : {}), ...(version ? { version } : {}), categoryId }];
60
+ });
61
+ }, categoryId);
62
+ }
63
+ // Modules that keep questions (FEATURE_USES_QUESTIONS in Moodle 5): a shared bank, a quiz's own.
64
+ const BANK_MODULES = new Set(["qbank", "quiz"]);
65
+ const bankModules = new Map();
66
+ /**
67
+ * Checks that a course module keeps questions before opening its bank: Moodle creates a bank's
68
+ * categories for any activity whose bank page is opened (confirmed empirically, 5.2). The module
69
+ * comes from its settings form, which changes nothing.
70
+ */
71
+ async function checkBank(context, siteUrl, bankCmid) {
72
+ const key = `${siteUrl}|${bankCmid}`;
73
+ let module = bankModules.get(key);
74
+ if (!module) {
75
+ const page = await openSitePage(context, siteUrl, `course/modedit.php?update=${bankCmid}`);
76
+ try {
77
+ module = (await formValue(page, "modulename")) ?? "";
78
+ }
79
+ finally {
80
+ await page.close().catch(() => undefined);
81
+ }
82
+ if (module)
83
+ bankModules.set(key, module);
84
+ }
85
+ if (!BANK_MODULES.has(module)) {
86
+ throw new QuestionBankError(`Activity ${bankCmid} ${module ? `is a ${module}, which` : "doesn't exist, or you can't edit it, so it"} has no question bank. Take a bank's id from the course's question banks.`);
87
+ }
88
+ }
89
+ // A bank page shows this many questions at most; more than that in one category isn't read.
90
+ const PER_PAGE = 1000;
91
+ /** A bank's categories and every question in them, category by category. */
92
+ export async function questions(context, siteUrl, bankCmid) {
93
+ await checkBank(context, siteUrl, bankCmid);
94
+ const page = await openSitePage(context, siteUrl, `question/edit.php?cmid=${bankCmid}`);
95
+ try {
96
+ if ((await page.locator("body#page-question-edit .questionbankwindow").count()) === 0) {
97
+ throw new QuestionBankError(`Activity ${bankCmid} has no question bank you can see. Take a bank's id from the course's question banks.`);
98
+ }
99
+ const { contextId, categories } = await bankCategories(page);
100
+ const found = [];
101
+ for (const category of categories) {
102
+ await page.goto(new URL(`question/edit.php?cmid=${bankCmid}&cat=${category.id},${contextId}&qperpage=${PER_PAGE}`, siteUrl).href, { waitUntil: "domcontentloaded" });
103
+ found.push(...(await readBankQuestions(page, category.id)));
104
+ }
105
+ return { categories, questions: found };
106
+ }
107
+ finally {
108
+ await page.close().catch(() => undefined);
109
+ }
110
+ }
111
+ /** The form's category option ("<id>,<context>") for a category id; the form's default without one. */
112
+ async function categoryOption(page, categoryId) {
113
+ if (categoryId === undefined)
114
+ return undefined;
115
+ const values = await page.evaluate(() => Array.from(globalThis.document.querySelectorAll('form.mform select[name="category"] option')).map((o) => o.value));
116
+ const value = values.find((v) => v.split(",")[0] === String(categoryId));
117
+ if (!value)
118
+ throw new QuestionBankError(`The bank has no category ${categoryId}: take one from the bank's categories.`);
119
+ return value;
120
+ }
121
+ /** Moodle's messages on the page after a form or an import (problems first). */
122
+ async function pageProblems(page) {
123
+ return page.evaluate(() => {
124
+ const doc = globalThis.document;
125
+ return Array.from(doc.querySelectorAll("#region-main .alert-danger, #region-main .notifyproblem, #region-main .errorbox, #region-main .alert-warning"))
126
+ .map((element) => {
127
+ // Without the close button, the error box's heading and its "more information" link.
128
+ const copy = element.cloneNode(true);
129
+ for (const extra of Array.from(copy.querySelectorAll("button, .visually-hidden, .errorcode, h2, h3, h4")))
130
+ extra.remove();
131
+ return (copy.textContent ?? "").replace(/\s+/g, " ").trim();
132
+ })
133
+ .filter(Boolean);
134
+ });
135
+ }
136
+ /**
137
+ * Imports questions into a bank from a GIFT or Moodle XML file (a local path) or text, through the
138
+ * bank's import form; returns the questions created. A file Moodle can't parse comes back as a
139
+ * `QuestionBankError` with its messages, and nothing is imported.
140
+ */
141
+ export async function importQuestions(context, siteUrl, bankCmid, { format, file, text, categoryId }) {
142
+ if ((file === undefined) === (text === undefined))
143
+ throw new QuestionBankError("Give the questions as a file or as text, one of the two.");
144
+ if (file)
145
+ await checkLocalFiles([file]);
146
+ const before = new Set((await questions(context, siteUrl, bankCmid)).questions.map((q) => q.id));
147
+ // Text goes up as a file, as a person would upload one.
148
+ const dir = text !== undefined ? await mkdtemp(path.join(tmpdir(), "moodle-import-")) : undefined;
149
+ const upload = dir ? path.join(dir, format === "gift" ? "questions.txt" : "questions.xml") : file;
150
+ if (dir)
151
+ await writeFile(upload, text, "utf8");
152
+ const page = await openForm(context, siteUrl, `question/bank/importquestions/import.php?cmid=${bankCmid}`).catch((error) => {
153
+ if (error instanceof NotLoggedInError)
154
+ throw error;
155
+ throw new QuestionBankError(`Activity ${bankCmid} has no question bank you can import into.`);
156
+ });
157
+ try {
158
+ await uploadToDraft(page, (await formValue(page, "newfile")), [upload]);
159
+ const values = { format };
160
+ const category = await categoryOption(page, categoryId);
161
+ // The file's own categories would override the one chosen.
162
+ if (category)
163
+ Object.assign(values, { category, catfromfile: false, contextfromfile: false });
164
+ await setFields(page, values);
165
+ await submitForm(page, "submitbutton");
166
+ const problems = await pageProblems(page);
167
+ if (problems.length > 0)
168
+ throw new QuestionBankError(`Moodle didn't import the questions: ${problems.join(" ")}`);
169
+ }
170
+ finally {
171
+ await page.close().catch(() => undefined);
172
+ if (dir)
173
+ await rm(dir, { recursive: true, force: true });
174
+ }
175
+ const created = (await questions(context, siteUrl, bankCmid)).questions.filter((q) => !before.has(q.id));
176
+ return { changed: `Imported ${created.length} question${created.length === 1 ? "" : "s"} into bank ${bankCmid}.`, questions: created };
177
+ }
178
+ export const QUESTION_TYPES = ["multichoice", "truefalse", "shortanswer", "numerical", "essay"];
179
+ /** The option of a fraction select nearest to `fraction` (Moodle offers 100%, 90%, 83.33%…). */
180
+ async function nearestFraction(page, field, fraction) {
181
+ const options = await page.evaluate((field) => Array.from(globalThis.document.querySelectorAll(`select[name="${field}"] option`)).map((o) => o.value), field);
182
+ return options.reduce((best, value) => (Math.abs(Number(value) - fraction) < Math.abs(Number(best) - fraction) ? value : best), options[0] ?? "0.0");
183
+ }
184
+ /**
185
+ * Creates one question in a bank through its type's edit form; returns its id. Imports are the way
186
+ * for several; this is for one-offs.
187
+ */
188
+ export async function createQuestion(context, siteUrl, bankCmid, input) {
189
+ if (!QUESTION_TYPES.includes(input.type))
190
+ throw new QuestionBankError(`Question type ${input.type} isn't one this creates (${QUESTION_TYPES.join(", ")}); import it as GIFT or Moodle XML instead.`);
191
+ const answers = input.answers ?? [];
192
+ if (["multichoice", "shortanswer", "numerical"].includes(input.type) && answers.length === 0)
193
+ throw new QuestionBankError(`A ${input.type} question needs answers.`);
194
+ if (input.type === "truefalse" && input.correct === undefined)
195
+ throw new QuestionBankError("A true/false question needs its right answer (correct).");
196
+ const { categories } = await questions(context, siteUrl, bankCmid);
197
+ // The bank lists its "Top" category first, which holds no questions: its default one comes next.
198
+ const category = input.categoryId ?? (categories[1] ?? categories[0])?.id;
199
+ const page = await openForm(context, siteUrl, `question/bank/editquestion/question.php?cmid=${bankCmid}&category=${category}&qtype=${input.type}`).catch((error) => {
200
+ if (error instanceof NotLoggedInError)
201
+ throw error;
202
+ throw new QuestionBankError(`Activity ${bankCmid} has no question bank you can add to, or category ${category} isn't in it.`);
203
+ });
204
+ try {
205
+ // The form comes with a few blank answers; "Blanks for more" adds three at a time.
206
+ for (let i = 0; i < 10 && answers.length > Number((await formValue(page, "noanswers")) ?? answers.length); i++) {
207
+ await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }), page.click('form.mform [name="addanswers"]')]);
208
+ }
209
+ const values = { name: input.name, "questiontext[text]": input.text, ...(input.defaultMark !== undefined ? { defaultmark: input.defaultMark } : {}) };
210
+ const choice = await categoryOption(page, input.categoryId);
211
+ if (choice)
212
+ values.category = choice;
213
+ if (input.type === "truefalse")
214
+ values.correctanswer = input.correct ? 1 : 0;
215
+ if (input.type === "multichoice")
216
+ values.single = answers.filter((a) => a.fraction > 0).length > 1 ? 0 : 1;
217
+ for (const [i, answer] of answers.entries()) {
218
+ values[input.type === "multichoice" ? `answer[${i}][text]` : `answer[${i}]`] = answer.text;
219
+ values[`fraction[${i}]`] = await nearestFraction(page, `fraction[${i}]`, answer.fraction);
220
+ if (answer.feedback !== undefined)
221
+ values[`feedback[${i}][text]`] = answer.feedback;
222
+ if (answer.tolerance !== undefined)
223
+ values[`tolerance[${i}]`] = answer.tolerance;
224
+ }
225
+ await setFields(page, { ...values, ...input.fields });
226
+ await submitForm(page, "submitbutton");
227
+ // Moodle lands on the bank, naming the question it changed last.
228
+ const id = Number(new URL(page.url()).searchParams.get("lastchanged")) || undefined;
229
+ return { changed: `Created ${input.type} question${id ? ` ${id}` : ""} "${input.name}" in bank ${bankCmid}.`, questionId: id };
230
+ }
231
+ finally {
232
+ await page.close().catch(() => undefined);
233
+ }
234
+ }
235
+ //# sourceMappingURL=questionBank.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"questionBank.js","sourceRoot":"","sources":["../../src/moodle/questionBank.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,117 @@
1
+ import type { BrowserContext, Page } from "playwright-core";
2
+ import type { Trail } from "../options.js";
3
+ /** The quiz or attempt isn't there, or the person can't do what was asked. */
4
+ export declare class QuizError extends Error {
5
+ name: string;
6
+ }
7
+ /** A label and value as Moodle shows them (site language). */
8
+ export interface Detail {
9
+ label: string;
10
+ value: string;
11
+ }
12
+ /** What the quiz's page says: its info lines, dates, the person's attempts, whether they can start one. */
13
+ export interface QuizInfo {
14
+ intro: string;
15
+ info: string[];
16
+ dates: Detail[];
17
+ feedback?: string;
18
+ attempts: {
19
+ attemptId: number;
20
+ inProgress: boolean;
21
+ details: Detail[];
22
+ }[];
23
+ canStart: boolean;
24
+ messages: string[];
25
+ }
26
+ /** One input of a question, by its form name, with its options and current value. */
27
+ export interface QuestionField {
28
+ name: string;
29
+ kind: "choice" | "checkbox" | "text" | "select";
30
+ label?: string;
31
+ options?: {
32
+ value: string;
33
+ label: string;
34
+ }[];
35
+ value?: string;
36
+ }
37
+ /** A question as the attempt or review page shows it. */
38
+ export interface Question {
39
+ slot: number;
40
+ page?: number;
41
+ number?: string;
42
+ type: string;
43
+ state: string;
44
+ mark?: string;
45
+ text: string;
46
+ images: string[];
47
+ fields: QuestionField[];
48
+ feedback?: string;
49
+ rightAnswer?: string;
50
+ }
51
+ export declare function readQuizView(page: Page): Promise<QuizInfo>;
52
+ /** The questions on an attempt or review page, with their fields read from the form. */
53
+ export declare function readQuestions(page: Page): Promise<Question[]>;
54
+ /** The pages of an attempt, from its navigation: slot → page. */
55
+ export declare function readNavigation(page: Page): Promise<Record<number, number>>;
56
+ export declare function quizInfo(context: BrowserContext, siteUrl: string, quizId: number): Promise<QuizInfo>;
57
+ /** Starts an attempt, or continues the one in progress, as the quiz page's button does; returns its id. */
58
+ export declare function startAttempt(context: BrowserContext, siteUrl: string, quizId: number): Promise<number>;
59
+ /** Every question of an attempt, page by page, with the page each is on. */
60
+ export declare function readAttempt(context: BrowserContext, siteUrl: string, attemptId: number): Promise<Question[]>;
61
+ /**
62
+ * Saves answers page by page, as the attempt's "next" button does; `beforeSave` runs with each
63
+ * page's questions (the realistic trail's reading pause). Throws, saving nothing more, on a field
64
+ * a page doesn't have.
65
+ */
66
+ export declare function saveAnswers(context: BrowserContext, siteUrl: string, attemptId: number, answers: {
67
+ slot: number;
68
+ responses: Record<string, string>;
69
+ }[], beforeSave?: (questions: Question[]) => Promise<void>): Promise<void>;
70
+ /** Submits the attempt for grading, as "Submit all and finish" does, without its confirm dialog. */
71
+ export declare function finishAttempt(context: BrowserContext, siteUrl: string, attemptId: number): Promise<void>;
72
+ /** A finished attempt's review, as far as the quiz's review options let the person see it. */
73
+ export declare function readReview(context: BrowserContext, siteUrl: string, attemptId: number): Promise<{
74
+ summary: Detail[];
75
+ questions: Question[];
76
+ }>;
77
+ /** Starts an attempt, or continues the one in progress, and gives its questions. */
78
+ export declare function startQuizAttempt(context: BrowserContext, siteUrl: string, quizId: number): Promise<{
79
+ attemptId: number;
80
+ questions: Question[];
81
+ }>;
82
+ /**
83
+ * Saves answers in an attempt in progress without finishing it, and gives its questions afterwards.
84
+ * With the realistic trail, it first takes the time a reader needs for the questions and their
85
+ * options (ADR-005), calling `onProgress` with the seconds elapsed and the total.
86
+ */
87
+ export declare function answerQuizQuestions(context: BrowserContext, siteUrl: string, attemptId: number, answers: {
88
+ slot: number;
89
+ responses: Record<string, string>;
90
+ }[], { trail, onProgress }?: {
91
+ trail?: Trail;
92
+ onProgress?: (elapsed: number, total: number) => void;
93
+ }): Promise<{
94
+ attemptId: number;
95
+ questions: Question[];
96
+ }>;
97
+ /** Submits an attempt for grading, and gives its review when the quiz shows it. */
98
+ export declare function finishQuizAttempt(context: BrowserContext, siteUrl: string, attemptId: number): Promise<{
99
+ attemptId: number;
100
+ finished: true;
101
+ review: {
102
+ summary: Detail[];
103
+ questions: Question[];
104
+ } | undefined;
105
+ }>;
106
+ /**
107
+ * Sets a user override on a quiz, through its override form: other opening and closing dates, a time
108
+ * limit in minutes, or a number of attempts (0 unlimited); what isn't given stays the quiz's.
109
+ */
110
+ export declare function setQuizOverride(context: BrowserContext, siteUrl: string, quizId: number, userId: number, { open, close, timeLimitMinutes, attempts }: {
111
+ open?: Date;
112
+ close?: Date;
113
+ timeLimitMinutes?: number;
114
+ attempts?: number;
115
+ }): Promise<{
116
+ changed: string;
117
+ }>;