utn-mcp 0.2.1 → 0.6.9
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 +22 -0
- package/build/aula-virtual/client/client.js +181 -3
- package/build/aula-virtual/client/moodle-api.js +47 -13
- package/build/aula-virtual/client/session.js +0 -1
- package/build/aula-virtual/client/stored-session.js +55 -0
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +102 -0
- package/build/aula-virtual/format/address.js +52 -0
- package/build/aula-virtual/format/fields.js +14 -0
- package/build/aula-virtual/format/files.js +89 -0
- package/build/aula-virtual/format/navigate.js +143 -0
- package/build/aula-virtual/format/submission.js +214 -0
- package/build/aula-virtual/login.js +30 -4
- package/build/aula-virtual/tools.js +291 -13
- package/build/http.js +121 -3
- package/build/index.js +23 -11
- package/build/server.js +7 -4
- package/build/shared/documents/index.js +46 -46
- package/build/shared/documents/office.js +63 -63
- package/build/shared/documents/pdf.js +80 -60
- package/build/shared/documents/png.js +20 -20
- package/build/shared/documents/zip.js +49 -48
- package/build/shared/html.js +15 -2
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.js +1 -0
- package/build/shared/store/sqlite-store.js +145 -0
- package/build/shared/uploads.js +80 -0
- package/package.json +1 -1
- package/build/aula-virtual/address.js +0 -39
- package/build/aula-virtual/files.js +0 -89
- package/build/aula-virtual/navigate.js +0 -144
- /package/build/aula-virtual/{types.js → client/types.js} +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const ROOT = "/";
|
|
2
|
+
export const courseAddr = (courseId) => `/curso/${courseId}`;
|
|
3
|
+
export const moduleAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
|
|
4
|
+
export const discussionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
|
|
5
|
+
const FORMATS = [
|
|
6
|
+
"/",
|
|
7
|
+
"/curso/{courseId}",
|
|
8
|
+
"/curso/{courseId}/mod/{cmid}",
|
|
9
|
+
"/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
|
|
10
|
+
].join(", ");
|
|
11
|
+
const PATTERN = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
|
|
12
|
+
export function parseAddress(raw) {
|
|
13
|
+
const path = raw.trim().replace(/\/+$/, "") || "/";
|
|
14
|
+
if (path === "/")
|
|
15
|
+
return { level: "root" };
|
|
16
|
+
const m = PATTERN.exec(path);
|
|
17
|
+
if (!m)
|
|
18
|
+
throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATS}`);
|
|
19
|
+
const [, course, cmid, disc] = m;
|
|
20
|
+
const courseId = Number(course);
|
|
21
|
+
if (!cmid)
|
|
22
|
+
return { level: "course", courseId };
|
|
23
|
+
if (!disc)
|
|
24
|
+
return { level: "module", courseId, cmid: Number(cmid) };
|
|
25
|
+
return { level: "discussion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
|
|
26
|
+
}
|
|
27
|
+
/** La dirección en texto, tal como la ve el modelo. */
|
|
28
|
+
export function formatAddress(address) {
|
|
29
|
+
switch (address.level) {
|
|
30
|
+
case "root":
|
|
31
|
+
return ROOT;
|
|
32
|
+
case "course":
|
|
33
|
+
return courseAddr(address.courseId);
|
|
34
|
+
case "module":
|
|
35
|
+
return moduleAddr(address.courseId, address.cmid);
|
|
36
|
+
case "discussion":
|
|
37
|
+
return discussionAddr(address.courseId, address.cmid, address.discussionId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
|
|
41
|
+
export function parentOf(address) {
|
|
42
|
+
switch (address.level) {
|
|
43
|
+
case "root":
|
|
44
|
+
return null;
|
|
45
|
+
case "course":
|
|
46
|
+
return ROOT;
|
|
47
|
+
case "module":
|
|
48
|
+
return courseAddr(address.courseId);
|
|
49
|
+
case "discussion":
|
|
50
|
+
return moduleAddr(address.courseId, address.cmid);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { userFacingUrl } from "./files.js";
|
|
2
|
+
/** Los timestamps de Moodle (segundos) en ISO; 0 o vacío es "sin fecha". */
|
|
3
|
+
export const isoDate = (seconds) => seconds ? new Date(seconds * 1000).toISOString() : null;
|
|
4
|
+
export function toFile(f) {
|
|
5
|
+
return {
|
|
6
|
+
nombre: f.filename,
|
|
7
|
+
ruta: f.filepath || undefined,
|
|
8
|
+
tamanio: f.filesize,
|
|
9
|
+
mimetype: f.mimetype,
|
|
10
|
+
modificado: isoDate(f.timemodified),
|
|
11
|
+
// Sirve para pasársela al usuario y también para leerlo con aula_read_file.
|
|
12
|
+
url: userFacingUrl(f.fileurl),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
|
|
2
|
+
import { AULA_BASE_URL } from "../client/constants.js";
|
|
3
|
+
import { fetchFile } from "../client/moodle-api.js";
|
|
4
|
+
const ORIGIN = new URL(AULA_BASE_URL).origin;
|
|
5
|
+
const WS_PATH = "/webservice/pluginfile.php/";
|
|
6
|
+
const WEB_PATH = "/pluginfile.php/";
|
|
7
|
+
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
8
|
+
const BUDGET_CHARS = 30_000;
|
|
9
|
+
/** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
|
|
10
|
+
const IMAGE_COST_CHARS = 6_000;
|
|
11
|
+
/** El más grande que hay hoy en los cursos pesa 12 MB. */
|
|
12
|
+
const MAX_BYTES = 30 * 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
|
|
15
|
+
* `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
|
|
16
|
+
* sale nunca del servidor.
|
|
17
|
+
*/
|
|
18
|
+
export function userFacingUrl(fileurl) {
|
|
19
|
+
return fileurl.replace(`${ORIGIN}${WS_PATH}`, `${ORIGIN}${WEB_PATH}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
|
|
23
|
+
* cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
|
|
24
|
+
*/
|
|
25
|
+
function downloadUrl(url) {
|
|
26
|
+
let u;
|
|
27
|
+
try {
|
|
28
|
+
u = new URL(url);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
|
|
32
|
+
}
|
|
33
|
+
const path = u.pathname.startsWith(WS_PATH) ? u.pathname : u.pathname.startsWith(WEB_PATH) ? WS_PATH + u.pathname.slice(WEB_PATH.length) : null;
|
|
34
|
+
if (u.origin !== ORIGIN || !path) {
|
|
35
|
+
throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGIN}${WEB_PATH}...).`);
|
|
36
|
+
}
|
|
37
|
+
u.pathname = path;
|
|
38
|
+
u.searchParams.delete("token");
|
|
39
|
+
u.searchParams.delete("wstoken");
|
|
40
|
+
return u;
|
|
41
|
+
}
|
|
42
|
+
/** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
|
|
43
|
+
export async function readAulaFile(session, url, from = 1) {
|
|
44
|
+
const download = downloadUrl(url);
|
|
45
|
+
const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
|
|
46
|
+
const forUser = userFacingUrl(download.href);
|
|
47
|
+
const { bytes } = await fetchFile(session, download, MAX_BYTES);
|
|
48
|
+
let doc;
|
|
49
|
+
try {
|
|
50
|
+
doc = await openDocument(bytes, fileName);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof UnsupportedFormat) {
|
|
54
|
+
throw new Error(`${e.message} El usuario lo puede bajar desde ${forUser}`);
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
if (from > doc.total) {
|
|
60
|
+
throw new Error(`"${fileName}" termina en ${doc.unit} ${doc.total}; se pidió desde ${from}.`);
|
|
61
|
+
}
|
|
62
|
+
const parts = [];
|
|
63
|
+
let spent = 0;
|
|
64
|
+
let n = from;
|
|
65
|
+
for (; n <= doc.total; n++) {
|
|
66
|
+
const part = await doc.part(n);
|
|
67
|
+
const cost = "text" in part ? part.text.length : IMAGE_COST_CHARS;
|
|
68
|
+
// Siempre al menos una parte, aunque sola se pase del presupuesto.
|
|
69
|
+
if (parts.length > 0 && spent + cost > BUDGET_CHARS)
|
|
70
|
+
break;
|
|
71
|
+
parts.push({ ...part, number: n });
|
|
72
|
+
spent += cost;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
archivo: fileName,
|
|
76
|
+
formato: doc.format,
|
|
77
|
+
unidad: doc.unit,
|
|
78
|
+
total: doc.total,
|
|
79
|
+
desde: from,
|
|
80
|
+
hasta: n - 1,
|
|
81
|
+
siguiente: n <= doc.total ? n : undefined,
|
|
82
|
+
urlParaUsuario: forUser,
|
|
83
|
+
parts,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await doc.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { htmlToSnippet, htmlToText } from "../../shared/html.js";
|
|
2
|
+
import { courseAddr, discussionAddr, formatAddress, moduleAddr, parentOf, parseAddress, } from "./address.js";
|
|
3
|
+
import { canAddDiscussion, getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, PAGE_SIZE, } from "../client/client.js";
|
|
4
|
+
import { viewChoice, viewChoicegroup, viewSubmission } from "./activities.js";
|
|
5
|
+
import { isoDate, toFile } from "./fields.js";
|
|
6
|
+
/**
|
|
7
|
+
* Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
|
|
8
|
+
* adentro. Es el único lugar que decide qué ve el modelo y cuánto texto le cuesta.
|
|
9
|
+
*/
|
|
10
|
+
export async function open(session, rawAddress, page = 0) {
|
|
11
|
+
const address = parseAddress(rawAddress);
|
|
12
|
+
const base = { direccion: formatAddress(address), padre: parentOf(address) };
|
|
13
|
+
switch (address.level) {
|
|
14
|
+
case "root":
|
|
15
|
+
return { ...base, cursos: await viewCourses(session) };
|
|
16
|
+
case "course":
|
|
17
|
+
return { ...base, secciones: await viewCourse(session, address.courseId) };
|
|
18
|
+
case "module":
|
|
19
|
+
return { ...base, ...(await viewModule(session, address, page)) };
|
|
20
|
+
case "discussion":
|
|
21
|
+
return { ...base, ...(await viewDiscussion(session, address.discussionId)) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function viewCourses(session) {
|
|
25
|
+
const courses = await getUserCourses(session);
|
|
26
|
+
return courses.map((c) => ({
|
|
27
|
+
direccion: courseAddr(c.id),
|
|
28
|
+
id: c.id,
|
|
29
|
+
nombre: c.fullname,
|
|
30
|
+
nombreCorto: c.shortname,
|
|
31
|
+
visible: c.visible === 1,
|
|
32
|
+
progreso: c.progress ?? null,
|
|
33
|
+
ultimoAcceso: isoDate(c.lastaccess),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
/** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
|
|
37
|
+
async function viewCourse(session, courseId) {
|
|
38
|
+
const sections = await getCourseSections(session, courseId);
|
|
39
|
+
return sections
|
|
40
|
+
.filter((s) => s.uservisible !== false)
|
|
41
|
+
.map((s) => ({
|
|
42
|
+
nombre: s.name,
|
|
43
|
+
numero: s.section,
|
|
44
|
+
resumen: htmlToText(s.summary) || undefined,
|
|
45
|
+
modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
|
|
46
|
+
}))
|
|
47
|
+
.filter((s) => s.resumen || s.modulos.length > 0);
|
|
48
|
+
}
|
|
49
|
+
async function viewModule(session, address, page) {
|
|
50
|
+
const { sectionName, module } = await findModule(session, address.courseId, address.cmid);
|
|
51
|
+
const base = { seccion: sectionName, modulo: toModule(module, address.courseId) };
|
|
52
|
+
switch (module.modname) {
|
|
53
|
+
case "forum":
|
|
54
|
+
return { ...base, ...(await viewForum(session, address, module.instance, page)) };
|
|
55
|
+
case "assign":
|
|
56
|
+
return { ...base, entrega: await viewSubmission(session, address.courseId, module) };
|
|
57
|
+
case "choice":
|
|
58
|
+
return { ...base, eleccion: await viewChoice(session, address.courseId, module) };
|
|
59
|
+
case "choicegroup":
|
|
60
|
+
return { ...base, eleccion: await viewChoicegroup(session, module) };
|
|
61
|
+
default:
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión. */
|
|
66
|
+
async function viewForum(session, address, forumid, page) {
|
|
67
|
+
const [{ discussions }, canStartDiscussion] = await Promise.all([
|
|
68
|
+
getForumDiscussions(session, forumid, page),
|
|
69
|
+
canAddDiscussion(session, forumid),
|
|
70
|
+
]);
|
|
71
|
+
return {
|
|
72
|
+
puedeAbrirHilo: canStartDiscussion,
|
|
73
|
+
discusiones: discussions.map((d) => ({
|
|
74
|
+
direccion: discussionAddr(address.courseId, address.cmid, d.discussion),
|
|
75
|
+
asunto: d.subject,
|
|
76
|
+
autor: d.userfullname,
|
|
77
|
+
autorId: d.userid,
|
|
78
|
+
fecha: isoDate(d.created),
|
|
79
|
+
ultimaActividad: isoDate(d.timemodified),
|
|
80
|
+
respuestas: d.numreplies,
|
|
81
|
+
fijada: d.pinned || undefined,
|
|
82
|
+
adjuntos: d.attachment || undefined,
|
|
83
|
+
resumen: htmlToSnippet(d.message),
|
|
84
|
+
})),
|
|
85
|
+
pagina: page,
|
|
86
|
+
hayMasDiscusiones: discussions.length === PAGE_SIZE || undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function viewDiscussion(session, discussionId) {
|
|
90
|
+
const { posts } = await getDiscussionPosts(session, discussionId);
|
|
91
|
+
const visible = posts.filter((p) => !p.isdeleted);
|
|
92
|
+
return {
|
|
93
|
+
asunto: visible[0]?.subject,
|
|
94
|
+
publicaciones: visible.map((p) => ({
|
|
95
|
+
autor: p.author.fullname,
|
|
96
|
+
autorId: p.author.id,
|
|
97
|
+
fecha: isoDate(p.timecreated),
|
|
98
|
+
respuestaA: p.parentid ?? undefined,
|
|
99
|
+
id: p.id,
|
|
100
|
+
mensaje: htmlToText(p.message),
|
|
101
|
+
adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
|
|
102
|
+
// Lo que el usuario puede hacer con cada publicación, para no intentar lo que Moodle va a rechazar.
|
|
103
|
+
acciones: allowedActions(p.capabilities),
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function allowedActions(c) {
|
|
108
|
+
const allowed = [c.reply && "responder", c.edit && "editar", c.delete && "borrar"].filter(Boolean);
|
|
109
|
+
return allowed.length ? allowed : undefined;
|
|
110
|
+
}
|
|
111
|
+
/** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
|
|
112
|
+
export async function findModule(session, courseId, cmid) {
|
|
113
|
+
const sections = await getCourseSections(session, courseId, cmid);
|
|
114
|
+
for (const section of sections) {
|
|
115
|
+
const module = section.modules.find((m) => m.id === cmid);
|
|
116
|
+
if (!module)
|
|
117
|
+
continue;
|
|
118
|
+
if (!module.uservisible)
|
|
119
|
+
throw new Error(`No tenés acceso al módulo ${cmid}.`);
|
|
120
|
+
return { sectionName: section.name, module };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
|
|
123
|
+
}
|
|
124
|
+
/** `summarized` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
|
|
125
|
+
function toModule(m, courseId, summarized = false) {
|
|
126
|
+
const contents = m.contents ?? [];
|
|
127
|
+
const files = contents.filter((c) => c.type === "file");
|
|
128
|
+
const links = contents.filter((c) => c.type === "url");
|
|
129
|
+
return {
|
|
130
|
+
direccion: moduleAddr(courseId, m.id),
|
|
131
|
+
id: m.id,
|
|
132
|
+
nombre: m.name,
|
|
133
|
+
tipo: m.modname,
|
|
134
|
+
url: m.url,
|
|
135
|
+
descripcion: (summarized ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
|
|
136
|
+
restriccion: htmlToText(m.availabilityinfo) || undefined,
|
|
137
|
+
fechas: m.dates?.length
|
|
138
|
+
? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
|
|
139
|
+
: undefined,
|
|
140
|
+
archivos: files.length ? files.map(toFile) : undefined,
|
|
141
|
+
enlaces: links.length ? links.map((l) => l.fileurl) : undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, isAbsolute } from "node:path";
|
|
3
|
+
import { htmlToText, textToHtml } from "../../shared/html.js";
|
|
4
|
+
import { checkFiles, formatSize, } from "../../shared/uploads.js";
|
|
5
|
+
import { getAssignments, getSubmissionStatus, getUnusedDraftItemId, removeSubmission, saveSubmission, submitForGrading, } from "../client/client.js";
|
|
6
|
+
import { fetchFile, uploadDraftFiles } from "../client/moodle-api.js";
|
|
7
|
+
import { viewSubmission } from "./activities.js";
|
|
8
|
+
import { parseAddress } from "./address.js";
|
|
9
|
+
import { userFacingUrl } from "./files.js";
|
|
10
|
+
import { findModule } from "./navigate.js";
|
|
11
|
+
/** El máximo por archivo que deja subir el aula (`usermaxuploadfilesize` del sitio, 20 MB). */
|
|
12
|
+
const SITE_MAX_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
/** La tarea con su configuración y el intento actual del usuario, o un error que dice por qué no. */
|
|
14
|
+
async function openAssignment(session, rawAddress) {
|
|
15
|
+
const address = parseAddress(rawAddress);
|
|
16
|
+
if (address.level !== "module")
|
|
17
|
+
throw new Error(`"${rawAddress}" no es la dirección de un módulo.`);
|
|
18
|
+
const { module } = await findModule(session, address.courseId, address.cmid);
|
|
19
|
+
if (module.modname !== "assign") {
|
|
20
|
+
throw new Error(`"${rawAddress}" es un módulo de tipo ${module.modname}; solo se entregan tareas (assign).`);
|
|
21
|
+
}
|
|
22
|
+
const [assignments, status] = await Promise.all([
|
|
23
|
+
getAssignments(session, address.courseId),
|
|
24
|
+
getSubmissionStatus(session, module.instance),
|
|
25
|
+
]);
|
|
26
|
+
const assignment = assignments.find((a) => a.cmid === module.id);
|
|
27
|
+
const attempt = status.lastattempt;
|
|
28
|
+
if (!assignment || !attempt)
|
|
29
|
+
throw new Error(`No se pudo leer la configuración de la tarea ${rawAddress}.`);
|
|
30
|
+
const config = (plugin, name) => assignment.configs.find((c) => c.subtype === "assignsubmission" && c.plugin === plugin && c.name === name)
|
|
31
|
+
?.value;
|
|
32
|
+
const limits = {
|
|
33
|
+
maxFiles: Number(config("file", "maxfilesubmissions")) || undefined,
|
|
34
|
+
maxBytes: Number(config("file", "maxsubmissionsizebytes")) || SITE_MAX_BYTES,
|
|
35
|
+
extensions: extensions(config("file", "filetypeslist") || ""),
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
address,
|
|
39
|
+
module,
|
|
40
|
+
assignment,
|
|
41
|
+
attempt,
|
|
42
|
+
config,
|
|
43
|
+
limits,
|
|
44
|
+
acceptsFiles: config("file", "enabled") === "1",
|
|
45
|
+
acceptsText: config("onlinetext", "enabled") === "1",
|
|
46
|
+
submission: attempt.teamsubmission ?? attempt.submission,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const CLOSED = "La tarea no acepta cambios: ya está enviada, venció, está bloqueada o todavía no abre. " +
|
|
50
|
+
"Abrila con aula_open para ver el estado y las fechas.";
|
|
51
|
+
/** Abre un link para recibir los archivos de una entrega en modo HTTP, con los límites de la tarea. */
|
|
52
|
+
export async function createUploadLink(session, rawAddress, inbox, owner) {
|
|
53
|
+
const { module, attempt, acceptsFiles, limits } = await openAssignment(session, rawAddress);
|
|
54
|
+
if (!acceptsFiles)
|
|
55
|
+
throw new Error("Esta tarea no acepta archivos.");
|
|
56
|
+
if (!attempt.canedit)
|
|
57
|
+
throw new Error(CLOSED);
|
|
58
|
+
const { id, url, expiresAt } = inbox.open(owner, `Entrega: ${module.name}`, limits);
|
|
59
|
+
return {
|
|
60
|
+
subida: id,
|
|
61
|
+
url,
|
|
62
|
+
vence: expiresAt,
|
|
63
|
+
acepta: {
|
|
64
|
+
maximo: limits.maxFiles,
|
|
65
|
+
tamanioMaximo: limits.maxBytes,
|
|
66
|
+
extensiones: limits.extensions?.join(", "),
|
|
67
|
+
},
|
|
68
|
+
conTerminal: `curl -F "archivos=@/ruta/al/archivo.pdf" ${url}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Deja la entrega con los archivos y el texto pedidos, y la envía si hace falta. Lo que no se pasa
|
|
73
|
+
* se conserva: Moodle reemplaza cada plugin con lo que recibe, así que se le reenvía lo que ya tenía.
|
|
74
|
+
*/
|
|
75
|
+
export async function submitAssignment(session, rawAddress, request, source) {
|
|
76
|
+
const { address, module, assignment, attempt, config, limits, acceptsFiles, acceptsText, submission } = await openAssignment(session, rawAddress);
|
|
77
|
+
const usesDrafts = assignment.submissiondrafts === 1;
|
|
78
|
+
const fileChanges = request.files !== undefined || request.upload !== undefined;
|
|
79
|
+
const changes = fileChanges || request.text !== undefined;
|
|
80
|
+
// Todo lo que se puede validar va antes de tocar nada, para no dejar la entrega a medias.
|
|
81
|
+
if (!changes && !(usesDrafts && request.send)) {
|
|
82
|
+
throw new Error("Pasá archivos, `texto` o los dos: no hay nada para entregar.");
|
|
83
|
+
}
|
|
84
|
+
if (changes && !attempt.canedit)
|
|
85
|
+
throw new Error(CLOSED);
|
|
86
|
+
if (fileChanges && !acceptsFiles)
|
|
87
|
+
throw new Error("Esta tarea no acepta archivos.");
|
|
88
|
+
if (request.text !== undefined && !acceptsText)
|
|
89
|
+
throw new Error("Esta tarea no acepta texto en línea.");
|
|
90
|
+
const confirm = usesDrafts && request.send;
|
|
91
|
+
if (confirm && assignment.requiresubmissionstatement === 1 && !request.acceptStatement) {
|
|
92
|
+
throw new Error("Para enviarla hay que aceptar esta declaración: «" +
|
|
93
|
+
htmlToText(assignment.submissionstatement) +
|
|
94
|
+
"». Mostrásela al usuario y, si la acepta, volvé a llamar con `aceptoDeclaracion: true`.");
|
|
95
|
+
}
|
|
96
|
+
if (changes) {
|
|
97
|
+
const files = acceptsFiles ? await collectFiles(session, request, submittedFiles(submission), source) : [];
|
|
98
|
+
checkFiles(files, limits);
|
|
99
|
+
const text = acceptsText
|
|
100
|
+
? currentOrNewText(submission, request.text, Number(config("onlinetext", "wordlimit")))
|
|
101
|
+
: null;
|
|
102
|
+
if (!files.length && !htmlToText(text?.html)) {
|
|
103
|
+
await removeSubmission(session, assignment.id);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await saveSubmission(session, assignment.id, {
|
|
107
|
+
filesItemId: acceptsFiles
|
|
108
|
+
? files.length
|
|
109
|
+
? await uploadDraftFiles(session, files)
|
|
110
|
+
: await getUnusedDraftItemId(session)
|
|
111
|
+
: undefined,
|
|
112
|
+
text: text ? { ...text, itemid: await getUnusedDraftItemId(session) } : undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (request.upload && !source.local)
|
|
116
|
+
source.inbox.close(request.upload);
|
|
117
|
+
}
|
|
118
|
+
if (confirm)
|
|
119
|
+
await submitForGrading(session, assignment.id, request.acceptStatement);
|
|
120
|
+
const after = await viewSubmission(session, address.courseId, module);
|
|
121
|
+
return {
|
|
122
|
+
direccion: rawAddress,
|
|
123
|
+
estado: after.estado,
|
|
124
|
+
// Con borradores y sin confirmar, lo subido no cuenta todavía.
|
|
125
|
+
aviso: usesDrafts && !request.send && after.estado === "borrador"
|
|
126
|
+
? "Quedó como borrador: no cuenta como entregada hasta confirmar el envío."
|
|
127
|
+
: undefined,
|
|
128
|
+
archivos: after.archivos?.map(({ nombre, tamanio, url }) => ({ nombre, tamanio, url })),
|
|
129
|
+
texto: after.texto,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function submittedFiles(submission) {
|
|
133
|
+
return (submission?.plugins ?? [])
|
|
134
|
+
.filter((p) => p.type === "file")
|
|
135
|
+
.flatMap((p) => p.fileareas?.flatMap((a) => a.files ?? []) ?? []);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Junta los bytes de la lista final: los de `files` (o los que ya estaban, si no se pasa) más los de
|
|
139
|
+
* la subida. Los que ya estaban hay que volver a subirlos porque el borrador reemplaza el área entera.
|
|
140
|
+
*/
|
|
141
|
+
async function collectFiles(session, request, current, source) {
|
|
142
|
+
const entries = request.files ?? current.map((f) => userFacingUrl(f.fileurl));
|
|
143
|
+
const collected = [];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const existing = current.find((f) => userFacingUrl(f.fileurl) === entry || f.fileurl === entry);
|
|
146
|
+
collected.push(existing
|
|
147
|
+
? {
|
|
148
|
+
name: existing.filename,
|
|
149
|
+
bytes: (await fetchFile(session, new URL(existing.fileurl), SITE_MAX_BYTES)).bytes,
|
|
150
|
+
}
|
|
151
|
+
: await readLocal(entry, source));
|
|
152
|
+
}
|
|
153
|
+
if (request.upload !== undefined) {
|
|
154
|
+
if (source.local)
|
|
155
|
+
throw new Error("`subida` es del modo HTTP; acá los archivos van como ruta en `archivos`.");
|
|
156
|
+
collected.push(...source.inbox.take(request.upload, source.owner));
|
|
157
|
+
}
|
|
158
|
+
return collected;
|
|
159
|
+
}
|
|
160
|
+
async function readLocal(path, source) {
|
|
161
|
+
if (/^https?:\/\//i.test(path)) {
|
|
162
|
+
throw new Error(`"${path}" no es un archivo de esta entrega. Las URLs sirven solo para conservar archivos ` +
|
|
163
|
+
"que ya están subidos (las `url` de `entrega.archivos` en aula_open).");
|
|
164
|
+
}
|
|
165
|
+
if (!source.local) {
|
|
166
|
+
throw new Error(`"${path}" no es un archivo de esta entrega, y este servidor no lee archivos de tu dispositivo: ` +
|
|
167
|
+
"los nuevos se suben con aula_assign_upload_link y se pasan con `subida`.");
|
|
168
|
+
}
|
|
169
|
+
if (!isAbsolute(path))
|
|
170
|
+
throw new Error(`Pasá la ruta absoluta del archivo; "${path}" es relativa.`);
|
|
171
|
+
let size;
|
|
172
|
+
try {
|
|
173
|
+
const info = await stat(path);
|
|
174
|
+
if (!info.isFile())
|
|
175
|
+
throw new Error(`"${path}" no es un archivo.`);
|
|
176
|
+
size = info.size;
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (e.code === "ENOENT")
|
|
180
|
+
throw new Error(`No existe el archivo "${path}".`);
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
// El límite de la tarea lo chequea checkFiles; esto solo evita cargar en memoria algo que no puede entrar.
|
|
184
|
+
if (size > SITE_MAX_BYTES) {
|
|
185
|
+
throw new Error(`"${basename(path)}" pesa ${formatSize(size)} y el aula acepta hasta ${formatSize(SITE_MAX_BYTES)} por archivo.`);
|
|
186
|
+
}
|
|
187
|
+
return { name: basename(path), bytes: new Uint8Array(await readFile(path)) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Moodle valida los tipos en el formulario web, no en el web service: si no lo chequeamos, un .docx
|
|
191
|
+
* entra en una tarea que pide PDF. Los grupos de tipos ("document", "image") no se pueden resolver
|
|
192
|
+
* sin la tabla de Moodle, así que con un grupo en la lista no se restringe.
|
|
193
|
+
*/
|
|
194
|
+
function extensions(types) {
|
|
195
|
+
const list = types
|
|
196
|
+
.split(/[\s,;]+/)
|
|
197
|
+
.map((t) => t.trim().toLowerCase())
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
return list.length && list.every((t) => t.startsWith(".")) ? list : undefined;
|
|
200
|
+
}
|
|
201
|
+
/** El texto nuevo en HTML, o el que ya estaba para no borrarlo al guardar solo archivos. */
|
|
202
|
+
function currentOrNewText(submission, text, wordLimit) {
|
|
203
|
+
if (text === undefined) {
|
|
204
|
+
const field = submission?.plugins
|
|
205
|
+
?.find((p) => p.type === "onlinetext")
|
|
206
|
+
?.editorfields?.find((e) => e.name === "onlinetext");
|
|
207
|
+
return { html: field?.text ?? "", format: field?.format ?? 1 };
|
|
208
|
+
}
|
|
209
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
210
|
+
if (wordLimit && words > wordLimit) {
|
|
211
|
+
throw new Error(`El texto tiene ${words} palabras y la tarea acepta hasta ${wordLimit}.`);
|
|
212
|
+
}
|
|
213
|
+
return { html: text.trim() ? textToHtml(text) : "", format: 1 };
|
|
214
|
+
}
|
|
@@ -1,18 +1,27 @@
|
|
|
1
|
+
import { CookieJar } from "tough-cookie";
|
|
1
2
|
import { ask, askHidden } from "../shared/input.js";
|
|
2
3
|
import { logger } from "../shared/logger.js";
|
|
4
|
+
import { SqliteStore } from "../shared/store/sqlite-store.js";
|
|
3
5
|
import { saveCredentials } from "./client/credentials.js";
|
|
4
6
|
import { LocalSession } from "./client/local-session.js";
|
|
7
|
+
import { fetchToken } from "./client/sso-login.js";
|
|
8
|
+
import { AULA_PLATFORM } from "./client/stored-session.js";
|
|
5
9
|
/**
|
|
6
10
|
* `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
|
|
7
11
|
* y obtiene el primer token. Después el servidor renueva solo.
|
|
12
|
+
* Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
|
|
8
13
|
*/
|
|
9
|
-
export async function runLogin() {
|
|
10
|
-
const username = await ask("Usuario institucional de la UTN: ");
|
|
14
|
+
export async function runLogin(http) {
|
|
15
|
+
const username = (await ask("Usuario institucional de la UTN: ")).trim();
|
|
11
16
|
if (!username)
|
|
12
17
|
throw new Error("Falta el usuario.");
|
|
13
18
|
const password = await askHidden("Contraseña: ");
|
|
14
19
|
const credentials = { username, password };
|
|
15
|
-
|
|
20
|
+
let accessToken = null;
|
|
21
|
+
if (http)
|
|
22
|
+
accessToken = await loginToStore(credentials);
|
|
23
|
+
else
|
|
24
|
+
await new LocalSession().login(credentials);
|
|
16
25
|
// Se guardan recién después de comprobar que el SSO las aceptó.
|
|
17
26
|
if (await saveCredentials(credentials)) {
|
|
18
27
|
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
@@ -20,5 +29,22 @@ export async function runLogin() {
|
|
|
20
29
|
else {
|
|
21
30
|
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
|
|
22
31
|
}
|
|
23
|
-
|
|
32
|
+
if (accessToken) {
|
|
33
|
+
logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
|
|
34
|
+
` Authorization: Bearer ${accessToken}\n`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
logger.info("Listo: ya podés usar el servidor MCP.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
|
|
41
|
+
async function loginToStore(credentials) {
|
|
42
|
+
const jar = new CookieJar();
|
|
43
|
+
const token = await fetchToken(jar, credentials);
|
|
44
|
+
const store = await SqliteStore.open();
|
|
45
|
+
const userId = await store.findOrCreateUser(credentials.username);
|
|
46
|
+
// En minúscula, igual que en `users`: es con lo que se compara al renovar.
|
|
47
|
+
const data = { username: credentials.username.toLowerCase(), token, cookies: await jar.serialize() };
|
|
48
|
+
await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
|
|
49
|
+
return store.issueToken(userId, { kind: "access", clientId: "local" });
|
|
24
50
|
}
|