chuvsu-js 4.2.0 → 5.0.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/README.md +165 -193
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/common/cache.js +30 -9
- package/dist/common/http.d.ts +6 -0
- package/dist/common/http.js +8 -1
- package/dist/common/parse.d.ts +1 -1
- package/dist/common/parse.js +2 -2
- package/dist/common/types.d.ts +8 -2
- package/dist/common/types.js +12 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +5 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -4
- package/dist/lk/client.d.ts +8 -6
- package/dist/lk/client.js +51 -23
- package/dist/lk/types.d.ts +7 -7
- package/dist/node.d.ts +1 -0
- package/dist/node.js +1 -0
- package/dist/parsers.d.ts +3 -0
- package/dist/parsers.js +2 -0
- package/dist/tt/client.d.ts +55 -63
- package/dist/tt/client.js +477 -226
- package/dist/tt/domain/directory.d.ts +14 -0
- package/dist/tt/domain/directory.js +116 -0
- package/dist/tt/domain/ids.d.ts +7 -0
- package/dist/tt/domain/ids.js +20 -0
- package/dist/tt/domain/index.d.ts +7 -0
- package/dist/tt/domain/index.js +5 -0
- package/dist/tt/domain/normalize.d.ts +7 -0
- package/dist/tt/domain/normalize.js +65 -0
- package/dist/tt/domain/repository.d.ts +51 -0
- package/dist/tt/domain/repository.js +805 -0
- package/dist/tt/domain/schedule.d.ts +37 -0
- package/dist/tt/domain/schedule.js +292 -0
- package/dist/tt/domain/types.d.ts +175 -0
- package/dist/tt/domain/types.js +1 -0
- package/dist/tt/observations.d.ts +11 -0
- package/dist/tt/observations.js +131 -0
- package/dist/tt/parse/audience.d.ts +3 -3
- package/dist/tt/parse/audience.js +47 -15
- package/dist/tt/parse/entry-parts.d.ts +1 -0
- package/dist/tt/parse/entry-parts.js +5 -2
- package/dist/tt/parse/full-schedule.d.ts +9 -4
- package/dist/tt/parse/full-schedule.js +32 -41
- package/dist/tt/parse/groups.d.ts +1 -1
- package/dist/tt/parse/groups.js +1 -1
- package/dist/tt/parse/index.d.ts +4 -4
- package/dist/tt/parse/index.js +4 -4
- package/dist/tt/parse/lists.d.ts +6 -5
- package/dist/tt/parse/lists.js +7 -2
- package/dist/tt/parse/overlays.d.ts +5 -4
- package/dist/tt/parse/overlays.js +13 -14
- package/dist/tt/parse/teacher.d.ts +2 -3
- package/dist/tt/parse/teacher.js +14 -56
- package/dist/tt/parse/webinars.js +3 -4
- package/dist/tt/types.d.ts +54 -69
- package/dist/tt/utils/date.d.ts +4 -0
- package/dist/tt/utils/date.js +30 -1
- package/dist/tt/utils/index.d.ts +2 -3
- package/dist/tt/utils/index.js +2 -3
- package/dist/tt/utils/period.d.ts +4 -4
- package/dist/tt/utils/period.js +9 -8
- package/dist/tt/utils/semester.d.ts +4 -4
- package/dist/tt/utils/semester.js +25 -5
- package/dist/tt/utils/time-slots.d.ts +3 -5
- package/dist/tt/utils/time-slots.js +19 -34
- package/dist/tt/webinars.d.ts +7 -0
- package/dist/tt/webinars.js +38 -0
- package/docs/fixture-review.md +56 -0
- package/docs/testing.md +23 -0
- package/docs/v5-architecture.md +144 -0
- package/docs/v5-migration.md +451 -0
- package/package.json +17 -7
- package/dist/shared.d.ts +0 -9
- package/dist/shared.js +0 -7
- package/dist/tt/schedule.d.ts +0 -55
- package/dist/tt/schedule.js +0 -233
- package/dist/tt/utils/lessons.d.ts +0 -16
- package/dist/tt/utils/lessons.js +0 -173
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import { isLocalDate } from "../utils/date.js";
|
|
2
|
+
import { TimetableDirectory } from "./directory.js";
|
|
3
|
+
import { RandomLessonIdGenerator } from "./ids.js";
|
|
4
|
+
import { entityKey, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
|
|
5
|
+
function claimKey(sourceKey, observationKey) {
|
|
6
|
+
return `${sourceKey}\u0000${observationKey}`;
|
|
7
|
+
}
|
|
8
|
+
function sourceObservationKey(sourceKey, observationKey) {
|
|
9
|
+
return claimKey(sourceKey, observationKey);
|
|
10
|
+
}
|
|
11
|
+
function sameDate(a, b) {
|
|
12
|
+
return a === b;
|
|
13
|
+
}
|
|
14
|
+
function rangesOverlap(a, b) {
|
|
15
|
+
const aFrom = a?.from ?? 1;
|
|
16
|
+
const aTo = a?.to ?? 17;
|
|
17
|
+
const bFrom = b?.from ?? 1;
|
|
18
|
+
const bTo = b?.to ?? 17;
|
|
19
|
+
return Math.max(aFrom, bFrom) <= Math.min(aTo, bTo);
|
|
20
|
+
}
|
|
21
|
+
function recurrenceWeeks(observation) {
|
|
22
|
+
const from = observation.recurrence.weeks?.from ?? 1;
|
|
23
|
+
const to = observation.recurrence.weeks?.to ?? 17;
|
|
24
|
+
const weeks = [];
|
|
25
|
+
for (let week = from; week <= to; week++) {
|
|
26
|
+
if (observation.recurrence.parity === "even" && week % 2 !== 0)
|
|
27
|
+
continue;
|
|
28
|
+
if (observation.recurrence.parity === "odd" && week % 2 === 0)
|
|
29
|
+
continue;
|
|
30
|
+
weeks.push(week);
|
|
31
|
+
}
|
|
32
|
+
return weeks.join(",");
|
|
33
|
+
}
|
|
34
|
+
function sameTimeRange(left, right) {
|
|
35
|
+
return left.start.hours === right.start.hours &&
|
|
36
|
+
left.start.minutes === right.start.minutes &&
|
|
37
|
+
left.end.hours === right.end.hours &&
|
|
38
|
+
left.end.minutes === right.end.minutes;
|
|
39
|
+
}
|
|
40
|
+
function valuesOverlap(left, right) {
|
|
41
|
+
if (!left?.length || !right?.length)
|
|
42
|
+
return undefined;
|
|
43
|
+
const keys = new Set(left.map(entityKey));
|
|
44
|
+
return right.some((value) => keys.has(entityKey(value)));
|
|
45
|
+
}
|
|
46
|
+
function groupsOverlap(left, right) {
|
|
47
|
+
if (!left?.length || !right?.length)
|
|
48
|
+
return undefined;
|
|
49
|
+
const keys = new Set(left.map((value) => entityKey(value.group)));
|
|
50
|
+
return right.some((value) => keys.has(entityKey(value.group)));
|
|
51
|
+
}
|
|
52
|
+
function relationScore(overlap) {
|
|
53
|
+
if (overlap === true)
|
|
54
|
+
return 14;
|
|
55
|
+
if (overlap === false)
|
|
56
|
+
return -18;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
function observationScore(left, right, sameSource, directory) {
|
|
60
|
+
if (left.kind !== right.kind)
|
|
61
|
+
return Number.NEGATIVE_INFINITY;
|
|
62
|
+
if (normalizeScheduleText(left.subject) !== normalizeScheduleText(right.subject)) {
|
|
63
|
+
return Number.NEGATIVE_INFINITY;
|
|
64
|
+
}
|
|
65
|
+
if (left.kind === "series" && right.kind === "series") {
|
|
66
|
+
if (left.recurrence.weekday !== right.recurrence.weekday) {
|
|
67
|
+
return Number.NEGATIVE_INFINITY;
|
|
68
|
+
}
|
|
69
|
+
if (!rangesOverlap(left.recurrence.weeks, right.recurrence.weeks) ||
|
|
70
|
+
recurrenceWeeks(left) !== recurrenceWeeks(right)) {
|
|
71
|
+
return Number.NEGATIVE_INFINITY;
|
|
72
|
+
}
|
|
73
|
+
if (left.recurrence.parity != null &&
|
|
74
|
+
right.recurrence.parity != null &&
|
|
75
|
+
left.recurrence.parity !== right.recurrence.parity) {
|
|
76
|
+
return Number.NEGATIVE_INFINITY;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (left.kind === "occurrence" &&
|
|
80
|
+
right.kind === "occurrence" &&
|
|
81
|
+
!sameDate(left.date, right.date)) {
|
|
82
|
+
return Number.NEGATIVE_INFINITY;
|
|
83
|
+
}
|
|
84
|
+
let score = 35;
|
|
85
|
+
if (normalizeScheduleText(left.type) === normalizeScheduleText(right.type)) {
|
|
86
|
+
score += 10;
|
|
87
|
+
}
|
|
88
|
+
else if (left.type && right.type) {
|
|
89
|
+
score -= 12;
|
|
90
|
+
}
|
|
91
|
+
if (left.slotNumber != null &&
|
|
92
|
+
right.slotNumber != null &&
|
|
93
|
+
left.slotNumber === right.slotNumber)
|
|
94
|
+
score += 12;
|
|
95
|
+
if (left.time &&
|
|
96
|
+
right.time &&
|
|
97
|
+
sameTimeRange(left.time, right.time)) {
|
|
98
|
+
score += 12;
|
|
99
|
+
}
|
|
100
|
+
if (left.kind === "series" && right.kind === "series") {
|
|
101
|
+
score += 24;
|
|
102
|
+
if (left.recurrence.parity === right.recurrence.parity)
|
|
103
|
+
score += 5;
|
|
104
|
+
}
|
|
105
|
+
if (left.kind === "occurrence" && right.kind === "occurrence") {
|
|
106
|
+
score += 34;
|
|
107
|
+
if (left.transfer &&
|
|
108
|
+
right.transfer &&
|
|
109
|
+
sameDate(left.transfer.fromDate, right.transfer.fromDate) &&
|
|
110
|
+
left.transfer.fromSlot === right.transfer.fromSlot) {
|
|
111
|
+
score += 30;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
score += relationScore(valuesOverlap(left.rooms.values.map((value) => directory.resolveRoom(value)), right.rooms.values.map((value) => directory.resolveRoom(value))));
|
|
115
|
+
score += relationScore(valuesOverlap(left.teachers.values.map((value) => directory.resolveTeacher(value)), right.teachers.values.map((value) => directory.resolveTeacher(value))));
|
|
116
|
+
score += relationScore(groupsOverlap(left.groups.values.map((value) => ({
|
|
117
|
+
...value,
|
|
118
|
+
group: directory.resolveGroup(value.group),
|
|
119
|
+
})), right.groups.values.map((value) => ({
|
|
120
|
+
...value,
|
|
121
|
+
group: directory.resolveGroup(value.group),
|
|
122
|
+
}))));
|
|
123
|
+
if (sameSource)
|
|
124
|
+
score += 8;
|
|
125
|
+
return score;
|
|
126
|
+
}
|
|
127
|
+
function serializeObservation(observation) {
|
|
128
|
+
return structuredClone(observation);
|
|
129
|
+
}
|
|
130
|
+
function deserializeObservation(observation) {
|
|
131
|
+
return structuredClone(observation);
|
|
132
|
+
}
|
|
133
|
+
function serializeSource(source) {
|
|
134
|
+
return {
|
|
135
|
+
...source,
|
|
136
|
+
observedAt: source.observedAt.toISOString(),
|
|
137
|
+
observations: source.observations.map(serializeObservation),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function deserializeSource(source) {
|
|
141
|
+
return {
|
|
142
|
+
...source,
|
|
143
|
+
observedAt: new Date(source.observedAt),
|
|
144
|
+
observations: source.observations.map(deserializeObservation),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function sourceRef(claim) {
|
|
148
|
+
return {
|
|
149
|
+
sourceKey: claim.source.sourceKey,
|
|
150
|
+
observationKey: claim.observation.key,
|
|
151
|
+
observedAt: new Date(claim.source.observedAt),
|
|
152
|
+
owner: claim.source.owner,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function deterministicClaims(record) {
|
|
156
|
+
return [...record.claims.values()].sort((a, b) => {
|
|
157
|
+
const source = a.source.sourceKey.localeCompare(b.source.sourceKey);
|
|
158
|
+
return source || a.observation.key.localeCompare(b.observation.key);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function preferredClaim(claims) {
|
|
162
|
+
return [...claims].sort((a, b) => {
|
|
163
|
+
const completeness = (claim) => claim.observation.groups.values.length +
|
|
164
|
+
claim.observation.teachers.values.length +
|
|
165
|
+
claim.observation.rooms.values.length;
|
|
166
|
+
return (completeness(b) - completeness(a) ||
|
|
167
|
+
a.source.sourceKey.localeCompare(b.source.sourceKey) ||
|
|
168
|
+
a.observation.key.localeCompare(b.observation.key));
|
|
169
|
+
})[0];
|
|
170
|
+
}
|
|
171
|
+
function consensusField(claims, select, key) {
|
|
172
|
+
const ranked = [...claims].sort((a, b) => {
|
|
173
|
+
const completeness = (claim) => claim.observation.groups.values.length +
|
|
174
|
+
claim.observation.teachers.values.length +
|
|
175
|
+
claim.observation.rooms.values.length;
|
|
176
|
+
return (completeness(b) - completeness(a) ||
|
|
177
|
+
a.source.sourceKey.localeCompare(b.source.sourceKey) ||
|
|
178
|
+
a.observation.key.localeCompare(b.observation.key));
|
|
179
|
+
});
|
|
180
|
+
const evidence = new Map();
|
|
181
|
+
for (const [rank, claim] of ranked.entries()) {
|
|
182
|
+
const value = select(claim.observation);
|
|
183
|
+
if (value === undefined)
|
|
184
|
+
continue;
|
|
185
|
+
const evidenceKey = key(value);
|
|
186
|
+
const current = evidence.get(evidenceKey);
|
|
187
|
+
if (current == null) {
|
|
188
|
+
evidence.set(evidenceKey, { value, count: 1, rank });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
current.count++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [...evidence.values()].sort((a, b) => b.count - a.count || a.rank - b.rank)[0]?.value;
|
|
195
|
+
}
|
|
196
|
+
function ownerEntity(owner) {
|
|
197
|
+
if (owner.type === "group") {
|
|
198
|
+
return { kind: "group", value: { group: owner.group } };
|
|
199
|
+
}
|
|
200
|
+
if (owner.type === "teacher")
|
|
201
|
+
return { kind: "teacher", value: owner };
|
|
202
|
+
return { kind: "room", value: owner };
|
|
203
|
+
}
|
|
204
|
+
function aggregateCompleteness(sets) {
|
|
205
|
+
if (sets.some((value) => value.completeness === "partial" || value.values.length > 0)) {
|
|
206
|
+
return "partial";
|
|
207
|
+
}
|
|
208
|
+
if (sets.some((value) => value.completeness === "complete"))
|
|
209
|
+
return "complete";
|
|
210
|
+
return "unknown";
|
|
211
|
+
}
|
|
212
|
+
function aggregateRelations(claims) {
|
|
213
|
+
const groups = [];
|
|
214
|
+
const teachers = [];
|
|
215
|
+
const rooms = [];
|
|
216
|
+
for (const claim of claims) {
|
|
217
|
+
groups.push(...claim.observation.groups.values);
|
|
218
|
+
teachers.push(...claim.observation.teachers.values);
|
|
219
|
+
rooms.push(...claim.observation.rooms.values);
|
|
220
|
+
const implicit = ownerEntity(claim.source.owner);
|
|
221
|
+
if (implicit.kind === "group" &&
|
|
222
|
+
!claim.observation.groups.values.some((value) => entityKey(value.group) === entityKey(implicit.value.group))) {
|
|
223
|
+
groups.push(implicit.value);
|
|
224
|
+
}
|
|
225
|
+
if (implicit.kind === "teacher")
|
|
226
|
+
teachers.push(implicit.value.teacher);
|
|
227
|
+
if (implicit.kind === "room")
|
|
228
|
+
rooms.push(implicit.value.room);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
groups: {
|
|
232
|
+
values: mergeGroups(groups),
|
|
233
|
+
completeness: claims.some((value) => value.source.owner.type === "group")
|
|
234
|
+
? "partial"
|
|
235
|
+
: aggregateCompleteness(claims.map((value) => value.observation.groups)),
|
|
236
|
+
},
|
|
237
|
+
teachers: {
|
|
238
|
+
values: mergeTeachers(teachers),
|
|
239
|
+
completeness: claims.some((value) => value.source.owner.type === "teacher")
|
|
240
|
+
? "partial"
|
|
241
|
+
: aggregateCompleteness(claims.map((value) => value.observation.teachers)),
|
|
242
|
+
},
|
|
243
|
+
rooms: {
|
|
244
|
+
values: mergeRooms(rooms),
|
|
245
|
+
completeness: claims.some((value) => value.source.owner.type === "room")
|
|
246
|
+
? "partial"
|
|
247
|
+
: aggregateCompleteness(claims.map((value) => value.observation.rooms)),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function ownerMatches(owner, relations) {
|
|
252
|
+
if (owner.type === "group") {
|
|
253
|
+
const key = entityKey(owner.group);
|
|
254
|
+
return relations.groups.values.some((value) => entityKey(value.group) === key);
|
|
255
|
+
}
|
|
256
|
+
if (owner.type === "teacher") {
|
|
257
|
+
const key = entityKey(owner.teacher);
|
|
258
|
+
return relations.teachers.values.some((value) => entityKey(value) === key);
|
|
259
|
+
}
|
|
260
|
+
const key = entityKey(owner.room);
|
|
261
|
+
return relations.rooms.values.some((value) => entityKey(value) === key);
|
|
262
|
+
}
|
|
263
|
+
export class TimetableRepository {
|
|
264
|
+
idGenerator;
|
|
265
|
+
_directory;
|
|
266
|
+
sources = new Map();
|
|
267
|
+
links = new Map();
|
|
268
|
+
seriesRecords = new Map();
|
|
269
|
+
occurrenceRecords = new Map();
|
|
270
|
+
_revision = 0;
|
|
271
|
+
constructor(opts) {
|
|
272
|
+
this.idGenerator = opts?.idGenerator ?? new RandomLessonIdGenerator();
|
|
273
|
+
this._directory = new TimetableDirectory();
|
|
274
|
+
if (opts?.snapshot)
|
|
275
|
+
this.restore(opts.snapshot);
|
|
276
|
+
}
|
|
277
|
+
get directory() {
|
|
278
|
+
return this._directory;
|
|
279
|
+
}
|
|
280
|
+
get revision() {
|
|
281
|
+
return this._revision;
|
|
282
|
+
}
|
|
283
|
+
rememberGroups(values) {
|
|
284
|
+
if (this.directory.rememberGroups(values))
|
|
285
|
+
this._revision++;
|
|
286
|
+
}
|
|
287
|
+
rememberTeachers(values) {
|
|
288
|
+
if (this.directory.rememberTeachers(values))
|
|
289
|
+
this._revision++;
|
|
290
|
+
}
|
|
291
|
+
rememberRooms(values) {
|
|
292
|
+
if (this.directory.rememberRooms(values))
|
|
293
|
+
this._revision++;
|
|
294
|
+
}
|
|
295
|
+
ingest(snapshot) {
|
|
296
|
+
const incoming = structuredClone(snapshot);
|
|
297
|
+
this.validateSource(incoming);
|
|
298
|
+
this.rememberSourceEntities(incoming);
|
|
299
|
+
const prior = this.sources.get(incoming.sourceKey);
|
|
300
|
+
const priorClaims = new Map();
|
|
301
|
+
if (prior) {
|
|
302
|
+
for (const observation of prior.observations) {
|
|
303
|
+
priorClaims.set(observation.key, { source: prior, observation });
|
|
304
|
+
this.removeClaim(incoming.sourceKey, observation.key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
this.sources.set(incoming.sourceKey, incoming);
|
|
308
|
+
const usedTargets = new Set();
|
|
309
|
+
const seriesIds = [];
|
|
310
|
+
const lessonIds = [];
|
|
311
|
+
let created = 0;
|
|
312
|
+
let updated = 0;
|
|
313
|
+
const currentKeys = new Set(incoming.observations.map((value) => value.key));
|
|
314
|
+
for (const observation of incoming.observations) {
|
|
315
|
+
const linkKey = sourceObservationKey(incoming.sourceKey, observation.key);
|
|
316
|
+
const exact = this.links.get(linkKey);
|
|
317
|
+
const exactPrior = priorClaims.get(observation.key);
|
|
318
|
+
let target = exact?.kind === observation.kind &&
|
|
319
|
+
exactPrior?.observation.kind === observation.kind &&
|
|
320
|
+
observationScore(observation, exactPrior.observation, true, this.directory) >= 55 &&
|
|
321
|
+
!usedTargets.has(exact.id)
|
|
322
|
+
? exact
|
|
323
|
+
: undefined;
|
|
324
|
+
if (!target) {
|
|
325
|
+
target = this.findPriorTarget(observation, priorClaims, usedTargets);
|
|
326
|
+
}
|
|
327
|
+
if (!target) {
|
|
328
|
+
target = this.findCanonicalTarget(observation, incoming, usedTargets);
|
|
329
|
+
}
|
|
330
|
+
if (!target) {
|
|
331
|
+
target =
|
|
332
|
+
observation.kind === "series"
|
|
333
|
+
? { kind: "series", id: this.idGenerator.seriesId() }
|
|
334
|
+
: { kind: "occurrence", id: this.idGenerator.lessonId() };
|
|
335
|
+
created++;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
updated++;
|
|
339
|
+
}
|
|
340
|
+
this.links.set(linkKey, target);
|
|
341
|
+
usedTargets.add(target.id);
|
|
342
|
+
this.addClaim(target, incoming, observation);
|
|
343
|
+
if (target.kind === "series")
|
|
344
|
+
seriesIds.push(target.id);
|
|
345
|
+
else
|
|
346
|
+
lessonIds.push(target.id);
|
|
347
|
+
}
|
|
348
|
+
if (prior) {
|
|
349
|
+
for (const observation of prior.observations) {
|
|
350
|
+
if (!currentKeys.has(observation.key)) {
|
|
351
|
+
this.links.delete(sourceObservationKey(incoming.sourceKey, observation.key));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.pruneEmptyRecords();
|
|
356
|
+
this._revision++;
|
|
357
|
+
return {
|
|
358
|
+
revision: this._revision,
|
|
359
|
+
seriesIds,
|
|
360
|
+
lessonIds,
|
|
361
|
+
created,
|
|
362
|
+
updated,
|
|
363
|
+
removedObservations: Math.max(0, prior?.observations.filter((value) => !currentKeys.has(value.key)).length ?? 0),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
getSeries(opts) {
|
|
367
|
+
const values = [];
|
|
368
|
+
for (const record of this.seriesRecords.values()) {
|
|
369
|
+
if (record.claims.size === 0)
|
|
370
|
+
continue;
|
|
371
|
+
const value = this.aggregateSeries(record);
|
|
372
|
+
if (opts?.academicYearStartYear != null &&
|
|
373
|
+
value.academicYearStartYear !== opts.academicYearStartYear) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (opts?.periods && !opts.periods.includes(value.period))
|
|
377
|
+
continue;
|
|
378
|
+
if (opts?.owner && !ownerMatches(opts.owner, value))
|
|
379
|
+
continue;
|
|
380
|
+
values.push(value);
|
|
381
|
+
}
|
|
382
|
+
return values.sort((a, b) => a.recurrence.weekday - b.recurrence.weekday ||
|
|
383
|
+
(a.slotNumber ?? Number.MAX_SAFE_INTEGER) -
|
|
384
|
+
(b.slotNumber ?? Number.MAX_SAFE_INTEGER) ||
|
|
385
|
+
a.subject.localeCompare(b.subject) ||
|
|
386
|
+
a.id.localeCompare(b.id));
|
|
387
|
+
}
|
|
388
|
+
getDirectOccurrences(opts) {
|
|
389
|
+
const values = [];
|
|
390
|
+
for (const record of this.occurrenceRecords.values()) {
|
|
391
|
+
if (record.claims.size === 0)
|
|
392
|
+
continue;
|
|
393
|
+
const value = this.aggregateOccurrence(record);
|
|
394
|
+
if (opts?.academicYearStartYear != null &&
|
|
395
|
+
value.academicYearStartYear !== opts.academicYearStartYear) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (opts?.periods && !opts.periods.includes(value.period))
|
|
399
|
+
continue;
|
|
400
|
+
if (opts?.owner && !ownerMatches(opts.owner, value))
|
|
401
|
+
continue;
|
|
402
|
+
values.push(value);
|
|
403
|
+
}
|
|
404
|
+
return values.sort((a, b) => a.scheduledDate.localeCompare(b.scheduledDate) ||
|
|
405
|
+
((a.time?.start.hours ?? 24) * 60 + (a.time?.start.minutes ?? 0)) -
|
|
406
|
+
((b.time?.start.hours ?? 24) * 60 + (b.time?.start.minutes ?? 0)) ||
|
|
407
|
+
a.id.localeCompare(b.id));
|
|
408
|
+
}
|
|
409
|
+
export() {
|
|
410
|
+
return {
|
|
411
|
+
schemaVersion: 5,
|
|
412
|
+
revision: this._revision,
|
|
413
|
+
directory: this.directory.export(),
|
|
414
|
+
sources: [...this.sources.values()]
|
|
415
|
+
.sort((a, b) => a.sourceKey.localeCompare(b.sourceKey))
|
|
416
|
+
.map(serializeSource),
|
|
417
|
+
links: [...this.links.entries()]
|
|
418
|
+
.map(([key, value]) => {
|
|
419
|
+
const separator = key.indexOf("\u0000");
|
|
420
|
+
return {
|
|
421
|
+
sourceKey: key.slice(0, separator),
|
|
422
|
+
observationKey: key.slice(separator + 1),
|
|
423
|
+
...value,
|
|
424
|
+
};
|
|
425
|
+
})
|
|
426
|
+
.sort((a, b) => a.sourceKey.localeCompare(b.sourceKey) ||
|
|
427
|
+
a.observationKey.localeCompare(b.observationKey)),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
replaceSnapshot(snapshot) {
|
|
431
|
+
this.restore(snapshot);
|
|
432
|
+
}
|
|
433
|
+
restore(snapshot) {
|
|
434
|
+
if (snapshot.schemaVersion !== 5) {
|
|
435
|
+
throw new Error(`Unsupported timetable repository schema: ${snapshot.schemaVersion}`);
|
|
436
|
+
}
|
|
437
|
+
if (!Number.isInteger(snapshot.revision) || snapshot.revision < 0) {
|
|
438
|
+
throw new Error("Invalid timetable repository revision");
|
|
439
|
+
}
|
|
440
|
+
const sources = snapshot.sources.map(deserializeSource);
|
|
441
|
+
const sourceKeys = new Set();
|
|
442
|
+
for (const source of sources) {
|
|
443
|
+
this.validateSource(source);
|
|
444
|
+
if (sourceKeys.has(source.sourceKey)) {
|
|
445
|
+
throw new Error(`Duplicate timetable source: ${source.sourceKey}`);
|
|
446
|
+
}
|
|
447
|
+
sourceKeys.add(source.sourceKey);
|
|
448
|
+
}
|
|
449
|
+
this.sources.clear();
|
|
450
|
+
this.links.clear();
|
|
451
|
+
this.seriesRecords.clear();
|
|
452
|
+
this.occurrenceRecords.clear();
|
|
453
|
+
this._directory = new TimetableDirectory(snapshot.directory);
|
|
454
|
+
this._revision = snapshot.revision;
|
|
455
|
+
for (const source of sources) {
|
|
456
|
+
this.sources.set(source.sourceKey, source);
|
|
457
|
+
}
|
|
458
|
+
const linkedObservations = new Set();
|
|
459
|
+
for (const link of snapshot.links) {
|
|
460
|
+
const key = sourceObservationKey(link.sourceKey, link.observationKey);
|
|
461
|
+
if (!link.id || linkedObservations.has(key)) {
|
|
462
|
+
throw new Error(`Invalid or duplicate timetable link: ${link.sourceKey}/${link.observationKey}`);
|
|
463
|
+
}
|
|
464
|
+
const source = this.sources.get(link.sourceKey);
|
|
465
|
+
const observation = source?.observations.find((value) => value.key === link.observationKey);
|
|
466
|
+
if (!observation || observation.kind !== link.kind) {
|
|
467
|
+
throw new Error(`Dangling timetable link: ${link.sourceKey}/${link.observationKey}`);
|
|
468
|
+
}
|
|
469
|
+
linkedObservations.add(key);
|
|
470
|
+
this.links.set(key, { kind: link.kind, id: link.id });
|
|
471
|
+
}
|
|
472
|
+
for (const source of this.sources.values()) {
|
|
473
|
+
for (const observation of source.observations) {
|
|
474
|
+
const link = this.links.get(sourceObservationKey(source.sourceKey, observation.key));
|
|
475
|
+
if (!link) {
|
|
476
|
+
throw new Error(`Missing timetable link: ${source.sourceKey}/${observation.key}`);
|
|
477
|
+
}
|
|
478
|
+
this.addClaim(link, source, observation);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
validateSource(source) {
|
|
483
|
+
if (!source.sourceKey.trim())
|
|
484
|
+
throw new Error("Timetable source key is empty");
|
|
485
|
+
if (!Number.isInteger(source.academicYearStartYear)) {
|
|
486
|
+
throw new Error(`Invalid academic year for source ${source.sourceKey}`);
|
|
487
|
+
}
|
|
488
|
+
if (!Number.isInteger(source.period) || source.period < 1 || source.period > 4) {
|
|
489
|
+
throw new Error(`Invalid academic period for source ${source.sourceKey}`);
|
|
490
|
+
}
|
|
491
|
+
if (!(source.observedAt instanceof Date) || !Number.isFinite(source.observedAt.getTime())) {
|
|
492
|
+
throw new Error(`Invalid observation time for source ${source.sourceKey}`);
|
|
493
|
+
}
|
|
494
|
+
const validateRef = (value, label) => {
|
|
495
|
+
if (!value.name.trim())
|
|
496
|
+
throw new Error(`Empty ${label} in ${source.sourceKey}`);
|
|
497
|
+
if (value.id != null && (!Number.isInteger(value.id) || value.id < 1)) {
|
|
498
|
+
throw new Error(`Invalid ${label} ID in ${source.sourceKey}`);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const ownerRef = source.owner.type === "group"
|
|
502
|
+
? source.owner.group
|
|
503
|
+
: source.owner.type === "teacher"
|
|
504
|
+
? source.owner.teacher
|
|
505
|
+
: source.owner.room;
|
|
506
|
+
validateRef(ownerRef, `${source.owner.type} owner`);
|
|
507
|
+
const keys = new Set();
|
|
508
|
+
for (const observation of source.observations) {
|
|
509
|
+
if (!observation.key.trim() || keys.has(observation.key)) {
|
|
510
|
+
throw new Error(`Invalid or duplicate observation key in ${source.sourceKey}`);
|
|
511
|
+
}
|
|
512
|
+
if (!observation.subject.trim()) {
|
|
513
|
+
throw new Error(`Empty lesson subject in ${source.sourceKey}/${observation.key}`);
|
|
514
|
+
}
|
|
515
|
+
if (!observation.type.trim()) {
|
|
516
|
+
throw new Error(`Empty lesson type in ${source.sourceKey}/${observation.key}`);
|
|
517
|
+
}
|
|
518
|
+
const relations = [
|
|
519
|
+
["group", observation.groups, observation.groups.values.map((value) => value.group)],
|
|
520
|
+
["teacher", observation.teachers, observation.teachers.values],
|
|
521
|
+
["room", observation.rooms, observation.rooms.values],
|
|
522
|
+
];
|
|
523
|
+
for (const [label, relation, values] of relations) {
|
|
524
|
+
if (!["unknown", "partial", "complete"].includes(relation.completeness)) {
|
|
525
|
+
throw new Error(`Invalid ${label} completeness in ${source.sourceKey}/${observation.key}`);
|
|
526
|
+
}
|
|
527
|
+
if (relation.completeness === "unknown" && values.length > 0) {
|
|
528
|
+
throw new Error(`Unknown ${label} relation has values in ${source.sourceKey}/${observation.key}`);
|
|
529
|
+
}
|
|
530
|
+
for (const value of values)
|
|
531
|
+
validateRef(value, label);
|
|
532
|
+
}
|
|
533
|
+
if (observation.slotNumber != null &&
|
|
534
|
+
(!Number.isInteger(observation.slotNumber) || observation.slotNumber < 1)) {
|
|
535
|
+
throw new Error(`Invalid slot number in ${source.sourceKey}/${observation.key}`);
|
|
536
|
+
}
|
|
537
|
+
if (observation.time) {
|
|
538
|
+
const start = observation.time.start.hours * 60 + observation.time.start.minutes;
|
|
539
|
+
const end = observation.time.end.hours * 60 + observation.time.end.minutes;
|
|
540
|
+
const validPart = (value) => Number.isInteger(value.hours) && value.hours >= 0 && value.hours <= 23 &&
|
|
541
|
+
Number.isInteger(value.minutes) && value.minutes >= 0 && value.minutes <= 59;
|
|
542
|
+
if (!validPart(observation.time.start) || !validPart(observation.time.end) || end <= start) {
|
|
543
|
+
throw new Error(`Invalid lesson time in ${source.sourceKey}/${observation.key}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const substitution of observation.substitutions ?? []) {
|
|
547
|
+
if (!isLocalDate(substitution.date)) {
|
|
548
|
+
throw new Error(`Invalid substitution date in ${source.sourceKey}/${observation.key}`);
|
|
549
|
+
}
|
|
550
|
+
for (const value of substitution.rooms ?? [])
|
|
551
|
+
validateRef(value, "substitution room");
|
|
552
|
+
for (const value of substitution.teachers ?? [])
|
|
553
|
+
validateRef(value, "substitution teacher");
|
|
554
|
+
}
|
|
555
|
+
if (observation.kind === "series") {
|
|
556
|
+
const { weekday, weeks } = observation.recurrence;
|
|
557
|
+
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) {
|
|
558
|
+
throw new Error(`Invalid recurrence weekday in ${source.sourceKey}/${observation.key}`);
|
|
559
|
+
}
|
|
560
|
+
if (weeks && (!Number.isInteger(weeks.from) || !Number.isInteger(weeks.to) ||
|
|
561
|
+
weeks.from < 1 || weeks.to < weeks.from)) {
|
|
562
|
+
throw new Error(`Invalid recurrence weeks in ${source.sourceKey}/${observation.key}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
if (!isLocalDate(observation.date)) {
|
|
567
|
+
throw new Error(`Invalid lesson date in ${source.sourceKey}/${observation.key}`);
|
|
568
|
+
}
|
|
569
|
+
if (observation.transfer && (!isLocalDate(observation.transfer.fromDate) ||
|
|
570
|
+
!isLocalDate(observation.transfer.targetDate) ||
|
|
571
|
+
!Number.isInteger(observation.transfer.fromSlot) ||
|
|
572
|
+
observation.transfer.fromSlot < 1)) {
|
|
573
|
+
throw new Error(`Invalid lesson transfer in ${source.sourceKey}/${observation.key}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
keys.add(observation.key);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
rememberSourceEntities(source) {
|
|
580
|
+
const groups = [];
|
|
581
|
+
const teachers = [];
|
|
582
|
+
const rooms = [];
|
|
583
|
+
if (source.owner.type === "group")
|
|
584
|
+
groups.push(source.owner.group);
|
|
585
|
+
if (source.owner.type === "teacher")
|
|
586
|
+
teachers.push(source.owner.teacher);
|
|
587
|
+
if (source.owner.type === "room")
|
|
588
|
+
rooms.push(source.owner.room);
|
|
589
|
+
for (const observation of source.observations) {
|
|
590
|
+
groups.push(...observation.groups.values.map((value) => value.group));
|
|
591
|
+
teachers.push(...observation.teachers.values);
|
|
592
|
+
rooms.push(...observation.rooms.values);
|
|
593
|
+
for (const substitution of observation.substitutions ?? []) {
|
|
594
|
+
teachers.push(...(substitution.teachers ?? []));
|
|
595
|
+
rooms.push(...(substitution.rooms ?? []));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.directory.rememberGroups(groups);
|
|
599
|
+
this.directory.rememberTeachers(teachers);
|
|
600
|
+
this.directory.rememberRooms(rooms);
|
|
601
|
+
}
|
|
602
|
+
pruneEmptyRecords() {
|
|
603
|
+
for (const [id, record] of this.seriesRecords) {
|
|
604
|
+
if (record.claims.size === 0)
|
|
605
|
+
this.seriesRecords.delete(id);
|
|
606
|
+
}
|
|
607
|
+
for (const [id, record] of this.occurrenceRecords) {
|
|
608
|
+
if (record.claims.size === 0)
|
|
609
|
+
this.occurrenceRecords.delete(id);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
removeClaim(sourceKey, observationKey) {
|
|
613
|
+
const link = this.links.get(sourceObservationKey(sourceKey, observationKey));
|
|
614
|
+
if (!link)
|
|
615
|
+
return;
|
|
616
|
+
const record = link.kind === "series"
|
|
617
|
+
? this.seriesRecords.get(link.id)
|
|
618
|
+
: this.occurrenceRecords.get(link.id);
|
|
619
|
+
record?.claims.delete(claimKey(sourceKey, observationKey));
|
|
620
|
+
}
|
|
621
|
+
addClaim(link, source, observation) {
|
|
622
|
+
const key = claimKey(source.sourceKey, observation.key);
|
|
623
|
+
if (link.kind === "series" && observation.kind === "series") {
|
|
624
|
+
let record = this.seriesRecords.get(link.id);
|
|
625
|
+
if (!record) {
|
|
626
|
+
record = { id: link.id, claims: new Map() };
|
|
627
|
+
this.seriesRecords.set(link.id, record);
|
|
628
|
+
}
|
|
629
|
+
record.claims.set(key, { source, observation });
|
|
630
|
+
}
|
|
631
|
+
if (link.kind === "occurrence" && observation.kind === "occurrence") {
|
|
632
|
+
let record = this.occurrenceRecords.get(link.id);
|
|
633
|
+
if (!record) {
|
|
634
|
+
record = { id: link.id, claims: new Map() };
|
|
635
|
+
this.occurrenceRecords.set(link.id, record);
|
|
636
|
+
}
|
|
637
|
+
record.claims.set(key, { source, observation });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
findPriorTarget(observation, prior, usedTargets) {
|
|
641
|
+
let best;
|
|
642
|
+
let tied = false;
|
|
643
|
+
for (const claim of prior.values()) {
|
|
644
|
+
const link = this.links.get(sourceObservationKey(claim.source.sourceKey, claim.observation.key));
|
|
645
|
+
if (!link || link.kind !== observation.kind || usedTargets.has(link.id)) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const score = observationScore(observation, claim.observation, true, this.directory);
|
|
649
|
+
if (!best || score > best.score) {
|
|
650
|
+
best = { link, score };
|
|
651
|
+
tied = false;
|
|
652
|
+
}
|
|
653
|
+
else if (score === best.score) {
|
|
654
|
+
tied = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return best && best.score >= 55 && !tied ? best.link : undefined;
|
|
658
|
+
}
|
|
659
|
+
findCanonicalTarget(observation, source, usedTargets) {
|
|
660
|
+
const records = observation.kind === "series"
|
|
661
|
+
? this.seriesRecords.values()
|
|
662
|
+
: this.occurrenceRecords.values();
|
|
663
|
+
let best;
|
|
664
|
+
let second = Number.NEGATIVE_INFINITY;
|
|
665
|
+
for (const record of records) {
|
|
666
|
+
if (usedTargets.has(record.id) || record.claims.size === 0)
|
|
667
|
+
continue;
|
|
668
|
+
let recordScore = Number.NEGATIVE_INFINITY;
|
|
669
|
+
for (const claim of record.claims.values()) {
|
|
670
|
+
if (claim.source.academicYearStartYear !==
|
|
671
|
+
source.academicYearStartYear ||
|
|
672
|
+
claim.source.period !== source.period) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
recordScore = Math.max(recordScore, observationScore(observation, claim.observation, false, this.directory));
|
|
676
|
+
}
|
|
677
|
+
if (!best || recordScore > best.score) {
|
|
678
|
+
second = best?.score ?? Number.NEGATIVE_INFINITY;
|
|
679
|
+
best = { id: record.id, score: recordScore };
|
|
680
|
+
}
|
|
681
|
+
else if (recordScore > second) {
|
|
682
|
+
second = recordScore;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (!best || best.score < 95 || best.score - second < 10)
|
|
686
|
+
return undefined;
|
|
687
|
+
return { kind: observation.kind, id: best.id };
|
|
688
|
+
}
|
|
689
|
+
aggregateSubstitutions(claims) {
|
|
690
|
+
const byDate = new Map();
|
|
691
|
+
for (const substitution of claims.flatMap((value) => value.observation.substitutions ?? [])) {
|
|
692
|
+
const current = byDate.get(substitution.date);
|
|
693
|
+
const rooms = mergeRooms([...(current?.rooms ?? []), ...(substitution.rooms ?? [])].map((value) => this.directory.resolveRoom(value)));
|
|
694
|
+
const teachers = mergeTeachers([...(current?.teachers ?? []), ...(substitution.teachers ?? [])].map((value) => this.directory.resolveTeacher(value)));
|
|
695
|
+
byDate.set(substitution.date, {
|
|
696
|
+
date: substitution.date,
|
|
697
|
+
rooms: rooms.length > 0 ? rooms : undefined,
|
|
698
|
+
teachers: teachers.length > 0 ? teachers : undefined,
|
|
699
|
+
isDistance: current?.isDistance === true || substitution.isDistance === true
|
|
700
|
+
? true
|
|
701
|
+
: current?.isDistance ?? substitution.isDistance,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
705
|
+
}
|
|
706
|
+
aggregateSeries(record) {
|
|
707
|
+
const claims = deterministicClaims(record);
|
|
708
|
+
const preferred = preferredClaim(claims);
|
|
709
|
+
const rawRelations = aggregateRelations(claims);
|
|
710
|
+
const relations = {
|
|
711
|
+
groups: {
|
|
712
|
+
values: mergeGroups(rawRelations.groups.values.map((value) => ({
|
|
713
|
+
...value,
|
|
714
|
+
group: this.directory.resolveGroup(value.group),
|
|
715
|
+
}))),
|
|
716
|
+
completeness: rawRelations.groups.completeness,
|
|
717
|
+
},
|
|
718
|
+
teachers: {
|
|
719
|
+
values: mergeTeachers(rawRelations.teachers.values.map((value) => this.directory.resolveTeacher(value))),
|
|
720
|
+
completeness: rawRelations.teachers.completeness,
|
|
721
|
+
},
|
|
722
|
+
rooms: {
|
|
723
|
+
values: mergeRooms(rawRelations.rooms.values.map((value) => this.directory.resolveRoom(value))),
|
|
724
|
+
completeness: rawRelations.rooms.completeness,
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
return {
|
|
728
|
+
id: record.id,
|
|
729
|
+
academicYearStartYear: preferred.source.academicYearStartYear,
|
|
730
|
+
period: preferred.source.period,
|
|
731
|
+
subject: preferred.observation.subject,
|
|
732
|
+
type: preferred.observation.type,
|
|
733
|
+
slotNumber: consensusField(claims, (value) => value.slotNumber, String),
|
|
734
|
+
time: consensusField(claims, (value) => value.time, (value) => JSON.stringify(value)),
|
|
735
|
+
recurrence: preferred.observation.recurrence,
|
|
736
|
+
...relations,
|
|
737
|
+
isDistance: claims.some((value) => value.observation.isDistance === true),
|
|
738
|
+
possibleChanges: claims.some((value) => value.observation.possibleChanges === true),
|
|
739
|
+
substitutions: this.aggregateSubstitutions(claims),
|
|
740
|
+
sources: claims.map(sourceRef),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
aggregateOccurrence(record) {
|
|
744
|
+
const claims = deterministicClaims(record);
|
|
745
|
+
const preferred = preferredClaim(claims);
|
|
746
|
+
const rawRelations = aggregateRelations(claims);
|
|
747
|
+
const relations = {
|
|
748
|
+
groups: {
|
|
749
|
+
values: mergeGroups(rawRelations.groups.values.map((value) => ({
|
|
750
|
+
...value,
|
|
751
|
+
group: this.directory.resolveGroup(value.group),
|
|
752
|
+
}))),
|
|
753
|
+
completeness: rawRelations.groups.completeness,
|
|
754
|
+
},
|
|
755
|
+
teachers: {
|
|
756
|
+
values: mergeTeachers(rawRelations.teachers.values.map((value) => this.directory.resolveTeacher(value))),
|
|
757
|
+
completeness: rawRelations.teachers.completeness,
|
|
758
|
+
},
|
|
759
|
+
rooms: {
|
|
760
|
+
values: mergeRooms(rawRelations.rooms.values.map((value) => this.directory.resolveRoom(value))),
|
|
761
|
+
completeness: rawRelations.rooms.completeness,
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
const transfer = claims
|
|
765
|
+
.map((value) => value.observation.transfer)
|
|
766
|
+
.find((value) => value != null);
|
|
767
|
+
return {
|
|
768
|
+
id: record.id,
|
|
769
|
+
academicYearStartYear: preferred.source.academicYearStartYear,
|
|
770
|
+
period: preferred.source.period,
|
|
771
|
+
nominalDate: transfer?.fromDate ?? preferred.observation.date,
|
|
772
|
+
scheduledDate: transfer?.targetDate ?? preferred.observation.date,
|
|
773
|
+
subject: preferred.observation.subject,
|
|
774
|
+
type: preferred.observation.type,
|
|
775
|
+
slotNumber: consensusField(claims, (value) => value.slotNumber, String),
|
|
776
|
+
time: consensusField(claims, (value) => value.time, (value) => JSON.stringify(value)),
|
|
777
|
+
...relations,
|
|
778
|
+
isDistance: claims.some((value) => value.observation.isDistance === true),
|
|
779
|
+
possibleChanges: claims.some((value) => value.observation.possibleChanges === true),
|
|
780
|
+
status: transfer ? "moved" : "scheduled",
|
|
781
|
+
movedFrom: transfer
|
|
782
|
+
? { date: transfer.fromDate, slotNumber: transfer.fromSlot }
|
|
783
|
+
: undefined,
|
|
784
|
+
sources: claims.map(sourceRef),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
export class MemoryTimetableRepositoryAdapter {
|
|
789
|
+
snapshot = null;
|
|
790
|
+
async load() {
|
|
791
|
+
return this.snapshot == null
|
|
792
|
+
? null
|
|
793
|
+
: structuredClone(this.snapshot);
|
|
794
|
+
}
|
|
795
|
+
async compareAndSet(expectedRevision, snapshot) {
|
|
796
|
+
const currentRevision = this.snapshot?.revision ?? 0;
|
|
797
|
+
if (currentRevision !== expectedRevision)
|
|
798
|
+
return false;
|
|
799
|
+
this.snapshot = structuredClone(snapshot);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
async clear() {
|
|
803
|
+
this.snapshot = null;
|
|
804
|
+
}
|
|
805
|
+
}
|