utn-mcp 0.2.1 → 0.6.7
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 +12 -0
- package/build/aula-virtual/client/client.js +135 -3
- package/build/aula-virtual/client/moodle-api.js +13 -13
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +101 -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/tools.js +190 -12
- package/build/server.js +1 -1
- 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/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
package/package.json
CHANGED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export const RAIZ = "/";
|
|
2
|
-
export const cursoAddr = (courseId) => `/curso/${courseId}`;
|
|
3
|
-
export const moduloAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
|
|
4
|
-
export const discusionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
|
|
5
|
-
const FORMATOS = [
|
|
6
|
-
"/",
|
|
7
|
-
"/curso/{courseId}",
|
|
8
|
-
"/curso/{courseId}/mod/{cmid}",
|
|
9
|
-
"/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
|
|
10
|
-
].join(", ");
|
|
11
|
-
const PATRON = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
|
|
12
|
-
export function parseAddress(raw) {
|
|
13
|
-
const path = raw.trim().replace(/\/+$/, "") || "/";
|
|
14
|
-
if (path === "/")
|
|
15
|
-
return { nivel: "raiz" };
|
|
16
|
-
const m = PATRON.exec(path);
|
|
17
|
-
if (!m)
|
|
18
|
-
throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATOS}`);
|
|
19
|
-
const [, curso, cmid, disc] = m;
|
|
20
|
-
const courseId = Number(curso);
|
|
21
|
-
if (!cmid)
|
|
22
|
-
return { nivel: "curso", courseId };
|
|
23
|
-
if (!disc)
|
|
24
|
-
return { nivel: "modulo", courseId, cmid: Number(cmid) };
|
|
25
|
-
return { nivel: "discusion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
|
|
26
|
-
}
|
|
27
|
-
/** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
|
|
28
|
-
export function padre(address) {
|
|
29
|
-
switch (address.nivel) {
|
|
30
|
-
case "raiz":
|
|
31
|
-
return null;
|
|
32
|
-
case "curso":
|
|
33
|
-
return RAIZ;
|
|
34
|
-
case "modulo":
|
|
35
|
-
return cursoAddr(address.courseId);
|
|
36
|
-
case "discusion":
|
|
37
|
-
return moduloAddr(address.courseId, address.cmid);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { abrirDocumento, FormatoNoSoportado } from "../shared/documents/index.js";
|
|
2
|
-
import { AULA_BASE_URL } from "./client/constants.js";
|
|
3
|
-
import { fetchFile } from "./client/moodle-api.js";
|
|
4
|
-
const ORIGEN = new URL(AULA_BASE_URL).origin;
|
|
5
|
-
const RUTA_WS = "/webservice/pluginfile.php/";
|
|
6
|
-
const RUTA_WEB = "/pluginfile.php/";
|
|
7
|
-
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
8
|
-
const PRESUPUESTO = 30_000;
|
|
9
|
-
/** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
|
|
10
|
-
const COSTO_IMAGEN = 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 urlParaUsuario(fileurl) {
|
|
19
|
-
return fileurl.replace(`${ORIGEN}${RUTA_WS}`, `${ORIGEN}${RUTA_WEB}`);
|
|
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 urlDeDescarga(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 ruta = u.pathname.startsWith(RUTA_WS) ? u.pathname : u.pathname.startsWith(RUTA_WEB) ? RUTA_WS + u.pathname.slice(RUTA_WEB.length) : null;
|
|
34
|
-
if (u.origin !== ORIGEN || !ruta) {
|
|
35
|
-
throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGEN}${RUTA_WEB}...).`);
|
|
36
|
-
}
|
|
37
|
-
u.pathname = ruta;
|
|
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 `desde` tantas como entren en el presupuesto. */
|
|
43
|
-
export async function leerArchivo(session, url, desde = 1) {
|
|
44
|
-
const descarga = urlDeDescarga(url);
|
|
45
|
-
const archivo = decodeURIComponent(descarga.pathname.split("/").pop() ?? "");
|
|
46
|
-
const paraUsuario = urlParaUsuario(descarga.href);
|
|
47
|
-
const { bytes } = await fetchFile(session, descarga, MAX_BYTES);
|
|
48
|
-
let documento;
|
|
49
|
-
try {
|
|
50
|
-
documento = await abrirDocumento(bytes, archivo);
|
|
51
|
-
}
|
|
52
|
-
catch (e) {
|
|
53
|
-
if (e instanceof FormatoNoSoportado) {
|
|
54
|
-
throw new Error(`${e.message} El usuario lo puede bajar desde ${paraUsuario}`);
|
|
55
|
-
}
|
|
56
|
-
throw e;
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
if (desde > documento.total) {
|
|
60
|
-
throw new Error(`"${archivo}" termina en ${documento.unidad} ${documento.total}; se pidió desde ${desde}.`);
|
|
61
|
-
}
|
|
62
|
-
const partes = [];
|
|
63
|
-
let gastado = 0;
|
|
64
|
-
let n = desde;
|
|
65
|
-
for (; n <= documento.total; n++) {
|
|
66
|
-
const parte = await documento.parte(n);
|
|
67
|
-
const costo = "texto" in parte ? parte.texto.length : COSTO_IMAGEN;
|
|
68
|
-
// Siempre al menos una parte, aunque sola se pase del presupuesto.
|
|
69
|
-
if (partes.length > 0 && gastado + costo > PRESUPUESTO)
|
|
70
|
-
break;
|
|
71
|
-
partes.push({ ...parte, numero: n });
|
|
72
|
-
gastado += costo;
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
archivo,
|
|
76
|
-
formato: documento.formato,
|
|
77
|
-
unidad: documento.unidad,
|
|
78
|
-
total: documento.total,
|
|
79
|
-
desde,
|
|
80
|
-
hasta: n - 1,
|
|
81
|
-
siguiente: n <= documento.total ? n : undefined,
|
|
82
|
-
urlParaUsuario: paraUsuario,
|
|
83
|
-
partes,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
finally {
|
|
87
|
-
await documento.cerrar();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { htmlToSnippet, htmlToText } from "../shared/html.js";
|
|
2
|
-
import { cursoAddr, discusionAddr, moduloAddr, padre, parseAddress, RAIZ } from "./address.js";
|
|
3
|
-
import { getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, POR_PAGINA, } from "./client/client.js";
|
|
4
|
-
import { urlParaUsuario } from "./files.js";
|
|
5
|
-
const isoDate = (seconds) => (seconds ? new Date(seconds * 1000).toISOString() : null);
|
|
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, direccion, pagina = 0) {
|
|
11
|
-
const address = parseAddress(direccion);
|
|
12
|
-
const base = { direccion: formatear(address), padre: padre(address) };
|
|
13
|
-
switch (address.nivel) {
|
|
14
|
-
case "raiz":
|
|
15
|
-
return { ...base, cursos: await verCursos(session) };
|
|
16
|
-
case "curso":
|
|
17
|
-
return { ...base, secciones: await verCurso(session, address.courseId) };
|
|
18
|
-
case "modulo":
|
|
19
|
-
return { ...base, ...(await verModulo(session, address, pagina)) };
|
|
20
|
-
case "discusion":
|
|
21
|
-
return { ...base, ...(await verDiscusion(session, address.discussionId)) };
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
function formatear(address) {
|
|
25
|
-
switch (address.nivel) {
|
|
26
|
-
case "raiz":
|
|
27
|
-
return RAIZ;
|
|
28
|
-
case "curso":
|
|
29
|
-
return cursoAddr(address.courseId);
|
|
30
|
-
case "modulo":
|
|
31
|
-
return moduloAddr(address.courseId, address.cmid);
|
|
32
|
-
case "discusion":
|
|
33
|
-
return discusionAddr(address.courseId, address.cmid, address.discussionId);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
async function verCursos(session) {
|
|
37
|
-
const courses = await getUserCourses(session);
|
|
38
|
-
return courses.map((c) => ({
|
|
39
|
-
direccion: cursoAddr(c.id),
|
|
40
|
-
id: c.id,
|
|
41
|
-
nombre: c.fullname,
|
|
42
|
-
nombreCorto: c.shortname,
|
|
43
|
-
visible: c.visible === 1,
|
|
44
|
-
progreso: c.progress ?? null,
|
|
45
|
-
ultimoAcceso: isoDate(c.lastaccess),
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
/** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
|
|
49
|
-
async function verCurso(session, courseId) {
|
|
50
|
-
const sections = await getCourseSections(session, courseId);
|
|
51
|
-
return sections
|
|
52
|
-
.filter((s) => s.uservisible !== false)
|
|
53
|
-
.map((s) => ({
|
|
54
|
-
nombre: s.name,
|
|
55
|
-
numero: s.section,
|
|
56
|
-
resumen: htmlToText(s.summary) || undefined,
|
|
57
|
-
modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
|
|
58
|
-
}))
|
|
59
|
-
.filter((s) => s.resumen || s.modulos.length > 0);
|
|
60
|
-
}
|
|
61
|
-
async function verModulo(session, address, pagina) {
|
|
62
|
-
const { seccion, module } = await buscarModulo(session, address.courseId, address.cmid);
|
|
63
|
-
const modulo = toModule(module, address.courseId);
|
|
64
|
-
if (module.modname !== "forum")
|
|
65
|
-
return { seccion, modulo };
|
|
66
|
-
// Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión.
|
|
67
|
-
const { discussions } = await getForumDiscussions(session, module.instance, pagina);
|
|
68
|
-
return {
|
|
69
|
-
seccion,
|
|
70
|
-
modulo,
|
|
71
|
-
discusiones: discussions.map((d) => ({
|
|
72
|
-
direccion: discusionAddr(address.courseId, address.cmid, d.discussion),
|
|
73
|
-
asunto: d.subject,
|
|
74
|
-
autor: d.userfullname,
|
|
75
|
-
fecha: isoDate(d.created),
|
|
76
|
-
ultimaActividad: isoDate(d.timemodified),
|
|
77
|
-
respuestas: d.numreplies,
|
|
78
|
-
fijada: d.pinned || undefined,
|
|
79
|
-
adjuntos: d.attachment || undefined,
|
|
80
|
-
resumen: htmlToSnippet(d.message),
|
|
81
|
-
})),
|
|
82
|
-
pagina,
|
|
83
|
-
hayMasDiscusiones: discussions.length === POR_PAGINA || undefined,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
async function verDiscusion(session, discussionId) {
|
|
87
|
-
const { posts } = await getDiscussionPosts(session, discussionId);
|
|
88
|
-
const visibles = posts.filter((p) => !p.isdeleted);
|
|
89
|
-
return {
|
|
90
|
-
asunto: visibles[0]?.subject,
|
|
91
|
-
publicaciones: visibles.map((p) => ({
|
|
92
|
-
autor: p.author.fullname,
|
|
93
|
-
fecha: isoDate(p.timecreated),
|
|
94
|
-
respuestaA: p.parentid ?? undefined,
|
|
95
|
-
id: p.id,
|
|
96
|
-
mensaje: htmlToText(p.message),
|
|
97
|
-
adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
|
|
98
|
-
})),
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
|
|
102
|
-
async function buscarModulo(session, courseId, cmid) {
|
|
103
|
-
const sections = await getCourseSections(session, courseId, cmid);
|
|
104
|
-
for (const section of sections) {
|
|
105
|
-
const module = section.modules.find((m) => m.id === cmid);
|
|
106
|
-
if (!module)
|
|
107
|
-
continue;
|
|
108
|
-
if (!module.uservisible)
|
|
109
|
-
throw new Error(`No tenés acceso al módulo ${cmid}.`);
|
|
110
|
-
return { seccion: section.name, module };
|
|
111
|
-
}
|
|
112
|
-
throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
|
|
113
|
-
}
|
|
114
|
-
/** `resumido` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
|
|
115
|
-
function toModule(m, courseId, resumido = false) {
|
|
116
|
-
const contents = m.contents ?? [];
|
|
117
|
-
const archivos = contents.filter((c) => c.type === "file");
|
|
118
|
-
const enlaces = contents.filter((c) => c.type === "url");
|
|
119
|
-
return {
|
|
120
|
-
direccion: moduloAddr(courseId, m.id),
|
|
121
|
-
id: m.id,
|
|
122
|
-
nombre: m.name,
|
|
123
|
-
tipo: m.modname,
|
|
124
|
-
url: m.url,
|
|
125
|
-
descripcion: (resumido ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
|
|
126
|
-
restriccion: htmlToText(m.availabilityinfo) || undefined,
|
|
127
|
-
fechas: m.dates?.length
|
|
128
|
-
? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
|
|
129
|
-
: undefined,
|
|
130
|
-
archivos: archivos.length ? archivos.map(toFile) : undefined,
|
|
131
|
-
enlaces: enlaces.length ? enlaces.map((l) => l.fileurl) : undefined,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
function toFile(f) {
|
|
135
|
-
return {
|
|
136
|
-
nombre: f.filename,
|
|
137
|
-
ruta: f.filepath || undefined,
|
|
138
|
-
tamanio: f.filesize,
|
|
139
|
-
mimetype: f.mimetype,
|
|
140
|
-
modificado: isoDate(f.timemodified),
|
|
141
|
-
// Sirve para pasársela al usuario y también para leerlo con aula_read_file.
|
|
142
|
-
url: urlParaUsuario(f.fileurl),
|
|
143
|
-
};
|
|
144
|
-
}
|
|
File without changes
|