@czlonkowski/n8n-nodes-librus 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +21 -1
- package/dist/nodes/Librus/LibrusClient.d.ts +15 -8
- package/dist/nodes/Librus/LibrusClient.js +167 -71
- package/dist/nodes/Librus/LibrusClient.js.map +1 -1
- package/dist/nodes/Librus/LibrusTrigger.node.js +133 -15
- package/dist/nodes/Librus/LibrusTrigger.node.js.map +1 -1
- package/dist/nodes/Librus/calendarState.d.ts +57 -0
- package/dist/nodes/Librus/calendarState.js +289 -0
- package/dist/nodes/Librus/calendarState.js.map +1 -0
- package/dist/nodes/Librus/errors.d.ts +9 -0
- package/dist/nodes/Librus/errors.js +45 -0
- package/dist/nodes/Librus/errors.js.map +1 -0
- package/dist/nodes/Librus/terminarz.d.ts +26 -0
- package/dist/nodes/Librus/terminarz.js +151 -0
- package/dist/nodes/Librus/terminarz.js.map +1 -0
- package/docs/architecture.md +43 -1
- package/docs/live-verification.md +58 -0
- package/package.json +3 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LibrusError = void 0;
|
|
4
|
+
exports.safeError = safeError;
|
|
5
|
+
exports.protocolError = protocolError;
|
|
6
|
+
const messages = {
|
|
7
|
+
UNSAFE_URL: 'Librus wskazał niedozwolony adres. Nie wysłano do niego zapytania.',
|
|
8
|
+
TRANSPORT_ERROR: 'Nie udało się połączyć z Librusem. Sprawdź połączenie i spróbuj ponownie później.',
|
|
9
|
+
PROTOCOL_ERROR: 'Librus zwrócił odpowiedź w nieoczekiwanym formacie. Integracja może wymagać aktualizacji.',
|
|
10
|
+
AUTH_FAILED: 'Librus odrzucił logowanie. Sprawdź login i hasło na stronie Librusa.',
|
|
11
|
+
ACTION_REQUIRED: 'Zaloguj się na stronie Librusa i uzupełnij wymagane potwierdzenia konta.',
|
|
12
|
+
SESSION_EXPIRED: 'Sesja Librusa wygasła podczas zapytania.',
|
|
13
|
+
RATE_LIMITED: 'Librus ogranicza liczbę zapytań. Odczekaj przed kolejną próbą.',
|
|
14
|
+
ACCESS_DENIED: 'Librus odmówił dostępu. Sprawdź uprawnienia konta na stronie Librusa.',
|
|
15
|
+
SERVICE_ERROR: 'Librus jest niedostępny lub zwrócił nieoczekiwany status HTTP.',
|
|
16
|
+
SCAN_INCOMPLETE: 'Nie udało się sprawdzić całej skrzynki w wyznaczonych granicach. Zwiększ Maksymalną liczbę stron lub ogranicz liczbę pobieranych wiadomości.',
|
|
17
|
+
CALENDAR_SCAN_INCOMPLETE: 'Nie udało się sprawdzić całego terminarza w wyznaczonych granicach. Zmniejsz Liczbę miesięcy do przodu i spróbuj ponownie.',
|
|
18
|
+
FULL_CONTENT_LIMIT: 'W jednym wykonaniu można pobrać pełną treść maksymalnie 50 wiadomości. Wyłącz Pobierz wszystkie i ustaw Limit na 50 lub mniej.',
|
|
19
|
+
INVALID_OPTIONS: 'Nieprawidłowe dane logowania do Librusa lub ustawienia pobierania wiadomości.',
|
|
20
|
+
CALENDAR_INVALID_OPTIONS: 'Nieprawidłowe dane logowania do Librusa lub ustawienia sprawdzania terminarza. Sprawdź Liczbę miesięcy do przodu.',
|
|
21
|
+
TRIGGER_STATE_INVALID: 'Zapisana historia wykrytych wiadomości jest nieprawidłowa. Utwórz ponownie węzeł Nowa wiadomość, aby zapamiętać aktualną skrzynkę.',
|
|
22
|
+
TRIGGER_STATE_LIMIT: 'Osiągnięto limit historii 10 000 wiadomości. Utwórz ponownie węzeł Nowa wiadomość, aby zapamiętać aktualną skrzynkę.',
|
|
23
|
+
CALENDAR_STATE_INVALID: 'Zapisana historia terminarza jest nieprawidłowa. Utwórz ponownie węzeł z wydarzeniem terminarza, aby zapamiętać aktualny kalendarz.',
|
|
24
|
+
CALENDAR_STATE_LIMIT: 'Osiągnięto limit historii 2000 wydarzeń terminarza. Zmniejsz Liczbę miesięcy do przodu lub utwórz węzeł ponownie, aby zapamiętać aktualny kalendarz.',
|
|
25
|
+
};
|
|
26
|
+
class LibrusError extends Error {
|
|
27
|
+
constructor(code, stage, destination, rejection) {
|
|
28
|
+
var _a;
|
|
29
|
+
super(((_a = messages[code]) !== null && _a !== void 0 ? _a : messages.PROTOCOL_ERROR) +
|
|
30
|
+
(stage ? ` [Etap: ${stage}; strona: ${destination}]` : '') +
|
|
31
|
+
(rejection ? ` [Odrzucone przekierowanie: ${rejection}]` : ''));
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.LibrusError = LibrusError;
|
|
36
|
+
function safeError(error) {
|
|
37
|
+
return error instanceof LibrusError ? error : new LibrusError('PROTOCOL_ERROR');
|
|
38
|
+
}
|
|
39
|
+
function protocolError(check, value) {
|
|
40
|
+
const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
41
|
+
const error = new LibrusError('PROTOCOL_ERROR');
|
|
42
|
+
error.message += ` [Walidacja: ${check}; otrzymany typ: ${type}]`;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../../nodes/Librus/errors.ts"],"names":[],"mappings":";;;AAiEA,8BAEC;AAGD,sCAKC;AA3ED,MAAM,QAAQ,GAA2B;IACxC,UAAU,EAAE,oEAAoE;IAChF,eAAe,EACd,mFAAmF;IACpF,cAAc,EACb,2FAA2F;IAC5F,WAAW,EAAE,sEAAsE;IACnF,eAAe,EAAE,0EAA0E;IAC3F,eAAe,EAAE,0CAA0C;IAC3D,YAAY,EAAE,gEAAgE;IAC9E,aAAa,EAAE,uEAAuE;IACtF,aAAa,EAAE,gEAAgE;IAC/E,eAAe,EACd,8IAA8I;IAE/I,wBAAwB,EACvB,4HAA4H;IAC7H,kBAAkB,EACjB,gIAAgI;IACjI,eAAe,EAAE,+EAA+E;IAEhG,wBAAwB,EACvB,mHAAmH;IACpH,qBAAqB,EACpB,oIAAoI;IACrI,mBAAmB,EAClB,sHAAsH;IACvH,sBAAsB,EACrB,qIAAqI;IACtI,oBAAoB,EACnB,sJAAsJ;CACvJ,CAAC;AAcF,MAAa,WAAY,SAAQ,KAAK;IAGrC,YACiB,IAAY,EAC5B,KAAiB,EACjB,WAA6B,EAC7B,SAIyB;;QAEzB,KAAK,CACJ,CAAC,MAAA,QAAQ,CAAC,IAAI,CAAC,mCAAI,QAAQ,CAAC,cAAc,CAAC;YAC1C,CAAC,KAAK,CAAC,CAAC,CAAC,WAAW,KAAK,aAAa,WAAW,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1D,CAAC,SAAS,CAAC,CAAC,CAAC,+BAA+B,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAC/D,CAAC;QAbc,SAAI,GAAJ,IAAI,CAAQ;IAc7B,CAAC;CACD;AAnBD,kCAmBC;AACD,SAAgB,SAAS,CAAC,KAAc;IACvC,OAAO,KAAK,YAAY,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,WAAW,CAAC,gBAAgB,CAAC,CAAC;AACjF,CAAC;AAGD,SAAgB,aAAa,CAAC,KAAa,EAAE,KAAe;IAC3D,MAAM,IAAI,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,KAAK,CAAC;IACrF,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAChD,KAAK,CAAC,OAAO,IAAI,gBAAgB,KAAK,oBAAoB,IAAI,GAAG,CAAC;IAClE,OAAO,KAAK,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CalendarEntry {
|
|
2
|
+
key: string;
|
|
3
|
+
route: 'szczegoly' | 'szczegoly_wolne' | null;
|
|
4
|
+
eventId: string | null;
|
|
5
|
+
date: string;
|
|
6
|
+
subject: string | null;
|
|
7
|
+
teacher: string | null;
|
|
8
|
+
description: string | null;
|
|
9
|
+
lessonNumber: number | null;
|
|
10
|
+
hour: string | null;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CalendarDetail {
|
|
14
|
+
fields: Record<string, string>;
|
|
15
|
+
rodzaj: string | null;
|
|
16
|
+
room: string | null;
|
|
17
|
+
addedAt: string | null;
|
|
18
|
+
teacher: string | null;
|
|
19
|
+
subject: string | null;
|
|
20
|
+
description: string | null;
|
|
21
|
+
lessonNumber: number | null;
|
|
22
|
+
date: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function decodeEntities(value: string): string;
|
|
25
|
+
export declare function parseMonth(html: string, year: number, month: number): CalendarEntry[];
|
|
26
|
+
export declare function parseEventDetail(html: string): CalendarDetail;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decodeEntities = decodeEntities;
|
|
4
|
+
exports.parseMonth = parseMonth;
|
|
5
|
+
exports.parseEventDetail = parseEventDetail;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const errors_1 = require("./errors");
|
|
8
|
+
function protocolError(check, value) {
|
|
9
|
+
return (0, errors_1.protocolError)(check, value);
|
|
10
|
+
}
|
|
11
|
+
const latin1 = 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml'.split(' ');
|
|
12
|
+
const named = {
|
|
13
|
+
amp: '&',
|
|
14
|
+
lt: '<',
|
|
15
|
+
gt: '>',
|
|
16
|
+
quot: '"',
|
|
17
|
+
apos: "'",
|
|
18
|
+
};
|
|
19
|
+
for (const [index, name] of latin1.entries())
|
|
20
|
+
named[name] = String.fromCodePoint(160 + index);
|
|
21
|
+
function decodeEntities(value) {
|
|
22
|
+
return value.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, name) => {
|
|
23
|
+
var _a, _b;
|
|
24
|
+
const code = name.startsWith('#')
|
|
25
|
+
? Number(name[1] === 'x' || name[1] === 'X' ? `0x${name.slice(2)}` : name.slice(1))
|
|
26
|
+
: NaN;
|
|
27
|
+
if (Number.isInteger(code) && code > 0 && code <= 0x10ffff)
|
|
28
|
+
return String.fromCodePoint(code);
|
|
29
|
+
return (_b = (_a = named[name]) !== null && _a !== void 0 ? _a : named[name.toLowerCase()]) !== null && _b !== void 0 ? _b : match;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function text(html) {
|
|
33
|
+
return decodeEntities(html.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]*>/g, ''))
|
|
34
|
+
.replace(/\u00a0/g, ' ')
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
37
|
+
.filter((line) => line)
|
|
38
|
+
.join('\n');
|
|
39
|
+
}
|
|
40
|
+
function attributes(tag) {
|
|
41
|
+
var _a, _b;
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const match of tag.matchAll(/([a-zA-Z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
|
|
44
|
+
result[match[1].toLowerCase()] = decodeEntities((_b = (_a = match[2]) !== null && _a !== void 0 ? _a : match[3]) !== null && _b !== void 0 ? _b : '');
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function titlePairs(title) {
|
|
48
|
+
const pairs = {};
|
|
49
|
+
for (const part of title.split(/<br\s*\/?>/i)) {
|
|
50
|
+
const index = part.indexOf(':');
|
|
51
|
+
if (index <= 0)
|
|
52
|
+
continue;
|
|
53
|
+
pairs[part.slice(0, index).trim().toLocaleLowerCase('pl')] = decodeEntities(part.slice(index + 1)).trim();
|
|
54
|
+
}
|
|
55
|
+
return pairs;
|
|
56
|
+
}
|
|
57
|
+
function entry(date, attrs, body) {
|
|
58
|
+
var _a, _b, _c, _d, _e;
|
|
59
|
+
const link = /\/terminarz\/(szczegoly_wolne|szczegoly)\/(\d+)/.exec((_a = attrs.onclick) !== null && _a !== void 0 ? _a : '');
|
|
60
|
+
const pairs = titlePairs((_b = attrs.title) !== null && _b !== void 0 ? _b : '');
|
|
61
|
+
const lesson = /lekcj\w*\s*:?\s*(\d{1,2})\b/i.exec(body);
|
|
62
|
+
const hour = /\b([01]?\d|2[0-3]):([0-5]\d)\b/.exec(body);
|
|
63
|
+
return {
|
|
64
|
+
key: link
|
|
65
|
+
? `${link[1]}/${link[2]}`
|
|
66
|
+
: `hash/${(0, node_crypto_1.createHash)('sha256').update(`${date} ${body}`).digest('hex').slice(0, 16)}`,
|
|
67
|
+
route: link ? link[1] : null,
|
|
68
|
+
eventId: link ? link[2] : null,
|
|
69
|
+
date,
|
|
70
|
+
subject: (_c = body.split('\n')[0]) !== null && _c !== void 0 ? _c : null,
|
|
71
|
+
teacher: (_d = pairs.nauczyciel) !== null && _d !== void 0 ? _d : null,
|
|
72
|
+
description: (_e = pairs.opis) !== null && _e !== void 0 ? _e : null,
|
|
73
|
+
lessonNumber: lesson ? Number(lesson[1]) : null,
|
|
74
|
+
hour: hour ? `${hour[1].padStart(2, '0')}:${hour[2]}` : null,
|
|
75
|
+
text: body,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function blockEnd(html, start) {
|
|
79
|
+
const tags = /<div\b|<\/div\b/gi;
|
|
80
|
+
tags.lastIndex = start;
|
|
81
|
+
let depth = 1;
|
|
82
|
+
for (let match = tags.exec(html); match; match = tags.exec(html)) {
|
|
83
|
+
depth += match[0][1] === '/' ? -1 : 1;
|
|
84
|
+
if (depth === 0)
|
|
85
|
+
return match.index;
|
|
86
|
+
}
|
|
87
|
+
throw protocolError('siatka terminarza', depth);
|
|
88
|
+
}
|
|
89
|
+
function parseMonth(html, year, month) {
|
|
90
|
+
var _a;
|
|
91
|
+
if (typeof html !== 'string')
|
|
92
|
+
throw protocolError('dokument HTML', html);
|
|
93
|
+
const blocks = [...html.matchAll(/<div\b[^>]*class="[^"]*\bkalendarz-dzien\b[^"]*"[^>]*>/gi)];
|
|
94
|
+
if (!blocks.length)
|
|
95
|
+
throw protocolError('siatka terminarza', blocks.length);
|
|
96
|
+
const days = new Set();
|
|
97
|
+
const entries = [];
|
|
98
|
+
for (const block of blocks) {
|
|
99
|
+
const start = ((_a = block.index) !== null && _a !== void 0 ? _a : 0) + block[0].length;
|
|
100
|
+
const chunk = html.slice(start, blockEnd(html, start));
|
|
101
|
+
const labels = [
|
|
102
|
+
...chunk.matchAll(/<div\b[^>]*class="[^"]*\bkalendarz-numer-dnia\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi),
|
|
103
|
+
];
|
|
104
|
+
if (labels.length !== 1)
|
|
105
|
+
throw protocolError('numery dni miesiąca', labels.length);
|
|
106
|
+
const day = Number(text(labels[0][1]));
|
|
107
|
+
if (!Number.isInteger(day) || day < 1 || day > 31 || days.has(day))
|
|
108
|
+
throw protocolError('numery dni miesiąca', day);
|
|
109
|
+
days.add(day);
|
|
110
|
+
const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
111
|
+
for (const cell of chunk.matchAll(/<td\b([^>]*)>([\s\S]*?)<\/td>/gi)) {
|
|
112
|
+
const body = text(cell[2]);
|
|
113
|
+
if (body)
|
|
114
|
+
entries.push(entry(date, attributes(cell[1]), body));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const length = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
118
|
+
if (days.size !== length || Math.max(...days) !== length)
|
|
119
|
+
throw protocolError('numery dni miesiąca', days.size);
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
function parseEventDetail(html) {
|
|
123
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
124
|
+
if (typeof html !== 'string')
|
|
125
|
+
throw protocolError('dokument HTML', html);
|
|
126
|
+
const fields = {};
|
|
127
|
+
for (const row of html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
|
128
|
+
const label = /<th\b[^>]*>([\s\S]*?)<\/th>/i.exec(row[1]);
|
|
129
|
+
const value = /<td\b[^>]*>([\s\S]*?)<\/td>/i.exec(row[1]);
|
|
130
|
+
if (!label || !value)
|
|
131
|
+
continue;
|
|
132
|
+
const name = text(label[1]).replace(/:$/, '').trim();
|
|
133
|
+
if (name && !(name in fields))
|
|
134
|
+
fields[name] = text(value[1]);
|
|
135
|
+
}
|
|
136
|
+
if (Object.keys(fields).length < 2)
|
|
137
|
+
throw protocolError('tabela szczegółów', Object.keys(fields).length);
|
|
138
|
+
const lesson = fields['Nr lekcji'] ? Number(fields['Nr lekcji']) : NaN;
|
|
139
|
+
return {
|
|
140
|
+
fields,
|
|
141
|
+
rodzaj: (_a = fields['Rodzaj']) !== null && _a !== void 0 ? _a : null,
|
|
142
|
+
room: (_b = fields['Sala']) !== null && _b !== void 0 ? _b : null,
|
|
143
|
+
addedAt: (_c = fields['Data dodania']) !== null && _c !== void 0 ? _c : null,
|
|
144
|
+
teacher: (_d = fields['Nauczyciel']) !== null && _d !== void 0 ? _d : null,
|
|
145
|
+
subject: (_e = fields['Przedmiot']) !== null && _e !== void 0 ? _e : null,
|
|
146
|
+
description: (_f = fields['Opis']) !== null && _f !== void 0 ? _f : null,
|
|
147
|
+
lessonNumber: Number.isSafeInteger(lesson) && lesson >= 0 ? lesson : null,
|
|
148
|
+
date: (_g = fields['Data']) !== null && _g !== void 0 ? _g : null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=terminarz.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"terminarz.js","sourceRoot":"","sources":["../../../nodes/Librus/terminarz.ts"],"names":[],"mappings":"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"}
|
package/docs/architecture.md
CHANGED
|
@@ -22,7 +22,7 @@ These are unofficial website routes, not a contracted public API. No verified re
|
|
|
22
22
|
|
|
23
23
|
HTTPS and four exact Librus hostnames are allowlisted. Every redirect is checked before a request is issued. Legacy HTTP Location headers targeting exact allowlisted hosts are upgraded to HTTPS before sending; no request is made over HTTP. URL credentials and non-default ports are rejected. Password-bearing POSTs are restricted to the authorization route and never automatically redirected or replayed. Cookies are obtained from the jar separately for each URL.
|
|
24
24
|
|
|
25
|
-
Requests time out after at most 15 seconds. An operation is bounded to 120 seconds and 100 requests, including a possible second login. Redirect chains stop after 10 redirects. A recognized session expiry during a scan allows one fresh login and restarts the scan, discarding partial results. Generic HTML, malformed JSON and schema mismatches fail rather than returning an empty inbox. A short page is treated as end-of-list; this assumption must be verified live. Pagination is not a transactional snapshot when messages arrive mid-scan.
|
|
25
|
+
Requests time out after at most 15 seconds. An operation is bounded to 120 seconds and 100 requests, including a possible second login; the calendar operation instead gives each of its two attempts its own 100-request budget, under the same single 120-second deadline (see the calendar section). Redirect chains stop after 10 redirects. A recognized session expiry during a scan allows one fresh login and restarts the scan, discarding partial results. Generic HTML, malformed JSON and schema mismatches fail rather than returning an empty inbox. A short page is treated as end-of-list; this assumption must be verified live. Pagination is not a transactional snapshot when messages arrive mid-scan.
|
|
26
26
|
|
|
27
27
|
No scheduling or cross-execution deduplication lives in the action node. The separate Librus Trigger owns account-scoped discovery history; downstream workflows own delivery state. Reading listing entries does not explicitly call a mark-read endpoint; side effects still need live measurement.
|
|
28
28
|
|
|
@@ -51,3 +51,45 @@ State comparison and assignment occur after the asynchronous scan, with no await
|
|
|
51
51
|
Verified installed n8n 2.37.10 code: n8n-core/dist/nodes-loader/directory-loader.js injects commonPollingParameters; active-workflow-triggers.js runs an activation poll; n8n/dist/workflows/triggers/workflow-trigger-activator.js saves static data after registration. workflow-execution.service.js and poll-cursor.service.js commit a durable cursor with the execution before downstream processing where durable scheduling is enabled. Legacy and durable behaviour depends on host configuration; no host-internal APIs are imported by this package.
|
|
52
52
|
|
|
53
53
|
Public Librus frontend research (2026-09-07): https://wiadomosci.librus.pl/nowy/inbox serves App-DRe0BPBk.js under /nowy/assets/. Its UI includes an unreadOnly filter, a GET inbox detail route and unread counts, but the inspected code exposes no mark-unread action. Only protocol observations were used; no frontend implementation was copied. Local filtering uses the listing contract already exercised by the client rather than relying on another unverified server query parameter.
|
|
54
|
+
|
|
55
|
+
## Calendar protocol and polling contract
|
|
56
|
+
|
|
57
|
+
The calendar month grid has no JSON equivalent: `LibrusClient.monthPage` POSTs form fields `rok` and `miesiac` to `https://synergia.librus.pl/terminarz`. That target is the only entry in a second, credential-free POST allowlist — its body is built solely from validated integers (year 2000-2100, month 1-12), never from free text or a credential, and the existing POST rules (no automatic redirect, no replay) still apply. A POST redirect toward `/loguj` or the OAuth host maps to `SESSION_EXPIRED` rather than an opaque protocol error. Detail pages are fetched from `/terminarz/szczegoly/<id>` and `/terminarz/szczegoly_wolne/<id>` (teacher absences), with the route restricted to those two literal values and the ID restricted to digits before any URL is built. A detail GET follows redirects, so its final address is checked the way `monthPage` checks the month form's: the response must have come from `synergia.librus.pl` at exactly the requested `/terminarz/<route>/<id>`, or it raises the `adres wydarzenia terminarza` protocol error. Without that check, a redirect to any other allowed page would be hydrated onto the event whenever its HTML happened to contain two `th`/`td` rows, which is all `parseEventDetail` requires. `/terminarz/dodane_od_ostatniego_logowania` is deliberately not used as a trigger source: it is consumed on view, so reading it would erase the marker for anyone else watching the same account. Discovery history stays owned by this package, as it already is for messages, kept under its own workflow-static-data key: the message trigger's history lives at `state.librus`, and the calendar's at a separate `state.librusCalendar`, so a calendar poll can never desync the message trigger's cursor or vice versa.
|
|
58
|
+
|
|
59
|
+
Both calendar events share the message trigger's node under one stored parameter, `event`, with three values: `newMessage` (unchanged), `newCalendarEvent` and `changedCalendarEvent`. Existing workflows built against `newMessage` are unaffected; the two calendar values are additive.
|
|
60
|
+
|
|
61
|
+
`parseMonth` requires the day grid to reconstruct every day of the requested month exactly once (`1..N`, no gaps, no duplicates) before returning any entries. An empty month is legitimate; a grid missing its day markers, or one that spills adjacent-month days into the same structure, is not, and either raises `PROTOCOL_ERROR` instead of returning a partial or wholesale-empty result. A calendar can legitimately have zero events in a month; it cannot legitimately fail to enumerate its own days. Silently mis-dating an event is worse than failing the poll outright.
|
|
62
|
+
|
|
63
|
+
A day block extends to the `</div>` that closes its own `div.kalendarz-dzien` element, found by counting `<div` opens against `</div>` closes from the block's opening tag (`terminarz.ts`, `blockEnd`). It does not extend to the start of the next day block. Ending it there would give the last day of the month everything that follows the grid in a real Synergia document — the page legend, the footer, any trailing table — and every `<td>` in that chrome would be read as an entry dated to that day; the day-grid completeness guard would not notice, because trailing content carries no `kalendarz-numer-dnia` and `1..N` still holds. Worse, footer content that varies between polls (a clock, a last-login stamp) would change the synthetic key of its phantom entry on every poll, producing a removal and an addition on the same date that `pairSynthetic` collapses into one `changed` item, emitted at every poll interval forever. The same rule also stops a complete `<td>…</td>` pair sitting between two day blocks from being attributed to the preceding day. An unbalanced document, where the depth never returns to zero, raises the `siatka terminarza` protocol error rather than falling back to the rest of the file: a truncated page must fail the poll, not absorb whatever follows.
|
|
64
|
+
|
|
65
|
+
Each grid entry's fingerprint (`calendarState.fingerprint`) is computed from the entry's date and its raw cell text plus the best-effort teacher/description extraction, not from the individually-parsed subject, lesson number or hour. Those derived fields are read from the same cell text, so if extraction of one of them is ever slightly wrong, it cannot by itself flip the fingerprint and manufacture a false "changed" emission.
|
|
66
|
+
|
|
67
|
+
Change detection is therefore entirely grid-visible: the fingerprint never covers `rodzaj` or `room`, which exist only on the detail page, and a detail page is fetched only for an entry that is new or whose grid fingerprint changed. An edit that changes nothing the grid shows — the room a lesson moved to, say — never flips the fingerprint, so the entry is never re-hydrated and no `changed` event fires for it, even though the new value would show up in `event.room` (and could land in `changedFields`) the next time some other, grid-visible edit does trigger hydration. Re-fetching every entry's detail page on every poll to close this gap was rejected as an unbounded per-poll cost; the narrower, grid-only contract is the accepted trade-off.
|
|
68
|
+
|
|
69
|
+
Before diffing, `planCalendarPoll` validates `state.librusCalendar`: besides the window and revision checks, each stored record's month `m` must match the same `^\d{4}-(0[1-9]|1[0-2])$` pattern the window uses, and every field of its snapshot must match the type `CalendarSnapshot` declares: `date` against `^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$`, `text` a string, `subject`, `teacher`, `description`, `hour`, `rodzaj`, `room` and `addedAt` each a string or `null`, and `lessonNumber` `null` or a non-negative safe integer (`calendarState.ts`, `validEvents` and `validSnapshot`), rejecting malformed state instead of diffing against it. Checking the whole snapshot, not just the two fields the diff reads, matters because the rest is consumed later as its declared type — the trigger's type filter calls `rodzaj.trim()` while emitting a removal, so a stored number there would raise an unhandled TypeError inside an unattended poll instead of `CALENDAR_STATE_INVALID`. The `m` check matters because that field drives both pruning (`scannedMonths.has(record.m)`) and removal detection (`scanned.has(record.m)`): a typed but malformed month would leave a record neither prunable nor removable, stuck in state forever. It then returns a shallow copy of the stored events (`{ ...previous.events }`), never the live object nested in n8n's workflow static data. This matters because planning must be safely repeatable: `LibrusClient.getCalendar` re-runs the month scan and calls the selector — which calls `planCalendarPoll` — again after a session expires mid-poll, and a live reference into static data would let that repeat mutate state the first attempt had already touched, corrupting the record `commitCalendarPoll` later writes.
|
|
70
|
+
|
|
71
|
+
`planCalendarPoll` computes the full added/changed/removed diff synchronously against the stored snapshot, then `commitCalendarPoll` hydrates detail pages only for the `added` and `changed` keys, in ascending lexicographic order, capped by `MAX_HYDRATION` (50) in `calendarState.ts`; `LibrusClient.getCalendar` applies its own independent cap of `MAX_DETAILS` (50) to whatever selection it is handed, so an oversized selection is silently truncated at the transport layer as well as the state layer. Hydration also stops early when fewer than `DETAIL_HEADROOM` (one request plus the ten redirect hops `request()` may follow) of the `MAX_REQUESTS` budget remain: redirect hops count against that budget, so 50 detail pages that each redirect once would otherwise exhaust it and fail the poll before `getCalendar` returned — committing nothing and leaving a persistent redirect pattern to retry the same backlog forever, which is exactly what partial commit exists to prevent. That cap counts only entries that have something to fetch: a synthetic `hash/`-keyed entry has no route or event id, so it is recorded as `null` before the cap is applied and never occupies one of the 50 slots — a real backlog of fetchable entries drains that much faster. Exceeding the cap does not fail the poll: candidates beyond the cutoff are left unresolved and simply retried on a later poll, so a backlog of `n` hydration candidates converges in `ceil(n/50)` polls with no duplicate emission. A resolved candidate — present in the details map, either with a fetched `CalendarDetail` or explicitly as `null` for an entry with no detail link at all (a synthetic `hash/`-keyed entry has no route or event id to fetch, so it is recorded resolved-with-nothing-more-to-fetch) — gets its stored record rebuilt from the fresh entry and detail. A detail page that 404s during hydration is the opposite case: it is deliberately left *out* of the details map entirely rather than recorded as `null`, so it counts as unresolved; its stored record is left untouched and it is retried on the next poll. An unresolved candidate is not treated as a change this poll.
|
|
72
|
+
|
|
73
|
+
A `removed` emission's `details` field (`LibrusTrigger.node.ts`, `scan.details.get(change.key)?.fields`) is always `null`: a key reported as removed is by definition absent from the current scan, so it was never a hydration candidate this poll, and the stored snapshot a removal is built from never retained the raw `th`/`td` detail-page fields to begin with — persisting them for every stored entry would inflate workflow static data against the 2000-entry `MAX_EVENTS` cap. `previous`, which every removal carries as the entry's last known `CalendarSnapshot`, is what describes the event that disappeared.
|
|
74
|
+
|
|
75
|
+
`commitCalendarPoll` re-reads `state.librusCalendar` after the awaited hydration and compares its revision counter and its account against what `planCalendarPoll` observed before hydration started. The account it compares against is the *stored owner recorded on the plan* (`plan.owner`, which is `previous?.account ?? account`), not the account the poll is running as. A mismatch means another poll (or another execution sharing the same static-data object) committed in between; this poll aborts, writing and emitting nothing, and the next poll repeats the diff from scratch. This is the same revision-race protection style as the message trigger's post-scan comparison, extended across an `await`.
|
|
76
|
+
|
|
77
|
+
The distinction matters because the guard must detect concurrent mutation, not a change of ownership. After a deliberate account change — editing the credential, pointing the node at a different credential, changing the username — the stored owner is by definition the previous account, so comparing it against the new one would abort every poll from then on, and `pollCalendar` maps `aborted` to `return null`: the node would emit nothing, raise nothing and never re-baseline. An account change instead plans as a baseline (`baseline = true`, `stored = {}`) and commits that baseline silently, writing the new account; the poll after it emits normally. This matches the message trigger's behaviour in `pollState.ts`.
|
|
78
|
+
|
|
79
|
+
Only baseline entries (the first complete scan for an account), silent-month entries (see below) and resolved hydration candidates get their stored record recomputed on a given poll. An entry that stayed the same — not new, not changed, not up for hydration — keeps its existing stored record byte-for-byte rather than being rebuilt with a null detail. Rebuilding unconditionally would have wiped a `rodzaj`, `room` or `addedAt` value hydrated on an earlier poll (the grid alone carries none of those three fields), and a later removal of that entry would then have reported `rodzaj: null` and slipped past the type filter it should have matched.
|
|
80
|
+
|
|
81
|
+
Stored entries are pruned to the exact set of scanned months on every write: a record whose month is not one of the months this poll scanned is dropped as unobservable, bounding growth to `MAX_EVENTS` (2000); reaching that cap fails the poll without mutating state. Pruning against the scanned-months set rather than only the window's start matters when `monthsAhead` shrinks: a month past the new window's end is not before its start either, so comparing only against the start would leave that month's records stuck in state — unobservable, unreportable as removed, and still counting toward the cap — until the calendar's own advance eventually carried the window past them. When the configured "months ahead" grows, the months the growth itself added are baselined silently — they were never observable before, so their entries are recorded without emitting — while a window that merely advances with the calendar (the same N, one month later) still emits normally.
|
|
82
|
+
|
|
83
|
+
The silent set is measured against the horizon the *current* start month would have reached under the *stored* N (`addMonths(window.from, storedAhead)`), not against the horizon stored at the last poll. Both a span change and a month boundary can land between two polls; measuring against the stored horizon would then silence months that the time advance alone had already brought into reach, turning real additions into silent baselining. A missed notification is worse than a redundant one, so only the months attributable to the growth are silenced.
|
|
84
|
+
|
|
85
|
+
An entry cell without an `onclick` detail link (a free day, a note with no drill-down) has no stable identifier, so `calendarState.ts` gives it a synthetic key hashed from its date and cell text. Because that key changes whenever the text changes, an edited link-less entry looks like one disappearance plus one appearance. `pairSynthetic` collapses exactly one `hash/`-keyed removal and exactly one `hash/`-keyed addition on the same date within the same poll into a single `changed` item. Documented failure mode: a genuine deletion and a genuine unrelated addition landing on that same date in the same poll collapse into one reported change instead of two, because the heuristic cannot distinguish the two situations from the grid text alone.
|
|
86
|
+
|
|
87
|
+
A calendar poll's request budget is reset per attempt, not per operation. A full window costs roughly 8 requests to authenticate, one per month (up to 7) and up to 50 detail pages — about 65 against a cap of 100 — so a session expiry late in hydration would leave a shared counter unable to fund the second attempt, and the retry would die on the budget rather than on the protocol. `getCalendar` therefore resets `this.requests` alongside the re-`authenticate()` in its retry path. The 120-second deadline is not reset and still bounds the operation as a whole. The message path is unchanged: `getMessages` and `getMessageContent` keep one budget across both attempts.
|
|
88
|
+
|
|
89
|
+
An exhausted budget on the calendar path raises `CALENDAR_SCAN_INCOMPLETE`, whose Polish message tells the user to reduce **Liczba miesięcy do przodu** — the only lever the calendar events have. Invalid calendar options raise `CALENDAR_INVALID_OPTIONS`, which names the same setting instead of the message-fetching settings `INVALID_OPTIONS` describes; `INVALID_OPTIONS` and every message-path raise site are unchanged. `SCAN_INCOMPLETE`, which names the inbox's **Maksymalna liczba stron**, is unchanged and stays on the message path; the client selects between them with a per-operation `budgetCode` field.
|
|
90
|
+
|
|
91
|
+
`Rodzaj` (event kind) is free text read only from the detail page, so an entry baselined or left unresolved without hydration has no known kind. The type filter (`eventTypes`, comma-separated, case-insensitive) always lets an entry with an unknown `rodzaj` through, on the same reasoning as the mass-removal guard: a missed cancellation is worse than one extra item reaching the workflow.
|
|
92
|
+
|
|
93
|
+
The manual execution path deliberately does not apply that filter to its sample of up to five current-month entries, unlike the automatic path. The `Rodzaj` vocabulary is unverified, and the manual test is exactly how a user discovers the real values before filling the filter in: filtering the sample would hide the values it exists to reveal, and a narrow filter would return an empty sample that reads as a broken connection. The inconsistency is intentional and is stated in the node's calendar notice and in the README.
|
|
94
|
+
|
|
95
|
+
None of this — the POST target, the grid markup, the detail-page shape, the `Rodzaj` vocabulary, or whether polling disturbs the Librus UI — is live-verified. Every check performed so far is against offline HTML fixtures written from source inspection of third-party unofficial clients, not against a real Librus account.
|
|
@@ -21,3 +21,61 @@ Do not store cookies in workflow static data or node output when adding session
|
|
|
21
21
|
- In a deliberately activated test workflow with no outbound action, confirm the first automatic poll emits no old messages; one newly received message emits once, even if read before polling. Do not send test messages to others without authorization.
|
|
22
22
|
- Restart the test instance with persistent storage; confirm no replay. Exercise downstream-failure recovery with synthetic data and execution retry.
|
|
23
23
|
- Check account-switch baseline, an inbox beyond the page cap, and ordinary session/browser effects before enabling unattended use.
|
|
24
|
+
|
|
25
|
+
## Calendar trigger acceptance
|
|
26
|
+
|
|
27
|
+
Observed on 2026-09-13, one account, one class, September 2026 only. A manual sample
|
|
28
|
+
returned four current-month entries and hydrated three detail pages.
|
|
29
|
+
|
|
30
|
+
- The grid rendered exactly the 30 days of the requested month with no adjacent-month
|
|
31
|
+
days: the `numery dni miesiaca` guard did not fire. Item 1 below holds for this month;
|
|
32
|
+
a month whose grid starts or ends mid-week may still differ, so re-check at a month
|
|
33
|
+
boundary before trusting it generally.
|
|
34
|
+
- Real `Rodzaj` values seen: `Inne`, `Impreza szkolna`, `Wycieczka`. Partial: one class,
|
|
35
|
+
one month. Not enough to turn the free-text filter into a multi-select.
|
|
36
|
+
- Detail rows seen: `Data`, `Nr lekcji`, `Nauczyciel`, `Rodzaj`, `Przedmiot`, `Opis`,
|
|
37
|
+
`Data dodania`. No `Sala` row appeared, so `room` stayed null on every entry; it is
|
|
38
|
+
still unknown whether any entry kind carries one.
|
|
39
|
+
- A `Wywiadowka` entry carried no detail link at all: no id, no route, so a synthetic
|
|
40
|
+
`hash/` key, and `rodzaj`, `room` and `addedAt` permanently null. The grid itself
|
|
41
|
+
supplied its teacher, description and hour. This is a second detail-less kind beyond
|
|
42
|
+
the `szczegoly_wolne` free days the design anticipated, and it makes the "unknown kind
|
|
43
|
+
always passes" rule in `matchesType` load-bearing rather than defensive: any non-empty
|
|
44
|
+
type filter would otherwise drop every parent-teacher meeting.
|
|
45
|
+
- The grid encodes its title attribute twice and the entity table covered only the five
|
|
46
|
+
XML names, so a description reached the user as `które` where the detail page of
|
|
47
|
+
the same event gave `które`. Both are fixed; re-check on a fresh account that no entity
|
|
48
|
+
text survives into the output, especially on an entry with no detail page.
|
|
49
|
+
- Consequence of the synthetic key, not yet observed live: a detail-less entry moved to
|
|
50
|
+
another date reports as a removal plus an addition rather than a change, because
|
|
51
|
+
`pairSynthetic` only pairs within one date. Entries with an id move correctly.
|
|
52
|
+
- Still unobserved: the container markup of a real detail page (item 4), every item from
|
|
53
|
+
5 onwards, and any month other than September 2026.
|
|
54
|
+
|
|
55
|
+
Testing gotchas, learned the hard way on 2026-09-13:
|
|
56
|
+
|
|
57
|
+
- A poll trigger that finds nothing returns no data, and n8n then creates **no
|
|
58
|
+
execution**. An empty execution list is the expected result of a silent poll, not
|
|
59
|
+
evidence that polling is broken. The database cannot tell the two apart either: n8n
|
|
60
|
+
calls `saveStaticData` only from the emit path, so a silent poll advances the stored
|
|
61
|
+
revision counter in memory and never writes it. Emitting is the only flush observed to
|
|
62
|
+
work: a deactivation and an ordinary stop of the dev server both left the stored counter
|
|
63
|
+
behind. To watch polling live, run the instance with `N8N_LOG_LEVEL=debug` and follow
|
|
64
|
+
the `Poll trigger initiated` lines.
|
|
65
|
+
- Losing those in-memory increments to a crash is harmless by construction: a silent poll
|
|
66
|
+
records only what the next scan of the calendar reconstructs, so a rebuilt baseline is
|
|
67
|
+
silent again and emits no duplicates.
|
|
68
|
+
- Stopping the dev server, gracefully or not, leaves an active workflow persisted as
|
|
69
|
+
`active=0`, and it stays off after the restart: polling then looks broken for as long as nobody checks.
|
|
70
|
+
Editing a source file is fine — the rebuild hot-reloads the node and the workflow stays
|
|
71
|
+
active. So after every restart of `npm run dev`, confirm `active` in the UI or in the
|
|
72
|
+
database before trusting any polling observation.
|
|
73
|
+
|
|
74
|
+
1. Confirm the month form: POST `rok`/`miesiac` returns the requested month, and that the grid renders every day of that month with no adjacent-month days. A `numery dni miesiąca` failure means the assumption is wrong — record the shape, do not paste private content.
|
|
75
|
+
2. Record the real `Rodzaj` vocabulary from the emitted output so the free-text filter can become a multi-select later.
|
|
76
|
+
3. Open a `szczegoly_wolne` entry and confirm its detail page parses; note whether it redirects.
|
|
77
|
+
4. Record the structure of a real `/terminarz/szczegoly/<id>` page: whether the detail table sits inside a `div.container-background` container, and whether any other Synergia page that can be served at that path (an error page, a notice, a redirect target) presents two `th`/`td` rows. The parser accepts any two-row `th`/`td` table anywhere in the document, and the client rejects only a response whose final URL is not the requested path. With that evidence the structural check can be tightened to the container; without it, tightening would risk a hard outage on every detail fetch. Record the shape, not the content.
|
|
78
|
+
5. Confirm the first automatic poll emits nothing, a newly added event emits once, an edited description and a moved date each emit one change, and a removed event emits one removal.
|
|
79
|
+
6. Restart the instance with persistent storage and confirm no replay.
|
|
80
|
+
7. Confirm calendar polling does not disturb the Librus web UI, and that the message trigger on the same account is unaffected.
|
|
81
|
+
8. Measure a safe poll interval before unattended use; 15 minutes or slower is the starting point.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@czlonkowski/n8n-nodes-librus",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dwa węzły n8n do pobierania wiadomości z Librus Synergia i uruchamiania automatyzacji po
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Dwa węzły n8n do pobierania wiadomości z Librus Synergia i uruchamiania automatyzacji po nowych wiadomościach oraz po dodaniu lub zmianie wydarzeń w terminarzu.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"n8n-community-node-package",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"NOTICE.md",
|
|
32
32
|
"SECURITY.md",
|
|
33
33
|
"CHANGELOG.md",
|
|
34
|
-
"docs"
|
|
34
|
+
"docs/*.md"
|
|
35
35
|
],
|
|
36
36
|
"n8n": {
|
|
37
37
|
"n8nNodesApiVersion": 1,
|