chuvsu-js 5.1.0 → 5.1.2
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/dist/lk/client.js +1 -2
- package/dist/tt/client.js +15 -13
- package/dist/tt/domain/repository.d.ts +10 -0
- package/dist/tt/domain/repository.js +159 -11
- package/docs/v5-migration.md +6 -0
- package/package.json +1 -1
package/dist/lk/client.js
CHANGED
|
@@ -55,8 +55,7 @@ export class StudentPortalClient {
|
|
|
55
55
|
}
|
|
56
56
|
isBinarySessionExpired(response) {
|
|
57
57
|
const prefix = response.body.subarray(0, 8_192).toString("utf8");
|
|
58
|
-
return this.isSessionExpired(prefix)
|
|
59
|
-
response.contentType?.toLowerCase().includes("text/html") === true;
|
|
58
|
+
return this.isSessionExpired(prefix);
|
|
60
59
|
}
|
|
61
60
|
async authGetBuffer(url) {
|
|
62
61
|
let res = await this.http.getBufferResponse(url);
|
package/dist/tt/client.js
CHANGED
|
@@ -123,6 +123,8 @@ export class TimetableClient {
|
|
|
123
123
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
124
124
|
const expectedRevision = this._repository.revision;
|
|
125
125
|
const result = mutate(this._repository);
|
|
126
|
+
if (this._repository.revision === expectedRevision)
|
|
127
|
+
return result;
|
|
126
128
|
if (!this.repositoryAdapter)
|
|
127
129
|
return result;
|
|
128
130
|
if (await this.repositoryAdapter.compareAndSet(expectedRevision, this._repository.export())) {
|
|
@@ -191,8 +193,7 @@ export class TimetableClient {
|
|
|
191
193
|
}
|
|
192
194
|
isBinarySessionExpired(response) {
|
|
193
195
|
const prefix = response.body.subarray(0, 8_192).toString("utf8");
|
|
194
|
-
return this.isSessionExpired(prefix)
|
|
195
|
-
response.contentType?.toLowerCase().includes("text/html") === true;
|
|
196
|
+
return this.isSessionExpired(prefix);
|
|
196
197
|
}
|
|
197
198
|
async relogin() {
|
|
198
199
|
if (!this.loginMode)
|
|
@@ -364,17 +365,18 @@ export class TimetableClient {
|
|
|
364
365
|
period,
|
|
365
366
|
days: await this.fetchOwnerSchedule(resolvedOwner, period, context.academicYearStartYear),
|
|
366
367
|
})));
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
368
|
+
await this.mutateRepository((repository) => {
|
|
369
|
+
for (const result of results) {
|
|
370
|
+
const currentOwner = this.resolveOwner(resolvedOwner);
|
|
371
|
+
repository.ingest(createScheduleSourceSnapshot({
|
|
372
|
+
sourceKey: this.sourceKey(currentOwner, result.period, context.academicYearStartYear),
|
|
373
|
+
owner: currentOwner,
|
|
374
|
+
academicYearStartYear: context.academicYearStartYear,
|
|
375
|
+
period: result.period,
|
|
376
|
+
days: result.days,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
});
|
|
378
380
|
return new Schedule(this._repository, this.resolveOwner(resolvedOwner), context.academicYearStartYear, { period: periods.length === 1 ? periods[0] : context.period });
|
|
379
381
|
}
|
|
380
382
|
async getGroupSchedule(groupId, options) {
|
|
@@ -8,6 +8,8 @@ export declare class TimetableRepository {
|
|
|
8
8
|
private readonly links;
|
|
9
9
|
private readonly seriesRecords;
|
|
10
10
|
private readonly occurrenceRecords;
|
|
11
|
+
private readonly recordIdsByBucket;
|
|
12
|
+
private readonly recordIdsByOwner;
|
|
11
13
|
private _revision;
|
|
12
14
|
constructor(opts?: {
|
|
13
15
|
idGenerator?: LessonIdGenerator;
|
|
@@ -39,6 +41,14 @@ export declare class TimetableRepository {
|
|
|
39
41
|
private addClaim;
|
|
40
42
|
private findPriorTarget;
|
|
41
43
|
private findCanonicalTarget;
|
|
44
|
+
private addRecordToBucket;
|
|
45
|
+
private ownerReferenceKeys;
|
|
46
|
+
private claimOwnerKeys;
|
|
47
|
+
private addClaimOwnerKeys;
|
|
48
|
+
private removeRecordOwnerIndex;
|
|
49
|
+
private rebuildRecordOwnerIndex;
|
|
50
|
+
private removeRecordIndexes;
|
|
51
|
+
private recordsForOwner;
|
|
42
52
|
private aggregateSubstitutions;
|
|
43
53
|
private aggregateSeries;
|
|
44
54
|
private aggregateOccurrence;
|
|
@@ -8,6 +8,12 @@ function claimKey(sourceKey, observationKey) {
|
|
|
8
8
|
function sourceObservationKey(sourceKey, observationKey) {
|
|
9
9
|
return claimKey(sourceKey, observationKey);
|
|
10
10
|
}
|
|
11
|
+
function canonicalBucketKey(observation, source) {
|
|
12
|
+
const prefix = `${source.academicYearStartYear}:${source.period}:${observation.kind}:${normalizeScheduleText(observation.subject)}`;
|
|
13
|
+
return observation.kind === "series"
|
|
14
|
+
? `${prefix}:${observation.recurrence.weekday}:${recurrenceWeeks(observation)}`
|
|
15
|
+
: `${prefix}:${observation.date}`;
|
|
16
|
+
}
|
|
11
17
|
function sameDate(a, b) {
|
|
12
18
|
return a === b;
|
|
13
19
|
}
|
|
@@ -144,6 +150,13 @@ function deserializeSource(source) {
|
|
|
144
150
|
observations: source.observations.map(deserializeObservation),
|
|
145
151
|
};
|
|
146
152
|
}
|
|
153
|
+
function sameSourceContent(left, right) {
|
|
154
|
+
return left.sourceKey === right.sourceKey &&
|
|
155
|
+
left.academicYearStartYear === right.academicYearStartYear &&
|
|
156
|
+
left.period === right.period &&
|
|
157
|
+
JSON.stringify(left.owner) === JSON.stringify(right.owner) &&
|
|
158
|
+
JSON.stringify(left.observations) === JSON.stringify(right.observations);
|
|
159
|
+
}
|
|
147
160
|
function sourceRef(claim) {
|
|
148
161
|
return {
|
|
149
162
|
sourceKey: claim.source.sourceKey,
|
|
@@ -267,6 +280,8 @@ export class TimetableRepository {
|
|
|
267
280
|
links = new Map();
|
|
268
281
|
seriesRecords = new Map();
|
|
269
282
|
occurrenceRecords = new Map();
|
|
283
|
+
recordIdsByBucket = new Map();
|
|
284
|
+
recordIdsByOwner = new Map();
|
|
270
285
|
_revision = 0;
|
|
271
286
|
constructor(opts) {
|
|
272
287
|
this.idGenerator = opts?.idGenerator ?? new RandomLessonIdGenerator();
|
|
@@ -297,6 +312,17 @@ export class TimetableRepository {
|
|
|
297
312
|
this.validateSource(incoming);
|
|
298
313
|
this.rememberSourceEntities(incoming);
|
|
299
314
|
const prior = this.sources.get(incoming.sourceKey);
|
|
315
|
+
if (prior && sameSourceContent(prior, incoming)) {
|
|
316
|
+
const links = incoming.observations.map((observation) => this.links.get(sourceObservationKey(incoming.sourceKey, observation.key)));
|
|
317
|
+
return {
|
|
318
|
+
revision: this._revision,
|
|
319
|
+
seriesIds: links.flatMap((link) => link?.kind === "series" ? [link.id] : []),
|
|
320
|
+
lessonIds: links.flatMap((link) => link?.kind === "occurrence" ? [link.id] : []),
|
|
321
|
+
created: 0,
|
|
322
|
+
updated: incoming.observations.length,
|
|
323
|
+
removedObservations: 0,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
300
326
|
const priorClaims = new Map();
|
|
301
327
|
if (prior) {
|
|
302
328
|
for (const observation of prior.observations) {
|
|
@@ -365,7 +391,7 @@ export class TimetableRepository {
|
|
|
365
391
|
}
|
|
366
392
|
getSeries(opts) {
|
|
367
393
|
const values = [];
|
|
368
|
-
for (const record of this.seriesRecords
|
|
394
|
+
for (const record of this.recordsForOwner(this.seriesRecords, opts?.owner)) {
|
|
369
395
|
if (record.claims.size === 0)
|
|
370
396
|
continue;
|
|
371
397
|
const value = this.aggregateSeries(record);
|
|
@@ -387,7 +413,7 @@ export class TimetableRepository {
|
|
|
387
413
|
}
|
|
388
414
|
getDirectOccurrences(opts) {
|
|
389
415
|
const values = [];
|
|
390
|
-
for (const record of this.occurrenceRecords
|
|
416
|
+
for (const record of this.recordsForOwner(this.occurrenceRecords, opts?.owner)) {
|
|
391
417
|
if (record.claims.size === 0)
|
|
392
418
|
continue;
|
|
393
419
|
const value = this.aggregateOccurrence(record);
|
|
@@ -450,6 +476,8 @@ export class TimetableRepository {
|
|
|
450
476
|
this.links.clear();
|
|
451
477
|
this.seriesRecords.clear();
|
|
452
478
|
this.occurrenceRecords.clear();
|
|
479
|
+
this.recordIdsByBucket.clear();
|
|
480
|
+
this.recordIdsByOwner.clear();
|
|
453
481
|
this._directory = new TimetableDirectory(snapshot.directory);
|
|
454
482
|
this._revision = snapshot.revision;
|
|
455
483
|
for (const source of sources) {
|
|
@@ -601,12 +629,16 @@ export class TimetableRepository {
|
|
|
601
629
|
}
|
|
602
630
|
pruneEmptyRecords() {
|
|
603
631
|
for (const [id, record] of this.seriesRecords) {
|
|
604
|
-
if (record.claims.size === 0)
|
|
632
|
+
if (record.claims.size === 0) {
|
|
633
|
+
this.removeRecordIndexes(record);
|
|
605
634
|
this.seriesRecords.delete(id);
|
|
635
|
+
}
|
|
606
636
|
}
|
|
607
637
|
for (const [id, record] of this.occurrenceRecords) {
|
|
608
|
-
if (record.claims.size === 0)
|
|
638
|
+
if (record.claims.size === 0) {
|
|
639
|
+
this.removeRecordIndexes(record);
|
|
609
640
|
this.occurrenceRecords.delete(id);
|
|
641
|
+
}
|
|
610
642
|
}
|
|
611
643
|
}
|
|
612
644
|
removeClaim(sourceKey, observationKey) {
|
|
@@ -616,25 +648,39 @@ export class TimetableRepository {
|
|
|
616
648
|
const record = link.kind === "series"
|
|
617
649
|
? this.seriesRecords.get(link.id)
|
|
618
650
|
: this.occurrenceRecords.get(link.id);
|
|
619
|
-
record
|
|
651
|
+
if (!record)
|
|
652
|
+
return;
|
|
653
|
+
record.claims.delete(claimKey(sourceKey, observationKey));
|
|
654
|
+
this.rebuildRecordOwnerIndex(record);
|
|
620
655
|
}
|
|
621
656
|
addClaim(link, source, observation) {
|
|
622
657
|
const key = claimKey(source.sourceKey, observation.key);
|
|
658
|
+
const bucketKey = canonicalBucketKey(observation, source);
|
|
623
659
|
if (link.kind === "series" && observation.kind === "series") {
|
|
624
660
|
let record = this.seriesRecords.get(link.id);
|
|
625
661
|
if (!record) {
|
|
626
|
-
record = { id: link.id, claims: new Map() };
|
|
662
|
+
record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
|
|
627
663
|
this.seriesRecords.set(link.id, record);
|
|
664
|
+
this.addRecordToBucket(record);
|
|
665
|
+
}
|
|
666
|
+
if (record.bucketKey !== bucketKey) {
|
|
667
|
+
throw new Error(`Incompatible timetable series link: ${link.id}`);
|
|
628
668
|
}
|
|
629
669
|
record.claims.set(key, { source, observation });
|
|
670
|
+
this.addClaimOwnerKeys(record, source, observation);
|
|
630
671
|
}
|
|
631
672
|
if (link.kind === "occurrence" && observation.kind === "occurrence") {
|
|
632
673
|
let record = this.occurrenceRecords.get(link.id);
|
|
633
674
|
if (!record) {
|
|
634
|
-
record = { id: link.id, claims: new Map() };
|
|
675
|
+
record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
|
|
635
676
|
this.occurrenceRecords.set(link.id, record);
|
|
677
|
+
this.addRecordToBucket(record);
|
|
678
|
+
}
|
|
679
|
+
if (record.bucketKey !== bucketKey) {
|
|
680
|
+
throw new Error(`Incompatible timetable occurrence link: ${link.id}`);
|
|
636
681
|
}
|
|
637
682
|
record.claims.set(key, { source, observation });
|
|
683
|
+
this.addClaimOwnerKeys(record, source, observation);
|
|
638
684
|
}
|
|
639
685
|
}
|
|
640
686
|
findPriorTarget(observation, prior, usedTargets) {
|
|
@@ -657,12 +703,18 @@ export class TimetableRepository {
|
|
|
657
703
|
return best && best.score >= 55 && !tied ? best.link : undefined;
|
|
658
704
|
}
|
|
659
705
|
findCanonicalTarget(observation, source, usedTargets) {
|
|
660
|
-
const
|
|
661
|
-
? this.seriesRecords
|
|
662
|
-
: this.occurrenceRecords
|
|
706
|
+
const recordMap = observation.kind === "series"
|
|
707
|
+
? this.seriesRecords
|
|
708
|
+
: this.occurrenceRecords;
|
|
709
|
+
const candidateIds = this.recordIdsByBucket.get(canonicalBucketKey(observation, source));
|
|
710
|
+
if (!candidateIds)
|
|
711
|
+
return undefined;
|
|
663
712
|
let best;
|
|
664
713
|
let second = Number.NEGATIVE_INFINITY;
|
|
665
|
-
for (const
|
|
714
|
+
for (const id of candidateIds) {
|
|
715
|
+
const record = recordMap.get(id);
|
|
716
|
+
if (!record)
|
|
717
|
+
continue;
|
|
666
718
|
if (usedTargets.has(record.id) || record.claims.size === 0)
|
|
667
719
|
continue;
|
|
668
720
|
let recordScore = Number.NEGATIVE_INFINITY;
|
|
@@ -686,6 +738,102 @@ export class TimetableRepository {
|
|
|
686
738
|
return undefined;
|
|
687
739
|
return { kind: observation.kind, id: best.id };
|
|
688
740
|
}
|
|
741
|
+
addRecordToBucket(record) {
|
|
742
|
+
let ids = this.recordIdsByBucket.get(record.bucketKey);
|
|
743
|
+
if (!ids) {
|
|
744
|
+
ids = new Set();
|
|
745
|
+
this.recordIdsByBucket.set(record.bucketKey, ids);
|
|
746
|
+
}
|
|
747
|
+
ids.add(record.id);
|
|
748
|
+
}
|
|
749
|
+
ownerReferenceKeys(kind, value) {
|
|
750
|
+
const resolved = kind === "group"
|
|
751
|
+
? this.directory.resolveGroup(value)
|
|
752
|
+
: kind === "teacher"
|
|
753
|
+
? this.directory.resolveTeacher(value)
|
|
754
|
+
: this.directory.resolveRoom(value);
|
|
755
|
+
const keys = new Set();
|
|
756
|
+
for (const reference of [value, resolved]) {
|
|
757
|
+
if (reference.id != null)
|
|
758
|
+
keys.add(`${kind}:id:${reference.id}`);
|
|
759
|
+
const name = normalizeScheduleText(reference.name);
|
|
760
|
+
if (name)
|
|
761
|
+
keys.add(`${kind}:name:${name}`);
|
|
762
|
+
}
|
|
763
|
+
return [...keys];
|
|
764
|
+
}
|
|
765
|
+
claimOwnerKeys(source, observation) {
|
|
766
|
+
const keys = new Set();
|
|
767
|
+
const add = (kind, value) => {
|
|
768
|
+
for (const key of this.ownerReferenceKeys(kind, value))
|
|
769
|
+
keys.add(key);
|
|
770
|
+
};
|
|
771
|
+
if (source.owner.type === "group")
|
|
772
|
+
add("group", source.owner.group);
|
|
773
|
+
if (source.owner.type === "teacher")
|
|
774
|
+
add("teacher", source.owner.teacher);
|
|
775
|
+
if (source.owner.type === "room")
|
|
776
|
+
add("room", source.owner.room);
|
|
777
|
+
for (const value of observation.groups.values)
|
|
778
|
+
add("group", value.group);
|
|
779
|
+
for (const value of observation.teachers.values)
|
|
780
|
+
add("teacher", value);
|
|
781
|
+
for (const value of observation.rooms.values)
|
|
782
|
+
add("room", value);
|
|
783
|
+
return keys;
|
|
784
|
+
}
|
|
785
|
+
addClaimOwnerKeys(record, source, observation) {
|
|
786
|
+
for (const key of this.claimOwnerKeys(source, observation)) {
|
|
787
|
+
record.ownerKeys.add(key);
|
|
788
|
+
let ids = this.recordIdsByOwner.get(key);
|
|
789
|
+
if (!ids) {
|
|
790
|
+
ids = new Set();
|
|
791
|
+
this.recordIdsByOwner.set(key, ids);
|
|
792
|
+
}
|
|
793
|
+
ids.add(record.id);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
removeRecordOwnerIndex(record) {
|
|
797
|
+
for (const key of record.ownerKeys) {
|
|
798
|
+
const ids = this.recordIdsByOwner.get(key);
|
|
799
|
+
ids?.delete(record.id);
|
|
800
|
+
if (ids?.size === 0)
|
|
801
|
+
this.recordIdsByOwner.delete(key);
|
|
802
|
+
}
|
|
803
|
+
record.ownerKeys.clear();
|
|
804
|
+
}
|
|
805
|
+
rebuildRecordOwnerIndex(record) {
|
|
806
|
+
this.removeRecordOwnerIndex(record);
|
|
807
|
+
for (const claim of record.claims.values()) {
|
|
808
|
+
this.addClaimOwnerKeys(record, claim.source, claim.observation);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
removeRecordIndexes(record) {
|
|
812
|
+
const bucketIds = this.recordIdsByBucket.get(record.bucketKey);
|
|
813
|
+
bucketIds?.delete(record.id);
|
|
814
|
+
if (bucketIds?.size === 0)
|
|
815
|
+
this.recordIdsByBucket.delete(record.bucketKey);
|
|
816
|
+
this.removeRecordOwnerIndex(record);
|
|
817
|
+
}
|
|
818
|
+
recordsForOwner(records, owner) {
|
|
819
|
+
if (!owner)
|
|
820
|
+
return records.values();
|
|
821
|
+
const kind = owner.type;
|
|
822
|
+
const reference = owner.type === "group"
|
|
823
|
+
? owner.group
|
|
824
|
+
: owner.type === "teacher"
|
|
825
|
+
? owner.teacher
|
|
826
|
+
: owner.room;
|
|
827
|
+
const ids = new Set();
|
|
828
|
+
for (const key of this.ownerReferenceKeys(kind, reference)) {
|
|
829
|
+
for (const id of this.recordIdsByOwner.get(key) ?? [])
|
|
830
|
+
ids.add(id);
|
|
831
|
+
}
|
|
832
|
+
return [...ids].flatMap((id) => {
|
|
833
|
+
const record = records.get(id);
|
|
834
|
+
return record ? [record] : [];
|
|
835
|
+
});
|
|
836
|
+
}
|
|
689
837
|
aggregateSubstitutions(claims) {
|
|
690
838
|
const byDate = new Map();
|
|
691
839
|
for (const substitution of claims.flatMap((value) => value.observation.substitutions ?? [])) {
|
package/docs/v5-migration.md
CHANGED
|
@@ -281,6 +281,12 @@ const snapshot = await client.exportRepository();
|
|
|
281
281
|
вместе со всеми когда-либо загруженными расписаниями, а восстановление требует
|
|
282
282
|
повторной агрегации связей.
|
|
283
283
|
|
|
284
|
+
Начиная с 5.1.2 поиск совпадающих занятий и выборка расписания владельца используют
|
|
285
|
+
внутренние индексы, одинаковые повторные снимки не меняют ревизию, а периоды одного
|
|
286
|
+
запроса сохраняются одной транзакцией адаптера. Это исключает полный обход
|
|
287
|
+
репозитория из обычного пути запроса; `exportRepository()` по-прежнему имеет цену,
|
|
288
|
+
пропорциональную размеру полного снимка, и не должен вызываться на клиентском пути.
|
|
289
|
+
|
|
284
290
|
Для API, SSR и офлайн-кеша материализуйте только готовое расписание владельца:
|
|
285
291
|
|
|
286
292
|
```ts
|