11 |
12 | UTN.BA Helper is a Chrome extension that makes navigating and using the UTN.BA - FRBA website easier.
13 |
14 | Features description of this extension are written in Spanish as the target users are from Argentina.
15 |
16 | ## Features
17 |
18 | UTN.BA Helper facilita el uso de la web de la UTN - FRBA.
19 |
20 | - Colecta anónimamente distintos datos, para ser utilizados en las distintas secciones, como:
21 | - Las encuestas docentes para poder publicar esta información en la sección de "Buscar Docentes" e incluso mostrarla al momento de inscribirse a un curso.
22 | - Los horarios de las cursadas para mostrar esta información al momento de inscribirse a un nuevo curso, y poder intentar predecir cuál va a ser el profesor que va a estar en cada cursada.
23 |
24 | - Al momento de inscribirse a materias, muestra los profesores que estuvieron en cada cursada, basándose en data colectada, para así poder saber qué profesor va a estar en cada curso.
25 |
26 | - Agrega nuevas secciones bajo el menu "UTN.BA Helper":
27 | - "Buscar Docentes", donde se puede ver información colectada, entre ello, la encuesta docente.
28 | - "Buscar Cursos", donde se puede ver información de cursos pasados, como horarios, profesores que estuvieron en cada uno, etc.
29 | - "Seguimiento de Plan":
30 | - Se visualiza el estado actual del plan, viendo materias aprobadas, habilitadas para rendir final, por cursar, etc.
31 | - Peso académico, cantidad de finales aprobados y desaprobados.
32 | - Promedio de notas ponderadas (según Ordenanza Nº 1549) y no ponderadas, contando y sin contar desaprobados.
33 |
34 | - Agrega el nombre de la materia en la grilla de horarios en la sección de Agenda.
35 |
36 | ## Screenshots:
37 |
38 |
39 |
40 | ---
41 |
42 |
43 |
44 | ---
45 |
46 |
47 |
48 | ---
49 |
50 |
51 |
52 | ---
53 |
54 |
55 |
56 | ## Glossary
57 |
58 | | English | Spanish |
59 | |---------:|:------------|
60 | | Course | Materia |
61 | | Class | Cursada |
62 | | Elective | Optativa |
63 | | Grade | Nota |
64 | | Signed | Firmada |
65 | | Passed | Aprobada |
66 | | Failed | Desaprobada |
67 |
68 |
69 | ## Stargazers over time
70 | [](https://starchart.cc/pablomatiasgomez/utn.ba-helper)
71 |
--------------------------------------------------------------------------------
/src/guarani/Errors.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | LoggedOutError,
3 | RedirectedToHomeError,
4 | GuaraniBackendError,
5 | MissingStudentIdError,
6 | isIgnoredError,
7 | stringifyError
8 | } from './Errors.js';
9 |
10 | describe('isIgnoredError', () => {
11 | it('should return true for LoggedOutError', () => {
12 | const error = new LoggedOutError();
13 | expect(isIgnoredError(error)).toBe(true);
14 | });
15 |
16 | it('should return true for GuaraniBackendError', () => {
17 | const error = new GuaraniBackendError('Backend error');
18 | expect(isIgnoredError(error)).toBe(true);
19 | });
20 |
21 | it('should return true for MissingStudentIdError', () => {
22 | const error = new MissingStudentIdError('No student ID');
23 | expect(isIgnoredError(error)).toBe(true);
24 | });
25 |
26 | it('should return false for RedirectedToHomeError', () => {
27 | const error = new RedirectedToHomeError();
28 | expect(isIgnoredError(error)).toBe(false);
29 | });
30 |
31 | it('should return false for generic Error', () => {
32 | const error = new Error('Generic error');
33 | expect(isIgnoredError(error)).toBe(false);
34 | });
35 |
36 | it('should return false for null', () => {
37 | expect(isIgnoredError(null)).toBe(false);
38 | });
39 |
40 | it('should return false for undefined', () => {
41 | expect(isIgnoredError(undefined)).toBe(false);
42 | });
43 |
44 | it('should return true if cause is an ignored error', () => {
45 | const cause = new LoggedOutError();
46 | const error = new Error('Wrapper error', {cause});
47 | expect(isIgnoredError(error)).toBe(true);
48 | });
49 |
50 | it('should return true if cause of cause is an ignored error', () => {
51 | const rootCause = new GuaraniBackendError('Root cause');
52 | const cause = new Error('Middle error', {cause: rootCause});
53 | const error = new Error('Top error', {cause});
54 | expect(isIgnoredError(error)).toBe(true);
55 | });
56 |
57 | it('should return false if no error in cause chain is ignored', () => {
58 | const rootCause = new RedirectedToHomeError();
59 | const cause = new Error('Middle error', {cause: rootCause});
60 | const error = new Error('Top error', {cause});
61 | expect(isIgnoredError(error)).toBe(false);
62 | });
63 |
64 | it('should handle deeply nested cause chains', () => {
65 | const level4 = new MissingStudentIdError('Deep error');
66 | const level3 = new Error('Level 3', {cause: level4});
67 | const level2 = new Error('Level 2', {cause: level3});
68 | const level1 = new Error('Level 1', {cause: level2});
69 | const level0 = new Error('Level 0', {cause: level1});
70 | expect(isIgnoredError(level0)).toBe(true);
71 | });
72 | });
73 |
74 | describe('stringifyError', () => {
75 | it('should stringify Error with stack', () => {
76 | const error = new Error('Test error');
77 | const result = stringifyError(error);
78 | expect(result).toContain('Test error');
79 | expect(result).toContain('Error: Test error');
80 | });
81 |
82 | it('should stringify custom error classes', () => {
83 | const error = new LoggedOutError('User logged out');
84 | const result = stringifyError(error);
85 | expect(result).toContain('LoggedOutError');
86 | expect(result).toContain('User logged out');
87 | });
88 |
89 | it('should include cause in stringification', () => {
90 | const cause = new Error('Original error');
91 | const error = new Error('Wrapper error', {cause});
92 | const result = stringifyError(error);
93 | expect(result).toContain('Wrapper error');
94 | expect(result).toContain('Caused by:');
95 | expect(result).toContain('Original error');
96 | });
97 |
98 | it('should include nested causes in stringification', () => {
99 | const rootCause = new Error('Root cause');
100 | const middleCause = new Error('Middle error', {cause: rootCause});
101 | const error = new Error('Top error', {cause: middleCause});
102 | const result = stringifyError(error);
103 | expect(result).toContain('Top error');
104 | expect(result).toContain('Caused by:');
105 | expect(result).toContain('Middle error');
106 | expect(result).toContain('Root cause');
107 | });
108 |
109 | it('should stringify plain objects', () => {
110 | const obj = {foo: 'bar', baz: 123};
111 | const result = stringifyError(obj);
112 | expect(result).toBe(JSON.stringify(obj));
113 | });
114 |
115 | it('should stringify primitive values', () => {
116 | expect(stringifyError('string error')).toBe('string error');
117 | expect(stringifyError(123)).toBe('123');
118 | expect(stringifyError(true)).toBe('true');
119 | });
120 |
121 | it('should handle null and undefined', () => {
122 | expect(stringifyError(null)).toBe('null');
123 | expect(stringifyError(undefined)).toBe('undefined');
124 | });
125 |
126 | it('should handle errors without stack property', () => {
127 | const error = new Error('Test');
128 | delete error.stack;
129 | const result = stringifyError(error);
130 | expect(result).toContain('Test');
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/src/guarani/DataCollector.js:
--------------------------------------------------------------------------------
1 | import {trace} from "@embrace-io/web-sdk";
2 |
3 | const LOCAL_STORAGE_DATA_COLLECTOR_KEY = "UtnBaHelper.DataCollector";
4 | const ONE_DAY_MS = 24 * 60 * 60 * 1000;
5 |
6 | export class DataCollector {
7 | #store;
8 | #pagesDataParser;
9 | #apiConnector;
10 | #hashedStudentId;
11 |
12 | constructor(store, pagesDataParser, apiConnector) {
13 | this.#store = store;
14 | this.#pagesDataParser = pagesDataParser;
15 | this.#apiConnector = apiConnector;
16 | }
17 |
18 | #getHashedStudentId() {
19 | if (this.#hashedStudentId) return this.#hashedStudentId;
20 |
21 | let studentId = this.#pagesDataParser.getStudentId();
22 | this.#hashedStudentId = this.#hashCode(studentId);
23 | return this.#hashedStudentId;
24 | }
25 |
26 | /**
27 | * Sends the user stat with the hashed student it to keep data anonymous.
28 | */
29 | logUserStat(pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount) {
30 | return this.#apiConnector.logUserStat(this.#getHashedStudentId(), pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount);
31 | }
32 |
33 | /**
34 | * Collects, every one day or more, background data such as:
35 | * - class schedules and professors
36 | * @returns {Promise}
37 | */
38 | collectBackgroundDataIfNeeded() {
39 | let hashedStudentId = this.#getHashedStudentId();
40 | // Save hashedStudentId to local storage, so that it can be used for surveys collection.
41 | return this.#store.saveHashedStudentIdToStore(hashedStudentId).then(() => {
42 | let lastTimeCollected = this.#getLastTimeCollectedForStudentId(hashedStudentId);
43 |
44 | let collectMethods = [
45 | {
46 | key: "schedules",
47 | minTime: ONE_DAY_MS,
48 | method: () => this.#collectClassSchedulesWithProfessors(),
49 | },
50 | {
51 | key: "planCourses",
52 | minTime: ONE_DAY_MS * 180,
53 | method: () => this.#collectStudentPlanCourses(),
54 | }
55 | ];
56 |
57 | let shouldSaveLastTimeCollected = false;
58 | let promise = Promise.resolve();
59 | collectMethods.filter(collectMethod => {
60 | // Never collected or min time has passed.
61 | return !lastTimeCollected[collectMethod.key] || Date.now() - lastTimeCollected[collectMethod.key] > collectMethod.minTime;
62 | }).forEach(collectMethod => {
63 | promise = promise.then(() => {
64 | const span = trace.startSpan("Collect-" + collectMethod.key);
65 | return collectMethod.method()
66 | .then(() => span.end())
67 | .catch((e) => {
68 | span.fail();
69 | throw e;
70 | });
71 | }).then(() => {
72 | // If at least one collect method is executed, we need to save the last time collected info to local storage.
73 | shouldSaveLastTimeCollected = true;
74 | lastTimeCollected[collectMethod.key] = Date.now();
75 | });
76 | });
77 |
78 | return promise.then(() => {
79 | if (shouldSaveLastTimeCollected) {
80 | this.#saveLastTimeCollected(hashedStudentId, lastTimeCollected);
81 | }
82 | });
83 | });
84 | }
85 |
86 | #collectClassSchedulesWithProfessors() {
87 | return Promise.all([
88 | this.#pagesDataParser.getClassSchedules(),
89 | this.#pagesDataParser.getProfessorClassesFromSurveys(),
90 | ]).then(results => {
91 | let classSchedules = results[0].concat(results[1]);
92 | if (classSchedules.length) {
93 | return this.#apiConnector.postClassSchedules(classSchedules);
94 | }
95 | });
96 | }
97 |
98 | #collectStudentPlanCourses() {
99 | return this.#pagesDataParser.getStudentPlanCourses().then(planCourses => {
100 | return this.#apiConnector.postCourses(planCourses);
101 | });
102 | }
103 |
104 | // -----
105 |
106 | #getLastTimeCollectedByHashedStudentId() {
107 | let lastTimeCollectedByHashedStudentId;
108 | try {
109 | // Don't know why, but some cases were failing with json parsing errors... We simply consider those as not present.
110 | lastTimeCollectedByHashedStudentId = JSON.parse(localStorage.getItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY));
111 | } catch (e) {
112 | console.error(`Error parsing localStorage item...`, e);
113 | }
114 | if (!lastTimeCollectedByHashedStudentId) {
115 | lastTimeCollectedByHashedStudentId = {};
116 | localStorage.setItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY, JSON.stringify(lastTimeCollectedByHashedStudentId));
117 | }
118 | return lastTimeCollectedByHashedStudentId;
119 | }
120 |
121 | #getLastTimeCollectedForStudentId(hashedStudentId) {
122 | return this.#getLastTimeCollectedByHashedStudentId()[hashedStudentId] || {};
123 | }
124 |
125 | #saveLastTimeCollected(hashedStudentId, lastTimeCollected) {
126 | let lastTimeCollectedByHashedStudentId = this.#getLastTimeCollectedByHashedStudentId();
127 | lastTimeCollectedByHashedStudentId[hashedStudentId] = lastTimeCollected;
128 | localStorage.setItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY, JSON.stringify(lastTimeCollectedByHashedStudentId));
129 | }
130 |
131 | // Used to make the studentId anonymous.
132 | #hashCode(str) {
133 | let hash = 0;
134 | for (let i = 0; i < str.length; i++) {
135 | hash = ((hash << 5) - hash) + str.charCodeAt(i);
136 | hash = hash & hash; // Convert to 32bit integer
137 | }
138 | return hash;
139 | }
140 |
141 | }
142 |
--------------------------------------------------------------------------------
/src/guarani/Utils.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import {Consts} from './Consts.js';
3 | import {isIgnoredError, stringifyError} from './Errors.js';
4 | import {log} from "@embrace-io/web-sdk";
5 | import {CustomPages} from './custompages/CustomPages.js';
6 |
7 | export class Utils {
8 | // TODO utils eventually shouldn't be instantiated and should be a set of functions.
9 | // But we need to get rid of using apiConnector here.
10 |
11 | #apiConnector;
12 | #failedToFetchErrors = 0;
13 |
14 | constructor(apiConnector) {
15 | this.#apiConnector = apiConnector;
16 | }
17 |
18 | // Related to the extension:
19 | delay(delayMs) {
20 | return result => new Promise(resolve => setTimeout(() => resolve(result), delayMs));
21 | }
22 |
23 | // TODO this is duplicated in the ApiConnector.
24 | backgroundFetch(options) {
25 | return new Promise((resolve, reject) => {
26 | chrome.runtime.sendMessage(options, response => (response && response.errorStr) ? reject(new Error(response.errorStr)) : resolve(response));
27 | });
28 | }
29 |
30 | injectScript(filePath, removeAfterLoad = false) {
31 | return new Promise((resolve, reject) => {
32 | let script = document.createElement('script');
33 | script.type = 'text/javascript';
34 | script.src = chrome.runtime.getURL(filePath);
35 | script.onload = () => {
36 | if (removeAfterLoad) script.remove();
37 | resolve();
38 | }
39 | script.onerror = () => reject(new Error(`Failed to load script: ${filePath}`));
40 | document.head.appendChild(script);
41 | });
42 | }
43 |
44 |
45 | /**
46 | * Wraps a function that is triggered from an async event, and handles errors by logging them to the api.
47 | */
48 | runAsync(name, fn) {
49 | // Start with Promise.resolve() as we don't know if fn returns promise or not.
50 | Promise.resolve().then(() => {
51 | return fn();
52 | }).catch(e => {
53 | console.error(`Error while executing ${name}`, e);
54 | // Not logging errors that we can't do anything.
55 | if (isIgnoredError(e)) return;
56 |
57 | let errStr = stringifyError(e);
58 | // Skip first 2 Failed to fetch errors. We only want to know about these if it's failing for every request.
59 | // These are usually related to the user closing the tab, dns not resolving, etc., but we cannot get the details.
60 | if (errStr.includes("Failed to fetch") && ++this.#failedToFetchErrors <= 2) return;
61 |
62 | // Log to Embrace
63 | log.logException(e, {handled: true, attributes: {name: name}});
64 |
65 | // Log to our backend
66 | return this.#apiConnector.logMessage(name, true, errStr);
67 | });
68 | }
69 |
70 | /**
71 | * logHTML is used to report the entire HTML or debug purposes
72 | * Can be called with a given pct of users to sample.
73 | * E.g.
74 | * this.#utils.logHTML("HorariosPage", 10);
75 | */
76 | logHTML(name, pct) {
77 | // Only log for a percentage of users
78 | if (Math.random() * 100 >= pct) return;
79 |
80 | let message = `HTML log for ${name}`;
81 | let html = document.documentElement.outerHTML;
82 | log.message(message, 'warning', {
83 | attributes: {
84 | name: name,
85 | html: html
86 | }
87 | });
88 | return this.#apiConnector.logMessage(message, false, `HTML log for ${name}. HTML: ${html}`);
89 | }
90 |
91 | waitForElementToHide(selector) {
92 | return new Promise((resolve) => {
93 | let check = () => {
94 | if (!$(selector).is(":visible")) {
95 | resolve();
96 | } else {
97 | setTimeout(check, 100);
98 | }
99 | };
100 | check();
101 | });
102 | }
103 |
104 | // ----
105 |
106 | /**
107 | * Removes the existent "powered by banner", if any.
108 | */
109 | removePoweredByUTNBAHelper() {
110 | document.querySelector(".powered-by-utnba-helper")?.remove();
111 | }
112 |
113 | /**
114 | * Appends the "powered by banner", unless it already exists.
115 | */
116 | addPoweredByUTNBAHelper() {
117 | if (!!document.querySelector(".powered-by-utnba-helper")) return;
118 | document.querySelector(".user-navbar").closest(".row-fluid")
119 | .insertAdjacentHTML('afterbegin', `POWERED BY UTN.BA HELPER`);
120 | }
121 |
122 |
123 | getSchedulesAsString(schedules) {
124 | if (!schedules) return "-";
125 | return schedules
126 | .map(schedule =>
127 | Consts.DAYS[schedule.day] + " (" + Consts.TIME_SHIFTS[schedule.shift] + ") " +
128 | Consts.HOURS[schedule.shift][schedule.firstHour].start + "hs a " + Consts.HOURS[schedule.shift][schedule.lastHour].end + "hs")
129 | .join(" y ");
130 | }
131 |
132 | getColorForAvg(avg, alpha = 1) {
133 | if (avg < 60) {
134 | return `rgba(213, 28, 38, ${alpha})`; // "#D51C26";
135 | } else if (avg >= 80) {
136 | return `rgba(25, 177, 53, ${alpha})`; // "#19B135";
137 | } else {
138 | return `rgba(244, 210, 36, ${alpha})`; // "#F4D224";
139 | }
140 | }
141 |
142 | getOverallScoreSpan(overallScore) {
143 | return `${overallScore}`;
144 | }
145 |
146 | getProfessorLi(professor) {
147 | let fontSize = professor.kind === "DOCENTE" ? "13px" : "11px";
148 | if (typeof professor.overallScore === "undefined") {
149 | // If we do not have surveys we do not show the score nor the link.
150 | return `
`;
123 | }).join("");
124 | this.#$courseDataDiv.find("table tbody tr:last").before(trs);
125 | }
126 |
127 | init() {
128 | return Promise.resolve().then(() => {
129 | this.#createPage();
130 | let courseCode = new URLSearchParams(window.location.search).get(CoursesSearchCustomPage.customParamKey);
131 | if (courseCode) {
132 | return this.#retrieveClassesForCourse(courseCode, 0, 15);
133 | }
134 | });
135 | }
136 |
137 | close() {
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | /** @type {import('jest').Config} */
7 | const config = {
8 | // All imported modules in your tests should be mocked automatically
9 | // automock: false,
10 |
11 | // Stop running tests after `n` failures
12 | // bail: 0,
13 |
14 | // The directory where Jest should store its cached dependency information
15 | // cacheDirectory: "/private/var/folders/75/d_z9ynpj6b927l05jfl_gs300000gn/T/jest_dx",
16 |
17 | // Automatically clear mock calls, instances, contexts and results before every test
18 | clearMocks: true,
19 |
20 | // Indicates whether the coverage information should be collected while executing the test
21 | collectCoverage: true,
22 |
23 | // An array of glob patterns indicating a set of files for which coverage information should be collected
24 | collectCoverageFrom: [
25 | "src/**/*.js",
26 | ],
27 |
28 | // The directory where Jest should output its coverage files
29 | coverageDirectory: "coverage",
30 |
31 | // An array of regexp pattern strings used to skip coverage collection
32 | // coveragePathIgnorePatterns: [
33 | // "/node_modules/"
34 | // ],
35 |
36 | // Indicates which provider should be used to instrument code for coverage
37 | coverageProvider: "v8",
38 |
39 | // A list of reporter names that Jest uses when writing coverage reports
40 | // coverageReporters: [
41 | // "json",
42 | // "text",
43 | // "lcov",
44 | // "clover"
45 | // ],
46 |
47 | // An object that configures minimum threshold enforcement for coverage results
48 | // coverageThreshold: undefined,
49 |
50 | // A path to a custom dependency extractor
51 | // dependencyExtractor: undefined,
52 |
53 | // Make calling deprecated APIs throw helpful error messages
54 | // errorOnDeprecated: false,
55 |
56 | // The default configuration for fake timers
57 | // fakeTimers: {
58 | // "enableGlobally": false
59 | // },
60 |
61 | // Force coverage collection from ignored files using an array of glob patterns
62 | // forceCoverageMatch: [],
63 |
64 | // A path to a module which exports an async function that is triggered once before all test suites
65 | // globalSetup: undefined,
66 |
67 | // A path to a module which exports an async function that is triggered once after all test suites
68 | // globalTeardown: undefined,
69 |
70 | // A set of global variables that need to be available in all test environments
71 | // globals: {},
72 |
73 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
74 | // maxWorkers: "50%",
75 |
76 | // An array of directory names to be searched recursively up from the requiring module's location
77 | // moduleDirectories: [
78 | // "node_modules"
79 | // ],
80 |
81 | // An array of file extensions your modules use
82 | // moduleFileExtensions: [
83 | // "js",
84 | // "mjs",
85 | // "cjs",
86 | // "jsx",
87 | // "ts",
88 | // "mts",
89 | // "cts",
90 | // "tsx",
91 | // "json",
92 | // "node"
93 | // ],
94 |
95 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
96 | moduleNameMapper: {
97 | '\\.(css|less)$': '/src/__mocks__/styleMock.js',
98 | },
99 |
100 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
101 | // modulePathIgnorePatterns: [],
102 |
103 | // Activates notifications for test results
104 | // notify: false,
105 |
106 | // An enum that specifies notification mode. Requires { notify: true }
107 | // notifyMode: "failure-change",
108 |
109 | // A preset that is used as a base for Jest's configuration
110 | // preset: undefined,
111 |
112 | // Run tests from one or more projects
113 | // projects: undefined,
114 |
115 | // Use this configuration option to add custom reporters to Jest
116 | // reporters: undefined,
117 |
118 | // Automatically reset mock state before every test
119 | // resetMocks: false,
120 |
121 | // Reset the module registry before running each individual test
122 | // resetModules: false,
123 |
124 | // A path to a custom resolver
125 | // resolver: undefined,
126 |
127 | // Automatically restore mock state and implementation before every test
128 | // restoreMocks: false,
129 |
130 | // The root directory that Jest should scan for tests and modules within
131 | // rootDir: undefined,
132 |
133 | // A list of paths to directories that Jest should use to search for files in
134 | // roots: [
135 | // ""
136 | // ],
137 |
138 | // Allows you to use a custom runner instead of Jest's default test runner
139 | // runner: "jest-runner",
140 |
141 | // The paths to modules that run some code to configure or set up the testing environment before each test
142 | // setupFiles: [],
143 |
144 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
145 | setupFilesAfterEnv: ['/jest.setup.js'],
146 |
147 | // The number of seconds after which a test is considered as slow and reported as such in the results.
148 | // slowTestThreshold: 5,
149 |
150 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
151 | // snapshotSerializers: [],
152 |
153 | // The test environment that will be used for testing
154 | testEnvironment: "jsdom",
155 |
156 | // Options that will be passed to the testEnvironment
157 | // testEnvironmentOptions: {},
158 |
159 | // Adds a location field to test results
160 | // testLocationInResults: false,
161 |
162 | // The glob patterns Jest uses to detect test files
163 | // testMatch: [
164 | // "**/__tests__/**/*.?([mc])[jt]s?(x)",
165 | // "**/?(*.)+(spec|test).?([mc])[jt]s?(x)"
166 | // ],
167 |
168 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
169 | // testPathIgnorePatterns: [
170 | // "/node_modules/"
171 | // ],
172 |
173 | // The regexp pattern or array of patterns that Jest uses to detect test files
174 | // testRegex: [],
175 |
176 | // This option allows the use of a custom results processor
177 | // testResultsProcessor: undefined,
178 |
179 | // This option allows use of a custom test runner
180 | // testRunner: "jest-circus/runner",
181 |
182 | // A map from regular expressions to paths to transformers
183 | // transform: undefined,
184 |
185 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
186 | // transformIgnorePatterns: [
187 | // "/node_modules/",
188 | // "\\.pnp\\.[^\\/]+$"
189 | // ],
190 |
191 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
192 | // unmockedModulePathPatterns: undefined,
193 |
194 | // Indicates whether each individual test should be reported during the run
195 | // verbose: undefined,
196 |
197 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
198 | // watchPathIgnorePatterns: [],
199 |
200 | // Whether to use watchman for file crawling
201 | // watchman: true,
202 | };
203 |
204 | export default config;
205 |
--------------------------------------------------------------------------------
/src/guarani/custompages/CustomPages.js:
--------------------------------------------------------------------------------
1 | import './CustomPages.css';
2 |
3 | import {CoursesSearchCustomPage} from './CoursesSearchCustomPage.js';
4 | import {ProfessorsSearchCustomPage} from './ProfessorsSearchCustomPage.js';
5 | import {PlanTrackingCustomPage} from './PlanTrackingCustomPage.js';
6 |
7 | const CUSTOM_PAGES = [
8 | CoursesSearchCustomPage,
9 | ProfessorsSearchCustomPage,
10 | PlanTrackingCustomPage,
11 | ];
12 |
13 | export class CustomPages {
14 | static CUSTOM_PAGE_QUERY_PARAM = "customPage";
15 |
16 | #pagesDataParser;
17 | #dataCollector;
18 | #utils;
19 | #apiConnector;
20 |
21 | constructor(pagesDataParser, dataCollector, utils, apiConnector) {
22 | this.#pagesDataParser = pagesDataParser;
23 | this.#dataCollector = dataCollector;
24 | this.#utils = utils;
25 | this.#apiConnector = apiConnector;
26 | }
27 |
28 | /**
29 | * Removes the existent menu, if any.
30 | */
31 | removeMenu() {
32 | document.querySelector(".main-nav #js-nav .utnba-helper-menu")?.remove();
33 | }
34 |
35 | /**
36 | * Appends the custom menu, unless it already exists.
37 | */
38 | appendMenu() {
39 | if (!!document.querySelector(".main-nav #js-nav .utnba-helper-menu")) return;
40 |
41 | const customMenusContainer = document.createElement('ul');
42 | customMenusContainer.className = 'dropdown-menu';
43 |
44 | const li = document.createElement('li');
45 | li.className = 'dropdown js-menuitem-root utnba-helper-menu';
46 | li.innerHTML = `UTN.BA Helper `;
47 |
48 | li.appendChild(customMenusContainer);
49 | document.querySelector(".main-nav #js-nav").appendChild(li);
50 |
51 | CUSTOM_PAGES.forEach(customPage => {
52 | const menuItem = document.createElement('li');
53 | menuItem.innerHTML = `${customPage.menuName}`;
54 | customMenusContainer.appendChild(menuItem);
55 | });
56 | }
57 |
58 | #initCustomPage(customPage) {
59 | document.querySelector("#kernel_contenido").innerHTML = `
60 |
61 |
62 |
63 |
UTN.BA HELPER - Información importante
64 |
65 |
Sobre el bloqueo de la extensión
66 |
67 |
68 | En el último tiempo, la facultad decidió bloquear activamente el uso de la extensión, advirtiendo a los estudiantes que la desactiven.
69 |
70 |
71 | Lamentablemente, esta decisión no protege a los estudiantes, los perjudica.
72 |
73 |
74 | Lo único que logra es quitarle poder a los alumnos, impedirles acceder a información que ellos mismos generan,
75 | y reforzar un esquema donde la facultad concentra el control total de los datos.
76 |
77 |
78 | La información recolectada en las encuestas docentes existe gracias a los estudiantes.
79 | Sin embargo, se decide retenerla y ocultarla, incluso frente a iniciativas voluntarias y
80 | transparentes que buscan devolver esa información a la comunidad.
81 |
82 |
83 |
Consecuencias reales de esta decisión
84 |
85 |
86 | Se desincentiva la mejora docente, ya que el mal desempeño o el maltrato a estudiantes no tiene consecuencias visibles.
87 |
88 |
89 | Se reduce la capacidad de los alumnos de tomar decisiones informadas sobre su propia formación.
90 |
91 |
92 |
93 |
Un ataque al aprendizaje y al desarrollo tecnológico
94 |
95 | Esta extensión es un proyecto abierto y comunitario.
96 | Estudiantes y docentes participaron activamente, abriendo pull
98 | requests, reportando issues, proponiendo ideas y sugerencias, etc.
99 |
100 |
101 | Esto es exactamente lo que una facultad (y más aún una facultad tecnológica) debería fomentar: aprender
102 | haciendo, colaborar, construir herramientas útiles para la comunidad.
103 |
104 |
105 | Resulta particularmente inexplicable en una institución como la UTN, cuyo objetivo debería ser formar
106 | profesionales críticos, autónomos y técnicamente capaces, no usuarios pasivos de sistemas cerrados.
107 |
108 |
109 |
Prohibir no es el camino
110 |
111 | La prohibición y el ocultamiento de los datos de las encuestas no es una solución educativa, ya que lo unico que
112 | logra es afectar a los estudiantes.
113 |
114 |
115 | El camino podría haber sido abrir al público toda la información de manera oficial,
116 | para proveer una herramienta aún mejor que la que hoy ofrece esta extensión,
117 | y así entonces eliminar su uso de forma orgánica y no mediante un bloqueo.
118 |
119 |
120 | La información generada por los alumnos debe volver a los alumnos, y la transparencia es el mejor camino para
121 | mejorar la educación.
122 |
123 |
124 |
Futuro de la extensión
125 |
126 | Como se mencionó más arriba, los datos recolectados siempre fueron y serán de los estudiantes, si en algún
127 | momento la extensión deja de funcionar por bloqueos totales, se intentará exponer la información en alguna
128 | sección separada del SIU Guarani.
129 |
130 |
131 |
132 |
133 |
${customPage.menuName}
134 |
135 |
138 |
139 |
UTN.BA HELPER - Información importante
140 |
Esta sección es provista por el "UTN.BA Helper" y no es parte del sistema de la UTN.
141 |
La información presentada en esta sección proviene de datos colectados de los usuarios que poseen la extensión, y de información generada por la misma extensión, por lo cual puede estar incompleta y/o errónea.
142 | Ninguno de los datos presentados en esta sección proviene del sistema de la UTN, por lo que debe ser usada bajo su propia interpretación.
143 |
Tener en cuenta que en los casos de encuestas, la información colectada es una muestra parcial del total real, y por ende en casos donde la muestra es muy baja, puede implicar que los resultados estén alejados de la realidad.
51 | Filtrar
52 | `);
53 |
54 | let filterClassSchedules = function () {
55 | // For all the filter types (branches, schedules, yearQuarter) at least one option of each has to match.
56 | Array.from(alternativesCombo.options).forEach((option, i) => {
57 | if (i === 0) {
58 | // Force select first option and keep it visible
59 | alternativesCombo.value = option.value;
60 | return;
61 | }
62 | let visible = Array.from(filtersDiv.querySelectorAll(".filter")).every(filter => {
63 | return Array.from(filter.querySelectorAll("input:checked")).some(filterOpt => option.text.includes(filterOpt.value));
64 | });
65 |
66 | let optionId = option.getAttribute("data-option-id");
67 | // hide or show both the option and the row in the previous professors table:
68 | if (visible) {
69 | option.removeAttribute("hidden");
70 | previousProfessorsTableBody.querySelector(`tr[data-option-id='${optionId}']`).removeAttribute("hidden");
71 | } else {
72 | option.setAttribute("hidden", "");
73 | previousProfessorsTableBody.querySelector(`tr[data-option-id='${optionId}']`).setAttribute("hidden", "");
74 | }
75 | });
76 | }
77 | filtersDiv.querySelector("a").addEventListener('click', (event) => {
78 | event.preventDefault();
79 | this.#utils.runAsync("filterClassSchedules", filterClassSchedules);
80 | });
81 | }
82 |
83 | #addPreviousProfessorsTable(alternativesCombo, filtersDiv, previousProfessorsTableBody) {
84 | return Promise.resolve().then(() => {
85 | return this.#fetchCourseAlternatives();
86 | }).then(courseOptionsData => {
87 | return this.#renderPreviousProfessorsTable(alternativesCombo, previousProfessorsTableBody, courseOptionsData);
88 | }).then(() => {
89 | // Once everything is rendered, display filter box:
90 | return this.#addClassSchedulesFilter(alternativesCombo, filtersDiv, previousProfessorsTableBody);
91 | });
92 | }
93 |
94 | #fetchCourseAlternatives() {
95 | return Promise.resolve().then(() => {
96 | // Avoid using cache as the endpoint is always the same but the student may register or unregister from the course.
97 | return this.#pagesDataParser.fetchAjaxGETContents(location.href, false);
98 | }).then(response => {
99 | if (!response.agenda || !response.agenda.comisiones) throw new Error(`Missing course alternatives for ${location.href}. response: ${JSON.stringify(response)}`);
100 |
101 | // `comisiones` may include the current class schedules. This is not a problem because we access the array by id.
102 | // But we could eventually filter them out by using the `cursadas` array if we confirm it
103 | return response.agenda.comisiones;
104 | });
105 | }
106 |
107 | #renderPreviousProfessorsTable(alternativesCombo, previousProfessorsTableBody, courseOptionsData) {
108 | let optionId = 0;
109 | let optionDetails = [];
110 | let previousProfessorsRequest = Array.from(alternativesCombo.options)
111 | .map(option => {
112 | let classData = courseOptionsData[option.value];
113 | if (!classData) return null;
114 |
115 | let classSchedule = this.#pagesDataParser.mapClassDataToClassSchedule(classData);
116 |
117 | // Set an optionId in the option text to identify in the table that is added later.
118 | optionId++;
119 | let textParts = option.textContent.split("|").map(t => t.trim());
120 | optionDetails.push(textParts.join(" ") + " " + classSchedule.classCode);
121 |
122 | option.textContent = `(${optionId}) | ${option.textContent} | ${classSchedule.classCode}`;
123 | option.setAttribute("data-option-id", optionId);
124 |
125 | return classSchedule;
126 | })
127 | .filter(req => !!req);
128 |
129 | // Returns a List that corresponds one to one with the request list, with maps that represent: year -> classCode (the new one) -> List of professors
130 | return this.#apiConnector.getPreviousProfessors(previousProfessorsRequest).then(response => {
131 | for (let i = 0; i < response.length; i++) {
132 | let previousProfessors = response[i];
133 | optionId = i + 1;
134 |
135 | let content = `
`;
156 | previousProfessorsTableBody.appendChild(row);
157 | }
158 | });
159 | }
160 |
161 | init() {
162 | return Promise.resolve().then(() => {
163 | // Need to listen to course register changes, as the combo is reloaded, and we need to add the table again.
164 | // We need to un register them on close, as changing a course will trigger a new PreInscripcionPage.
165 | // Events triggered from foreground script:
166 | this.#addPreviousProfessorsTableEventFn = () => {
167 | let alternativesDiv = document.querySelector("#insc_alternativas .inscripcion-alternativa");
168 | if (!alternativesDiv) return; // There may be no div if for example the alternative already has a selected option.
169 | alternativesDiv.insertAdjacentHTML("beforebegin", `
170 |
171 |
172 |
UTN.BA HELPER - Información importante
173 |
La información sobre profesores anteriores es provista por el "UTN.BA Helper" y no es parte del sistema de la UTN.
174 |
La intención de esta tabla es, en base a datos colectados por el "UTN.BA Helper", intentar predecir que profesor va a estar en cada cursada, basándonos en los profesores que estuvieron en cursadas anteriores.
175 |
Para cada horario presentado en el combo de abajo, se muestra un item en la tabla, que puede ser identificado por el ID que es agregado al texto de cada opción, y que es mostrado en cada fila de la tabla.
`);
31 |
32 | this.#$gradesSummary = $("");
33 | this.#$container.append(this.#$gradesSummary);
34 | promises.push(this.#buildGradesSummary(coursesHistory));
35 |
36 | this.#$container.append("");
37 |
38 | this.#planDiv = document.createElement('div');
39 | this.#$container.append(this.#planDiv);
40 | promises.push(this.#loadPlan(planCode, coursesHistory));
41 |
42 | return Promise.all(promises);
43 | }
44 |
45 | #buildGradesSummary(coursesHistory) {
46 | let startYear = coursesHistory.courses.concat(coursesHistory.finalExams)
47 | .map(course => course.date)
48 | .sort((a, b) => a - b)
49 | .map(date => date.getFullYear())
50 | [0];
51 | if (!startYear) return; // If no courses nor finalExams, there is nothing we can show.
52 | let yearsCount = (new Date().getFullYear() - startYear + 1);
53 |
54 | const arrayAverage = arr => Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100;
55 |
56 | let passedFinalExams = coursesHistory.finalExams.filter(course => course.isPassed);
57 | let failedFinalExams = coursesHistory.finalExams.filter(course => !course.isPassed);
58 | let pesoAcademico = 11 * passedFinalExams.length - 5 * yearsCount - 3 * failedFinalExams.length;
59 |
60 | // Some final exams do not have grade (e.g. "Equivalencia Total") so we ignore them for the average.
61 | let passedWeightedGrades = passedFinalExams.filter(course => typeof course.weightedGrade === "number").map(course => course.weightedGrade);
62 | let failedWeightedGrades = failedFinalExams.filter(course => typeof course.weightedGrade === "number").map(course => course.weightedGrade);
63 | let passedNonWeightedGrades = passedFinalExams.filter(course => typeof course.grade === "number").map(course => course.grade);
64 | let failedNonWeightedGrades = failedFinalExams.filter(course => typeof course.grade === "number").map(course => course.grade);
65 |
66 | let allWeightedGradesAverage = arrayAverage(passedWeightedGrades.concat(failedWeightedGrades));
67 | let passedWeightedGradesAverage = arrayAverage(passedWeightedGrades);
68 | let allNonWeightedGradesAverage = arrayAverage(passedNonWeightedGrades.concat(failedNonWeightedGrades));
69 | let passedNonWeightedGradesAverage = arrayAverage(passedNonWeightedGrades);
70 |
71 | this.#$gradesSummary.html(`
72 | a Peso académico: Materias Aprobadas * 11 - años de carrera * 5 - finales desaprobados * 3
73 | b La nota ponderada es calculada por el "UTN.BA Helper" según Ordenanza Nº 1549`);
74 | const appendTableRow = (description, value) => this.#$gradesSummary.find("tbody").append("
" + description + "
" + (value || value === 0 ? value : "n/a") + "
");
75 |
76 | appendTableRow("Peso academico", `${pesoAcademico} (11*${passedFinalExams.length} - 5*${yearsCount} - 3*${failedFinalExams.length}) a`);
77 | appendTableRow("Cantidad de finales aprobados", passedFinalExams.length);
78 | appendTableRow("Cantidad de finales desaprobados", failedFinalExams.length);
79 | appendTableRow("Promedio de notas ponderadasb con desaprobados", allWeightedGradesAverage);
80 | appendTableRow("Promedio de notas ponderadasb sin desaprobados", passedWeightedGradesAverage);
81 | appendTableRow("Promedio de notas originalesb con desaprobados", allNonWeightedGradesAverage);
82 | appendTableRow("Promedio de notas originalesb sin desaprobados", passedNonWeightedGradesAverage);
83 |
84 | return this.#services.dataCollector.logUserStat(pesoAcademico, passedWeightedGradesAverage, allWeightedGradesAverage, passedFinalExams.length, failedFinalExams.length);
85 | }
86 |
87 | //...
88 |
89 | #loadPlan(planCode, coursesHistory) {
90 | if (!planCode) return;
91 | return this.#services.apiConnector.getPlanCourses(planCode).then(planCourses => {
92 | return this.#loadPlanCourses(planCourses, coursesHistory);
93 | });
94 | }
95 |
96 | #loadPlanCourses(planCourses, coursesHistory) {
97 | let courseNamesByCode = planCourses.reduce(function (courseNamesByCode, course) {
98 | courseNamesByCode[course.courseCode] = course.courseName;
99 | return courseNamesByCode;
100 | }, {});
101 |
102 | // For signed courses we consider both passed and signed, and remove duplicates.
103 | let passedCourses = coursesHistory.finalExams.filter(course => course.isPassed).map(course => course.courseCode);
104 | let signedCourses = [...new Set([...passedCourses, ...coursesHistory.courses.filter(course => course.isPassed).map(course => course.courseCode)])];
105 | let courseRequirementToArray = {
106 | "SIGNED": signedCourses,
107 | "PASSED": passedCourses,
108 | };
109 | let hasCourse = (requirement, courseCode) => courseRequirementToArray[requirement].includes(courseCode);
110 |
111 | let getCoursesHtml = level => {
112 | let lastWasElective = false;
113 | let showExtraElectivesButtonAdded = false;
114 | return planCourses.filter(course => {
115 | return course.level === level;
116 | }).map(course => {
117 | let meetsDependencies = kind => course.dependencies
118 | .filter(dependency => dependency.kind === kind)
119 | .every(dependency => hasCourse(dependency.requirement, dependency.courseCode));
120 | course.isSigned = hasCourse("SIGNED", course.courseCode);
121 | course.isPassed = hasCourse("PASSED", course.courseCode);
122 | course.canRegister = meetsDependencies("REGISTER");
123 | course.canTakeFinalExam = meetsDependencies("TAKE_FINAL_EXAM");
124 | return course;
125 | }).sort((c1, c2) => {
126 | let courseWeight = course => {
127 | let w;
128 | if (course.isPassed) w = 10;
129 | else if (course.canTakeFinalExam) w = 9;
130 | else if (course.isSigned) w = 8;
131 | else if (course.canRegister) w = 7;
132 | else w = 6;
133 | if (course.elective) {
134 | w -= 5;
135 | }
136 | return w;
137 | };
138 | return courseWeight(c2) - courseWeight(c1);
139 | }).map(course => {
140 | let status;
141 | let backgroundColor = "#7e7e7e";
142 | let color = "#000000";
143 | if (course.isPassed) {
144 | status = TRANSLATIONS["PASSED"];
145 | backgroundColor = "#55bb55";
146 | } else if (course.canTakeFinalExam) {
147 | status = "Puede " + TRANSLATIONS["TAKE_FINAL_EXAM"].toLowerCase();
148 | backgroundColor = "#ffcc00";
149 | } else if (course.isSigned) {
150 | status = TRANSLATIONS["SIGNED"];
151 | backgroundColor = "#ffcc00";
152 | } else if (course.canRegister) {
153 | status = "Puede " + TRANSLATIONS["REGISTER"].toLowerCase();
154 | backgroundColor = "#5555bb";
155 | color = "#f1f1f1";
156 | }
157 |
158 | let hr = "";
159 | if (!lastWasElective && course.elective) {
160 | lastWasElective = true;
161 | hr = `