@kainoa/5c-mcp 0.4.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 +33 -0
- package/dist/chunk-3PCAZ5XS.js +258 -0
- package/dist/chunk-B3TGUWJT.js +253 -0
- package/dist/chunk-R2DX67JM.js +257 -0
- package/dist/hyperschedule-32D52IZE-P6VRQDQR.js +21 -0
- package/dist/hyperschedule-OT6BLROL-BRSP2NPX.js +21 -0
- package/dist/hyperschedule-VZ6NIDDP-NJRKLKJI.js +21 -0
- package/dist/server.js +4968 -0
- package/package.json +50 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,4968 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
// ../core/dist/index.js
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
mkdir,
|
|
12
|
+
open,
|
|
13
|
+
readFile,
|
|
14
|
+
rename,
|
|
15
|
+
unlink,
|
|
16
|
+
writeFile
|
|
17
|
+
} from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
// ../../node_modules/.pnpm/minisearch@7.2.0/node_modules/minisearch/dist/es/index.js
|
|
23
|
+
var ENTRIES = "ENTRIES";
|
|
24
|
+
var KEYS = "KEYS";
|
|
25
|
+
var VALUES = "VALUES";
|
|
26
|
+
var LEAF = "";
|
|
27
|
+
var TreeIterator = class {
|
|
28
|
+
constructor(set, type) {
|
|
29
|
+
const node = set._tree;
|
|
30
|
+
const keys = Array.from(node.keys());
|
|
31
|
+
this.set = set;
|
|
32
|
+
this._type = type;
|
|
33
|
+
this._path = keys.length > 0 ? [{ node, keys }] : [];
|
|
34
|
+
}
|
|
35
|
+
next() {
|
|
36
|
+
const value = this.dive();
|
|
37
|
+
this.backtrack();
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
dive() {
|
|
41
|
+
if (this._path.length === 0) {
|
|
42
|
+
return { done: true, value: void 0 };
|
|
43
|
+
}
|
|
44
|
+
const { node, keys } = last$1(this._path);
|
|
45
|
+
if (last$1(keys) === LEAF) {
|
|
46
|
+
return { done: false, value: this.result() };
|
|
47
|
+
}
|
|
48
|
+
const child = node.get(last$1(keys));
|
|
49
|
+
this._path.push({ node: child, keys: Array.from(child.keys()) });
|
|
50
|
+
return this.dive();
|
|
51
|
+
}
|
|
52
|
+
backtrack() {
|
|
53
|
+
if (this._path.length === 0) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const keys = last$1(this._path).keys;
|
|
57
|
+
keys.pop();
|
|
58
|
+
if (keys.length > 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this._path.pop();
|
|
62
|
+
this.backtrack();
|
|
63
|
+
}
|
|
64
|
+
key() {
|
|
65
|
+
return this.set._prefix + this._path.map(({ keys }) => last$1(keys)).filter((key) => key !== LEAF).join("");
|
|
66
|
+
}
|
|
67
|
+
value() {
|
|
68
|
+
return last$1(this._path).node.get(LEAF);
|
|
69
|
+
}
|
|
70
|
+
result() {
|
|
71
|
+
switch (this._type) {
|
|
72
|
+
case VALUES:
|
|
73
|
+
return this.value();
|
|
74
|
+
case KEYS:
|
|
75
|
+
return this.key();
|
|
76
|
+
default:
|
|
77
|
+
return [this.key(), this.value()];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
[Symbol.iterator]() {
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var last$1 = (array) => {
|
|
85
|
+
return array[array.length - 1];
|
|
86
|
+
};
|
|
87
|
+
var fuzzySearch = (node, query, maxDistance) => {
|
|
88
|
+
const results = /* @__PURE__ */ new Map();
|
|
89
|
+
if (query === void 0)
|
|
90
|
+
return results;
|
|
91
|
+
const n = query.length + 1;
|
|
92
|
+
const m = n + maxDistance;
|
|
93
|
+
const matrix = new Uint8Array(m * n).fill(maxDistance + 1);
|
|
94
|
+
for (let j = 0; j < n; ++j)
|
|
95
|
+
matrix[j] = j;
|
|
96
|
+
for (let i = 1; i < m; ++i)
|
|
97
|
+
matrix[i * n] = i;
|
|
98
|
+
recurse(node, query, maxDistance, results, matrix, 1, n, "");
|
|
99
|
+
return results;
|
|
100
|
+
};
|
|
101
|
+
var recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
|
|
102
|
+
const offset = m * n;
|
|
103
|
+
key: for (const key of node.keys()) {
|
|
104
|
+
if (key === LEAF) {
|
|
105
|
+
const distance = matrix[offset - 1];
|
|
106
|
+
if (distance <= maxDistance) {
|
|
107
|
+
results.set(prefix, [node.get(key), distance]);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
let i = m;
|
|
111
|
+
for (let pos = 0; pos < key.length; ++pos, ++i) {
|
|
112
|
+
const char = key[pos];
|
|
113
|
+
const thisRowOffset = n * i;
|
|
114
|
+
const prevRowOffset = thisRowOffset - n;
|
|
115
|
+
let minDistance = matrix[thisRowOffset];
|
|
116
|
+
const jmin = Math.max(0, i - maxDistance - 1);
|
|
117
|
+
const jmax = Math.min(n - 1, i + maxDistance);
|
|
118
|
+
for (let j = jmin; j < jmax; ++j) {
|
|
119
|
+
const different = char !== query[j];
|
|
120
|
+
const rpl = matrix[prevRowOffset + j] + +different;
|
|
121
|
+
const del = matrix[prevRowOffset + j + 1] + 1;
|
|
122
|
+
const ins = matrix[thisRowOffset + j] + 1;
|
|
123
|
+
const dist = matrix[thisRowOffset + j + 1] = Math.min(rpl, del, ins);
|
|
124
|
+
if (dist < minDistance)
|
|
125
|
+
minDistance = dist;
|
|
126
|
+
}
|
|
127
|
+
if (minDistance > maxDistance) {
|
|
128
|
+
continue key;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
recurse(node.get(key), query, maxDistance, results, matrix, i, n, prefix + key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var SearchableMap = class _SearchableMap {
|
|
136
|
+
/**
|
|
137
|
+
* The constructor is normally called without arguments, creating an empty
|
|
138
|
+
* map. In order to create a {@link SearchableMap} from an iterable or from an
|
|
139
|
+
* object, check {@link SearchableMap.from} and {@link
|
|
140
|
+
* SearchableMap.fromObject}.
|
|
141
|
+
*
|
|
142
|
+
* The constructor arguments are for internal use, when creating derived
|
|
143
|
+
* mutable views of a map at a prefix.
|
|
144
|
+
*/
|
|
145
|
+
constructor(tree = /* @__PURE__ */ new Map(), prefix = "") {
|
|
146
|
+
this._size = void 0;
|
|
147
|
+
this._tree = tree;
|
|
148
|
+
this._prefix = prefix;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Creates and returns a mutable view of this {@link SearchableMap},
|
|
152
|
+
* containing only entries that share the given prefix.
|
|
153
|
+
*
|
|
154
|
+
* ### Usage:
|
|
155
|
+
*
|
|
156
|
+
* ```javascript
|
|
157
|
+
* let map = new SearchableMap()
|
|
158
|
+
* map.set("unicorn", 1)
|
|
159
|
+
* map.set("universe", 2)
|
|
160
|
+
* map.set("university", 3)
|
|
161
|
+
* map.set("unique", 4)
|
|
162
|
+
* map.set("hello", 5)
|
|
163
|
+
*
|
|
164
|
+
* let uni = map.atPrefix("uni")
|
|
165
|
+
* uni.get("unique") // => 4
|
|
166
|
+
* uni.get("unicorn") // => 1
|
|
167
|
+
* uni.get("hello") // => undefined
|
|
168
|
+
*
|
|
169
|
+
* let univer = map.atPrefix("univer")
|
|
170
|
+
* univer.get("unique") // => undefined
|
|
171
|
+
* univer.get("universe") // => 2
|
|
172
|
+
* univer.get("university") // => 3
|
|
173
|
+
* ```
|
|
174
|
+
*
|
|
175
|
+
* @param prefix The prefix
|
|
176
|
+
* @return A {@link SearchableMap} representing a mutable view of the original
|
|
177
|
+
* Map at the given prefix
|
|
178
|
+
*/
|
|
179
|
+
atPrefix(prefix) {
|
|
180
|
+
if (!prefix.startsWith(this._prefix)) {
|
|
181
|
+
throw new Error("Mismatched prefix");
|
|
182
|
+
}
|
|
183
|
+
const [node, path] = trackDown(this._tree, prefix.slice(this._prefix.length));
|
|
184
|
+
if (node === void 0) {
|
|
185
|
+
const [parentNode, key] = last(path);
|
|
186
|
+
for (const k of parentNode.keys()) {
|
|
187
|
+
if (k !== LEAF && k.startsWith(key)) {
|
|
188
|
+
const node2 = /* @__PURE__ */ new Map();
|
|
189
|
+
node2.set(k.slice(key.length), parentNode.get(k));
|
|
190
|
+
return new _SearchableMap(node2, prefix);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return new _SearchableMap(node, prefix);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear
|
|
198
|
+
*/
|
|
199
|
+
clear() {
|
|
200
|
+
this._size = void 0;
|
|
201
|
+
this._tree.clear();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete
|
|
205
|
+
* @param key Key to delete
|
|
206
|
+
*/
|
|
207
|
+
delete(key) {
|
|
208
|
+
this._size = void 0;
|
|
209
|
+
return remove(this._tree, key);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
|
|
213
|
+
* @return An iterator iterating through `[key, value]` entries.
|
|
214
|
+
*/
|
|
215
|
+
entries() {
|
|
216
|
+
return new TreeIterator(this, ENTRIES);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
|
|
220
|
+
* @param fn Iteration function
|
|
221
|
+
*/
|
|
222
|
+
forEach(fn) {
|
|
223
|
+
for (const [key, value] of this) {
|
|
224
|
+
fn(key, value, this);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Returns a Map of all the entries that have a key within the given edit
|
|
229
|
+
* distance from the search key. The keys of the returned Map are the matching
|
|
230
|
+
* keys, while the values are two-element arrays where the first element is
|
|
231
|
+
* the value associated to the key, and the second is the edit distance of the
|
|
232
|
+
* key to the search key.
|
|
233
|
+
*
|
|
234
|
+
* ### Usage:
|
|
235
|
+
*
|
|
236
|
+
* ```javascript
|
|
237
|
+
* let map = new SearchableMap()
|
|
238
|
+
* map.set('hello', 'world')
|
|
239
|
+
* map.set('hell', 'yeah')
|
|
240
|
+
* map.set('ciao', 'mondo')
|
|
241
|
+
*
|
|
242
|
+
* // Get all entries that match the key 'hallo' with a maximum edit distance of 2
|
|
243
|
+
* map.fuzzyGet('hallo', 2)
|
|
244
|
+
* // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }
|
|
245
|
+
*
|
|
246
|
+
* // In the example, the "hello" key has value "world" and edit distance of 1
|
|
247
|
+
* // (change "e" to "a"), the key "hell" has value "yeah" and edit distance of 2
|
|
248
|
+
* // (change "e" to "a", delete "o")
|
|
249
|
+
* ```
|
|
250
|
+
*
|
|
251
|
+
* @param key The search key
|
|
252
|
+
* @param maxEditDistance The maximum edit distance (Levenshtein)
|
|
253
|
+
* @return A Map of the matching keys to their value and edit distance
|
|
254
|
+
*/
|
|
255
|
+
fuzzyGet(key, maxEditDistance) {
|
|
256
|
+
return fuzzySearch(this._tree, key, maxEditDistance);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get
|
|
260
|
+
* @param key Key to get
|
|
261
|
+
* @return Value associated to the key, or `undefined` if the key is not
|
|
262
|
+
* found.
|
|
263
|
+
*/
|
|
264
|
+
get(key) {
|
|
265
|
+
const node = lookup(this._tree, key);
|
|
266
|
+
return node !== void 0 ? node.get(LEAF) : void 0;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has
|
|
270
|
+
* @param key Key
|
|
271
|
+
* @return True if the key is in the map, false otherwise
|
|
272
|
+
*/
|
|
273
|
+
has(key) {
|
|
274
|
+
const node = lookup(this._tree, key);
|
|
275
|
+
return node !== void 0 && node.has(LEAF);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys
|
|
279
|
+
* @return An `Iterable` iterating through keys
|
|
280
|
+
*/
|
|
281
|
+
keys() {
|
|
282
|
+
return new TreeIterator(this, KEYS);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set
|
|
286
|
+
* @param key Key to set
|
|
287
|
+
* @param value Value to associate to the key
|
|
288
|
+
* @return The {@link SearchableMap} itself, to allow chaining
|
|
289
|
+
*/
|
|
290
|
+
set(key, value) {
|
|
291
|
+
if (typeof key !== "string") {
|
|
292
|
+
throw new Error("key must be a string");
|
|
293
|
+
}
|
|
294
|
+
this._size = void 0;
|
|
295
|
+
const node = createPath(this._tree, key);
|
|
296
|
+
node.set(LEAF, value);
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size
|
|
301
|
+
*/
|
|
302
|
+
get size() {
|
|
303
|
+
if (this._size) {
|
|
304
|
+
return this._size;
|
|
305
|
+
}
|
|
306
|
+
this._size = 0;
|
|
307
|
+
const iter = this.entries();
|
|
308
|
+
while (!iter.next().done)
|
|
309
|
+
this._size += 1;
|
|
310
|
+
return this._size;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Updates the value at the given key using the provided function. The function
|
|
314
|
+
* is called with the current value at the key, and its return value is used as
|
|
315
|
+
* the new value to be set.
|
|
316
|
+
*
|
|
317
|
+
* ### Example:
|
|
318
|
+
*
|
|
319
|
+
* ```javascript
|
|
320
|
+
* // Increment the current value by one
|
|
321
|
+
* searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* If the value at the given key is or will be an object, it might not require
|
|
325
|
+
* re-assignment. In that case it is better to use `fetch()`, because it is
|
|
326
|
+
* faster.
|
|
327
|
+
*
|
|
328
|
+
* @param key The key to update
|
|
329
|
+
* @param fn The function used to compute the new value from the current one
|
|
330
|
+
* @return The {@link SearchableMap} itself, to allow chaining
|
|
331
|
+
*/
|
|
332
|
+
update(key, fn) {
|
|
333
|
+
if (typeof key !== "string") {
|
|
334
|
+
throw new Error("key must be a string");
|
|
335
|
+
}
|
|
336
|
+
this._size = void 0;
|
|
337
|
+
const node = createPath(this._tree, key);
|
|
338
|
+
node.set(LEAF, fn(node.get(LEAF)));
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Fetches the value of the given key. If the value does not exist, calls the
|
|
343
|
+
* given function to create a new value, which is inserted at the given key
|
|
344
|
+
* and subsequently returned.
|
|
345
|
+
*
|
|
346
|
+
* ### Example:
|
|
347
|
+
*
|
|
348
|
+
* ```javascript
|
|
349
|
+
* const map = searchableMap.fetch('somekey', () => new Map())
|
|
350
|
+
* map.set('foo', 'bar')
|
|
351
|
+
* ```
|
|
352
|
+
*
|
|
353
|
+
* @param key The key to update
|
|
354
|
+
* @param initial A function that creates a new value if the key does not exist
|
|
355
|
+
* @return The existing or new value at the given key
|
|
356
|
+
*/
|
|
357
|
+
fetch(key, initial) {
|
|
358
|
+
if (typeof key !== "string") {
|
|
359
|
+
throw new Error("key must be a string");
|
|
360
|
+
}
|
|
361
|
+
this._size = void 0;
|
|
362
|
+
const node = createPath(this._tree, key);
|
|
363
|
+
let value = node.get(LEAF);
|
|
364
|
+
if (value === void 0) {
|
|
365
|
+
node.set(LEAF, value = initial());
|
|
366
|
+
}
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values
|
|
371
|
+
* @return An `Iterable` iterating through values.
|
|
372
|
+
*/
|
|
373
|
+
values() {
|
|
374
|
+
return new TreeIterator(this, VALUES);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator
|
|
378
|
+
*/
|
|
379
|
+
[Symbol.iterator]() {
|
|
380
|
+
return this.entries();
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Creates a {@link SearchableMap} from an `Iterable` of entries
|
|
384
|
+
*
|
|
385
|
+
* @param entries Entries to be inserted in the {@link SearchableMap}
|
|
386
|
+
* @return A new {@link SearchableMap} with the given entries
|
|
387
|
+
*/
|
|
388
|
+
static from(entries) {
|
|
389
|
+
const tree = new _SearchableMap();
|
|
390
|
+
for (const [key, value] of entries) {
|
|
391
|
+
tree.set(key, value);
|
|
392
|
+
}
|
|
393
|
+
return tree;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Creates a {@link SearchableMap} from the iterable properties of a JavaScript object
|
|
397
|
+
*
|
|
398
|
+
* @param object Object of entries for the {@link SearchableMap}
|
|
399
|
+
* @return A new {@link SearchableMap} with the given entries
|
|
400
|
+
*/
|
|
401
|
+
static fromObject(object) {
|
|
402
|
+
return _SearchableMap.from(Object.entries(object));
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
var trackDown = (tree, key, path = []) => {
|
|
406
|
+
if (key.length === 0 || tree == null) {
|
|
407
|
+
return [tree, path];
|
|
408
|
+
}
|
|
409
|
+
for (const k of tree.keys()) {
|
|
410
|
+
if (k !== LEAF && key.startsWith(k)) {
|
|
411
|
+
path.push([tree, k]);
|
|
412
|
+
return trackDown(tree.get(k), key.slice(k.length), path);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
path.push([tree, key]);
|
|
416
|
+
return trackDown(void 0, "", path);
|
|
417
|
+
};
|
|
418
|
+
var lookup = (tree, key) => {
|
|
419
|
+
if (key.length === 0 || tree == null) {
|
|
420
|
+
return tree;
|
|
421
|
+
}
|
|
422
|
+
for (const k of tree.keys()) {
|
|
423
|
+
if (k !== LEAF && key.startsWith(k)) {
|
|
424
|
+
return lookup(tree.get(k), key.slice(k.length));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
var createPath = (node, key) => {
|
|
429
|
+
const keyLength = key.length;
|
|
430
|
+
outer: for (let pos = 0; node && pos < keyLength; ) {
|
|
431
|
+
for (const k of node.keys()) {
|
|
432
|
+
if (k !== LEAF && key[pos] === k[0]) {
|
|
433
|
+
const len = Math.min(keyLength - pos, k.length);
|
|
434
|
+
let offset = 1;
|
|
435
|
+
while (offset < len && key[pos + offset] === k[offset])
|
|
436
|
+
++offset;
|
|
437
|
+
const child2 = node.get(k);
|
|
438
|
+
if (offset === k.length) {
|
|
439
|
+
node = child2;
|
|
440
|
+
} else {
|
|
441
|
+
const intermediate = /* @__PURE__ */ new Map();
|
|
442
|
+
intermediate.set(k.slice(offset), child2);
|
|
443
|
+
node.set(key.slice(pos, pos + offset), intermediate);
|
|
444
|
+
node.delete(k);
|
|
445
|
+
node = intermediate;
|
|
446
|
+
}
|
|
447
|
+
pos += offset;
|
|
448
|
+
continue outer;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const child = /* @__PURE__ */ new Map();
|
|
452
|
+
node.set(key.slice(pos), child);
|
|
453
|
+
return child;
|
|
454
|
+
}
|
|
455
|
+
return node;
|
|
456
|
+
};
|
|
457
|
+
var remove = (tree, key) => {
|
|
458
|
+
const [node, path] = trackDown(tree, key);
|
|
459
|
+
if (node === void 0) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
node.delete(LEAF);
|
|
463
|
+
if (node.size === 0) {
|
|
464
|
+
cleanup(path);
|
|
465
|
+
} else if (node.size === 1) {
|
|
466
|
+
const [key2, value] = node.entries().next().value;
|
|
467
|
+
merge(path, key2, value);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
var cleanup = (path) => {
|
|
471
|
+
if (path.length === 0) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const [node, key] = last(path);
|
|
475
|
+
node.delete(key);
|
|
476
|
+
if (node.size === 0) {
|
|
477
|
+
cleanup(path.slice(0, -1));
|
|
478
|
+
} else if (node.size === 1) {
|
|
479
|
+
const [key2, value] = node.entries().next().value;
|
|
480
|
+
if (key2 !== LEAF) {
|
|
481
|
+
merge(path.slice(0, -1), key2, value);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
var merge = (path, key, value) => {
|
|
486
|
+
if (path.length === 0) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const [node, nodeKey] = last(path);
|
|
490
|
+
node.set(nodeKey + key, value);
|
|
491
|
+
node.delete(nodeKey);
|
|
492
|
+
};
|
|
493
|
+
var last = (array) => {
|
|
494
|
+
return array[array.length - 1];
|
|
495
|
+
};
|
|
496
|
+
var OR = "or";
|
|
497
|
+
var AND = "and";
|
|
498
|
+
var AND_NOT = "and_not";
|
|
499
|
+
var MiniSearch = class _MiniSearch {
|
|
500
|
+
/**
|
|
501
|
+
* @param options Configuration options
|
|
502
|
+
*
|
|
503
|
+
* ### Examples:
|
|
504
|
+
*
|
|
505
|
+
* ```javascript
|
|
506
|
+
* // Create a search engine that indexes the 'title' and 'text' fields of your
|
|
507
|
+
* // documents:
|
|
508
|
+
* const miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
509
|
+
* ```
|
|
510
|
+
*
|
|
511
|
+
* ### ID Field:
|
|
512
|
+
*
|
|
513
|
+
* ```javascript
|
|
514
|
+
* // Your documents are assumed to include a unique 'id' field, but if you want
|
|
515
|
+
* // to use a different field for document identification, you can set the
|
|
516
|
+
* // 'idField' option:
|
|
517
|
+
* const miniSearch = new MiniSearch({ idField: 'key', fields: ['title', 'text'] })
|
|
518
|
+
* ```
|
|
519
|
+
*
|
|
520
|
+
* ### Options and defaults:
|
|
521
|
+
*
|
|
522
|
+
* ```javascript
|
|
523
|
+
* // The full set of options (here with their default value) is:
|
|
524
|
+
* const miniSearch = new MiniSearch({
|
|
525
|
+
* // idField: field that uniquely identifies a document
|
|
526
|
+
* idField: 'id',
|
|
527
|
+
*
|
|
528
|
+
* // extractField: function used to get the value of a field in a document.
|
|
529
|
+
* // By default, it assumes the document is a flat object with field names as
|
|
530
|
+
* // property keys and field values as string property values, but custom logic
|
|
531
|
+
* // can be implemented by setting this option to a custom extractor function.
|
|
532
|
+
* extractField: (document, fieldName) => document[fieldName],
|
|
533
|
+
*
|
|
534
|
+
* // tokenize: function used to split fields into individual terms. By
|
|
535
|
+
* // default, it is also used to tokenize search queries, unless a specific
|
|
536
|
+
* // `tokenize` search option is supplied. When tokenizing an indexed field,
|
|
537
|
+
* // the field name is passed as the second argument.
|
|
538
|
+
* tokenize: (string, _fieldName) => string.split(SPACE_OR_PUNCTUATION),
|
|
539
|
+
*
|
|
540
|
+
* // processTerm: function used to process each tokenized term before
|
|
541
|
+
* // indexing. It can be used for stemming and normalization. Return a falsy
|
|
542
|
+
* // value in order to discard a term. By default, it is also used to process
|
|
543
|
+
* // search queries, unless a specific `processTerm` option is supplied as a
|
|
544
|
+
* // search option. When processing a term from a indexed field, the field
|
|
545
|
+
* // name is passed as the second argument.
|
|
546
|
+
* processTerm: (term, _fieldName) => term.toLowerCase(),
|
|
547
|
+
*
|
|
548
|
+
* // searchOptions: default search options, see the `search` method for
|
|
549
|
+
* // details
|
|
550
|
+
* searchOptions: undefined,
|
|
551
|
+
*
|
|
552
|
+
* // fields: document fields to be indexed. Mandatory, but not set by default
|
|
553
|
+
* fields: undefined
|
|
554
|
+
*
|
|
555
|
+
* // storeFields: document fields to be stored and returned as part of the
|
|
556
|
+
* // search results.
|
|
557
|
+
* storeFields: []
|
|
558
|
+
* })
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
constructor(options) {
|
|
562
|
+
if ((options === null || options === void 0 ? void 0 : options.fields) == null) {
|
|
563
|
+
throw new Error('MiniSearch: option "fields" must be provided');
|
|
564
|
+
}
|
|
565
|
+
const autoVacuum = options.autoVacuum == null || options.autoVacuum === true ? defaultAutoVacuumOptions : options.autoVacuum;
|
|
566
|
+
this._options = {
|
|
567
|
+
...defaultOptions,
|
|
568
|
+
...options,
|
|
569
|
+
autoVacuum,
|
|
570
|
+
searchOptions: { ...defaultSearchOptions, ...options.searchOptions || {} },
|
|
571
|
+
autoSuggestOptions: { ...defaultAutoSuggestOptions, ...options.autoSuggestOptions || {} }
|
|
572
|
+
};
|
|
573
|
+
this._index = new SearchableMap();
|
|
574
|
+
this._documentCount = 0;
|
|
575
|
+
this._documentIds = /* @__PURE__ */ new Map();
|
|
576
|
+
this._idToShortId = /* @__PURE__ */ new Map();
|
|
577
|
+
this._fieldIds = {};
|
|
578
|
+
this._fieldLength = /* @__PURE__ */ new Map();
|
|
579
|
+
this._avgFieldLength = [];
|
|
580
|
+
this._nextId = 0;
|
|
581
|
+
this._storedFields = /* @__PURE__ */ new Map();
|
|
582
|
+
this._dirtCount = 0;
|
|
583
|
+
this._currentVacuum = null;
|
|
584
|
+
this._enqueuedVacuum = null;
|
|
585
|
+
this._enqueuedVacuumConditions = defaultVacuumConditions;
|
|
586
|
+
this.addFields(this._options.fields);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Adds a document to the index
|
|
590
|
+
*
|
|
591
|
+
* @param document The document to be indexed
|
|
592
|
+
*/
|
|
593
|
+
add(document) {
|
|
594
|
+
const { extractField, stringifyField, tokenize, processTerm, fields, idField } = this._options;
|
|
595
|
+
const id = extractField(document, idField);
|
|
596
|
+
if (id == null) {
|
|
597
|
+
throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
|
|
598
|
+
}
|
|
599
|
+
if (this._idToShortId.has(id)) {
|
|
600
|
+
throw new Error(`MiniSearch: duplicate ID ${id}`);
|
|
601
|
+
}
|
|
602
|
+
const shortDocumentId = this.addDocumentId(id);
|
|
603
|
+
this.saveStoredFields(shortDocumentId, document);
|
|
604
|
+
for (const field of fields) {
|
|
605
|
+
const fieldValue = extractField(document, field);
|
|
606
|
+
if (fieldValue == null)
|
|
607
|
+
continue;
|
|
608
|
+
const tokens = tokenize(stringifyField(fieldValue, field), field);
|
|
609
|
+
const fieldId = this._fieldIds[field];
|
|
610
|
+
const uniqueTerms = new Set(tokens).size;
|
|
611
|
+
this.addFieldLength(shortDocumentId, fieldId, this._documentCount - 1, uniqueTerms);
|
|
612
|
+
for (const term of tokens) {
|
|
613
|
+
const processedTerm = processTerm(term, field);
|
|
614
|
+
if (Array.isArray(processedTerm)) {
|
|
615
|
+
for (const t of processedTerm) {
|
|
616
|
+
this.addTerm(fieldId, shortDocumentId, t);
|
|
617
|
+
}
|
|
618
|
+
} else if (processedTerm) {
|
|
619
|
+
this.addTerm(fieldId, shortDocumentId, processedTerm);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Adds all the given documents to the index
|
|
626
|
+
*
|
|
627
|
+
* @param documents An array of documents to be indexed
|
|
628
|
+
*/
|
|
629
|
+
addAll(documents) {
|
|
630
|
+
for (const document of documents)
|
|
631
|
+
this.add(document);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Adds all the given documents to the index asynchronously.
|
|
635
|
+
*
|
|
636
|
+
* Returns a promise that resolves (to `undefined`) when the indexing is done.
|
|
637
|
+
* This method is useful when index many documents, to avoid blocking the main
|
|
638
|
+
* thread. The indexing is performed asynchronously and in chunks.
|
|
639
|
+
*
|
|
640
|
+
* @param documents An array of documents to be indexed
|
|
641
|
+
* @param options Configuration options
|
|
642
|
+
* @return A promise resolving to `undefined` when the indexing is done
|
|
643
|
+
*/
|
|
644
|
+
addAllAsync(documents, options = {}) {
|
|
645
|
+
const { chunkSize = 10 } = options;
|
|
646
|
+
const acc = { chunk: [], promise: Promise.resolve() };
|
|
647
|
+
const { chunk, promise } = documents.reduce(({ chunk: chunk2, promise: promise2 }, document, i) => {
|
|
648
|
+
chunk2.push(document);
|
|
649
|
+
if ((i + 1) % chunkSize === 0) {
|
|
650
|
+
return {
|
|
651
|
+
chunk: [],
|
|
652
|
+
promise: promise2.then(() => new Promise((resolve) => setTimeout(resolve, 0))).then(() => this.addAll(chunk2))
|
|
653
|
+
};
|
|
654
|
+
} else {
|
|
655
|
+
return { chunk: chunk2, promise: promise2 };
|
|
656
|
+
}
|
|
657
|
+
}, acc);
|
|
658
|
+
return promise.then(() => this.addAll(chunk));
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Removes the given document from the index.
|
|
662
|
+
*
|
|
663
|
+
* The document to remove must NOT have changed between indexing and removal,
|
|
664
|
+
* otherwise the index will be corrupted.
|
|
665
|
+
*
|
|
666
|
+
* This method requires passing the full document to be removed (not just the
|
|
667
|
+
* ID), and immediately removes the document from the inverted index, allowing
|
|
668
|
+
* memory to be released. A convenient alternative is {@link
|
|
669
|
+
* MiniSearch#discard}, which needs only the document ID, and has the same
|
|
670
|
+
* visible effect, but delays cleaning up the index until the next vacuuming.
|
|
671
|
+
*
|
|
672
|
+
* @param document The document to be removed
|
|
673
|
+
*/
|
|
674
|
+
remove(document) {
|
|
675
|
+
const { tokenize, processTerm, extractField, stringifyField, fields, idField } = this._options;
|
|
676
|
+
const id = extractField(document, idField);
|
|
677
|
+
if (id == null) {
|
|
678
|
+
throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
|
|
679
|
+
}
|
|
680
|
+
const shortId = this._idToShortId.get(id);
|
|
681
|
+
if (shortId == null) {
|
|
682
|
+
throw new Error(`MiniSearch: cannot remove document with ID ${id}: it is not in the index`);
|
|
683
|
+
}
|
|
684
|
+
for (const field of fields) {
|
|
685
|
+
const fieldValue = extractField(document, field);
|
|
686
|
+
if (fieldValue == null)
|
|
687
|
+
continue;
|
|
688
|
+
const tokens = tokenize(stringifyField(fieldValue, field), field);
|
|
689
|
+
const fieldId = this._fieldIds[field];
|
|
690
|
+
const uniqueTerms = new Set(tokens).size;
|
|
691
|
+
this.removeFieldLength(shortId, fieldId, this._documentCount, uniqueTerms);
|
|
692
|
+
for (const term of tokens) {
|
|
693
|
+
const processedTerm = processTerm(term, field);
|
|
694
|
+
if (Array.isArray(processedTerm)) {
|
|
695
|
+
for (const t of processedTerm) {
|
|
696
|
+
this.removeTerm(fieldId, shortId, t);
|
|
697
|
+
}
|
|
698
|
+
} else if (processedTerm) {
|
|
699
|
+
this.removeTerm(fieldId, shortId, processedTerm);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
this._storedFields.delete(shortId);
|
|
704
|
+
this._documentIds.delete(shortId);
|
|
705
|
+
this._idToShortId.delete(id);
|
|
706
|
+
this._fieldLength.delete(shortId);
|
|
707
|
+
this._documentCount -= 1;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Removes all the given documents from the index. If called with no arguments,
|
|
711
|
+
* it removes _all_ documents from the index.
|
|
712
|
+
*
|
|
713
|
+
* @param documents The documents to be removed. If this argument is omitted,
|
|
714
|
+
* all documents are removed. Note that, for removing all documents, it is
|
|
715
|
+
* more efficient to call this method with no arguments than to pass all
|
|
716
|
+
* documents.
|
|
717
|
+
*/
|
|
718
|
+
removeAll(documents) {
|
|
719
|
+
if (documents) {
|
|
720
|
+
for (const document of documents)
|
|
721
|
+
this.remove(document);
|
|
722
|
+
} else if (arguments.length > 0) {
|
|
723
|
+
throw new Error("Expected documents to be present. Omit the argument to remove all documents.");
|
|
724
|
+
} else {
|
|
725
|
+
this._index = new SearchableMap();
|
|
726
|
+
this._documentCount = 0;
|
|
727
|
+
this._documentIds = /* @__PURE__ */ new Map();
|
|
728
|
+
this._idToShortId = /* @__PURE__ */ new Map();
|
|
729
|
+
this._fieldLength = /* @__PURE__ */ new Map();
|
|
730
|
+
this._avgFieldLength = [];
|
|
731
|
+
this._storedFields = /* @__PURE__ */ new Map();
|
|
732
|
+
this._nextId = 0;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Discards the document with the given ID, so it won't appear in search results
|
|
737
|
+
*
|
|
738
|
+
* It has the same visible effect of {@link MiniSearch.remove} (both cause the
|
|
739
|
+
* document to stop appearing in searches), but a different effect on the
|
|
740
|
+
* internal data structures:
|
|
741
|
+
*
|
|
742
|
+
* - {@link MiniSearch#remove} requires passing the full document to be
|
|
743
|
+
* removed as argument, and removes it from the inverted index immediately.
|
|
744
|
+
*
|
|
745
|
+
* - {@link MiniSearch#discard} instead only needs the document ID, and
|
|
746
|
+
* works by marking the current version of the document as discarded, so it
|
|
747
|
+
* is immediately ignored by searches. This is faster and more convenient
|
|
748
|
+
* than {@link MiniSearch#remove}, but the index is not immediately
|
|
749
|
+
* modified. To take care of that, vacuuming is performed after a certain
|
|
750
|
+
* number of documents are discarded, cleaning up the index and allowing
|
|
751
|
+
* memory to be released.
|
|
752
|
+
*
|
|
753
|
+
* After discarding a document, it is possible to re-add a new version, and
|
|
754
|
+
* only the new version will appear in searches. In other words, discarding
|
|
755
|
+
* and re-adding a document works exactly like removing and re-adding it. The
|
|
756
|
+
* {@link MiniSearch.replace} method can also be used to replace a document
|
|
757
|
+
* with a new version.
|
|
758
|
+
*
|
|
759
|
+
* #### Details about vacuuming
|
|
760
|
+
*
|
|
761
|
+
* Repetite calls to this method would leave obsolete document references in
|
|
762
|
+
* the index, invisible to searches. Two mechanisms take care of cleaning up:
|
|
763
|
+
* clean up during search, and vacuuming.
|
|
764
|
+
*
|
|
765
|
+
* - Upon search, whenever a discarded ID is found (and ignored for the
|
|
766
|
+
* results), references to the discarded document are removed from the
|
|
767
|
+
* inverted index entries for the search terms. This ensures that subsequent
|
|
768
|
+
* searches for the same terms do not need to skip these obsolete references
|
|
769
|
+
* again.
|
|
770
|
+
*
|
|
771
|
+
* - In addition, vacuuming is performed automatically by default (see the
|
|
772
|
+
* `autoVacuum` field in {@link Options}) after a certain number of
|
|
773
|
+
* documents are discarded. Vacuuming traverses all terms in the index,
|
|
774
|
+
* cleaning up all references to discarded documents. Vacuuming can also be
|
|
775
|
+
* triggered manually by calling {@link MiniSearch#vacuum}.
|
|
776
|
+
*
|
|
777
|
+
* @param id The ID of the document to be discarded
|
|
778
|
+
*/
|
|
779
|
+
discard(id) {
|
|
780
|
+
const shortId = this._idToShortId.get(id);
|
|
781
|
+
if (shortId == null) {
|
|
782
|
+
throw new Error(`MiniSearch: cannot discard document with ID ${id}: it is not in the index`);
|
|
783
|
+
}
|
|
784
|
+
this._idToShortId.delete(id);
|
|
785
|
+
this._documentIds.delete(shortId);
|
|
786
|
+
this._storedFields.delete(shortId);
|
|
787
|
+
(this._fieldLength.get(shortId) || []).forEach((fieldLength, fieldId) => {
|
|
788
|
+
this.removeFieldLength(shortId, fieldId, this._documentCount, fieldLength);
|
|
789
|
+
});
|
|
790
|
+
this._fieldLength.delete(shortId);
|
|
791
|
+
this._documentCount -= 1;
|
|
792
|
+
this._dirtCount += 1;
|
|
793
|
+
this.maybeAutoVacuum();
|
|
794
|
+
}
|
|
795
|
+
maybeAutoVacuum() {
|
|
796
|
+
if (this._options.autoVacuum === false) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const { minDirtFactor, minDirtCount, batchSize, batchWait } = this._options.autoVacuum;
|
|
800
|
+
this.conditionalVacuum({ batchSize, batchWait }, { minDirtCount, minDirtFactor });
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Discards the documents with the given IDs, so they won't appear in search
|
|
804
|
+
* results
|
|
805
|
+
*
|
|
806
|
+
* It is equivalent to calling {@link MiniSearch#discard} for all the given
|
|
807
|
+
* IDs, but with the optimization of triggering at most one automatic
|
|
808
|
+
* vacuuming at the end.
|
|
809
|
+
*
|
|
810
|
+
* Note: to remove all documents from the index, it is faster and more
|
|
811
|
+
* convenient to call {@link MiniSearch.removeAll} with no argument, instead
|
|
812
|
+
* of passing all IDs to this method.
|
|
813
|
+
*/
|
|
814
|
+
discardAll(ids) {
|
|
815
|
+
const autoVacuum = this._options.autoVacuum;
|
|
816
|
+
try {
|
|
817
|
+
this._options.autoVacuum = false;
|
|
818
|
+
for (const id of ids) {
|
|
819
|
+
this.discard(id);
|
|
820
|
+
}
|
|
821
|
+
} finally {
|
|
822
|
+
this._options.autoVacuum = autoVacuum;
|
|
823
|
+
}
|
|
824
|
+
this.maybeAutoVacuum();
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* It replaces an existing document with the given updated version
|
|
828
|
+
*
|
|
829
|
+
* It works by discarding the current version and adding the updated one, so
|
|
830
|
+
* it is functionally equivalent to calling {@link MiniSearch#discard}
|
|
831
|
+
* followed by {@link MiniSearch#add}. The ID of the updated document should
|
|
832
|
+
* be the same as the original one.
|
|
833
|
+
*
|
|
834
|
+
* Since it uses {@link MiniSearch#discard} internally, this method relies on
|
|
835
|
+
* vacuuming to clean up obsolete document references from the index, allowing
|
|
836
|
+
* memory to be released (see {@link MiniSearch#discard}).
|
|
837
|
+
*
|
|
838
|
+
* @param updatedDocument The updated document to replace the old version
|
|
839
|
+
* with
|
|
840
|
+
*/
|
|
841
|
+
replace(updatedDocument) {
|
|
842
|
+
const { idField, extractField } = this._options;
|
|
843
|
+
const id = extractField(updatedDocument, idField);
|
|
844
|
+
this.discard(id);
|
|
845
|
+
this.add(updatedDocument);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Triggers a manual vacuuming, cleaning up references to discarded documents
|
|
849
|
+
* from the inverted index
|
|
850
|
+
*
|
|
851
|
+
* Vacuuming is only useful for applications that use the {@link
|
|
852
|
+
* MiniSearch#discard} or {@link MiniSearch#replace} methods.
|
|
853
|
+
*
|
|
854
|
+
* By default, vacuuming is performed automatically when needed (controlled by
|
|
855
|
+
* the `autoVacuum` field in {@link Options}), so there is usually no need to
|
|
856
|
+
* call this method, unless one wants to make sure to perform vacuuming at a
|
|
857
|
+
* specific moment.
|
|
858
|
+
*
|
|
859
|
+
* Vacuuming traverses all terms in the inverted index in batches, and cleans
|
|
860
|
+
* up references to discarded documents from the posting list, allowing memory
|
|
861
|
+
* to be released.
|
|
862
|
+
*
|
|
863
|
+
* The method takes an optional object as argument with the following keys:
|
|
864
|
+
*
|
|
865
|
+
* - `batchSize`: the size of each batch (1000 by default)
|
|
866
|
+
*
|
|
867
|
+
* - `batchWait`: the number of milliseconds to wait between batches (10 by
|
|
868
|
+
* default)
|
|
869
|
+
*
|
|
870
|
+
* On large indexes, vacuuming could have a non-negligible cost: batching
|
|
871
|
+
* avoids blocking the thread for long, diluting this cost so that it is not
|
|
872
|
+
* negatively affecting the application. Nonetheless, this method should only
|
|
873
|
+
* be called when necessary, and relying on automatic vacuuming is usually
|
|
874
|
+
* better.
|
|
875
|
+
*
|
|
876
|
+
* It returns a promise that resolves (to undefined) when the clean up is
|
|
877
|
+
* completed. If vacuuming is already ongoing at the time this method is
|
|
878
|
+
* called, a new one is enqueued immediately after the ongoing one, and a
|
|
879
|
+
* corresponding promise is returned. However, no more than one vacuuming is
|
|
880
|
+
* enqueued on top of the ongoing one, even if this method is called more
|
|
881
|
+
* times (enqueuing multiple ones would be useless).
|
|
882
|
+
*
|
|
883
|
+
* @param options Configuration options for the batch size and delay. See
|
|
884
|
+
* {@link VacuumOptions}.
|
|
885
|
+
*/
|
|
886
|
+
vacuum(options = {}) {
|
|
887
|
+
return this.conditionalVacuum(options);
|
|
888
|
+
}
|
|
889
|
+
conditionalVacuum(options, conditions) {
|
|
890
|
+
if (this._currentVacuum) {
|
|
891
|
+
this._enqueuedVacuumConditions = this._enqueuedVacuumConditions && conditions;
|
|
892
|
+
if (this._enqueuedVacuum != null) {
|
|
893
|
+
return this._enqueuedVacuum;
|
|
894
|
+
}
|
|
895
|
+
this._enqueuedVacuum = this._currentVacuum.then(() => {
|
|
896
|
+
const conditions2 = this._enqueuedVacuumConditions;
|
|
897
|
+
this._enqueuedVacuumConditions = defaultVacuumConditions;
|
|
898
|
+
return this.performVacuuming(options, conditions2);
|
|
899
|
+
});
|
|
900
|
+
return this._enqueuedVacuum;
|
|
901
|
+
}
|
|
902
|
+
if (this.vacuumConditionsMet(conditions) === false) {
|
|
903
|
+
return Promise.resolve();
|
|
904
|
+
}
|
|
905
|
+
this._currentVacuum = this.performVacuuming(options);
|
|
906
|
+
return this._currentVacuum;
|
|
907
|
+
}
|
|
908
|
+
async performVacuuming(options, conditions) {
|
|
909
|
+
const initialDirtCount = this._dirtCount;
|
|
910
|
+
if (this.vacuumConditionsMet(conditions)) {
|
|
911
|
+
const batchSize = options.batchSize || defaultVacuumOptions.batchSize;
|
|
912
|
+
const batchWait = options.batchWait || defaultVacuumOptions.batchWait;
|
|
913
|
+
let i = 1;
|
|
914
|
+
for (const [term, fieldsData] of this._index) {
|
|
915
|
+
for (const [fieldId, fieldIndex] of fieldsData) {
|
|
916
|
+
for (const [shortId] of fieldIndex) {
|
|
917
|
+
if (this._documentIds.has(shortId)) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (fieldIndex.size <= 1) {
|
|
921
|
+
fieldsData.delete(fieldId);
|
|
922
|
+
} else {
|
|
923
|
+
fieldIndex.delete(shortId);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (this._index.get(term).size === 0) {
|
|
928
|
+
this._index.delete(term);
|
|
929
|
+
}
|
|
930
|
+
if (i % batchSize === 0) {
|
|
931
|
+
await new Promise((resolve) => setTimeout(resolve, batchWait));
|
|
932
|
+
}
|
|
933
|
+
i += 1;
|
|
934
|
+
}
|
|
935
|
+
this._dirtCount -= initialDirtCount;
|
|
936
|
+
}
|
|
937
|
+
await null;
|
|
938
|
+
this._currentVacuum = this._enqueuedVacuum;
|
|
939
|
+
this._enqueuedVacuum = null;
|
|
940
|
+
}
|
|
941
|
+
vacuumConditionsMet(conditions) {
|
|
942
|
+
if (conditions == null) {
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
let { minDirtCount, minDirtFactor } = conditions;
|
|
946
|
+
minDirtCount = minDirtCount || defaultAutoVacuumOptions.minDirtCount;
|
|
947
|
+
minDirtFactor = minDirtFactor || defaultAutoVacuumOptions.minDirtFactor;
|
|
948
|
+
return this.dirtCount >= minDirtCount && this.dirtFactor >= minDirtFactor;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Is `true` if a vacuuming operation is ongoing, `false` otherwise
|
|
952
|
+
*/
|
|
953
|
+
get isVacuuming() {
|
|
954
|
+
return this._currentVacuum != null;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The number of documents discarded since the most recent vacuuming
|
|
958
|
+
*/
|
|
959
|
+
get dirtCount() {
|
|
960
|
+
return this._dirtCount;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* A number between 0 and 1 giving an indication about the proportion of
|
|
964
|
+
* documents that are discarded, and can therefore be cleaned up by vacuuming.
|
|
965
|
+
* A value close to 0 means that the index is relatively clean, while a higher
|
|
966
|
+
* value means that the index is relatively dirty, and vacuuming could release
|
|
967
|
+
* memory.
|
|
968
|
+
*/
|
|
969
|
+
get dirtFactor() {
|
|
970
|
+
return this._dirtCount / (1 + this._documentCount + this._dirtCount);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Returns `true` if a document with the given ID is present in the index and
|
|
974
|
+
* available for search, `false` otherwise
|
|
975
|
+
*
|
|
976
|
+
* @param id The document ID
|
|
977
|
+
*/
|
|
978
|
+
has(id) {
|
|
979
|
+
return this._idToShortId.has(id);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Returns the stored fields (as configured in the `storeFields` constructor
|
|
983
|
+
* option) for the given document ID. Returns `undefined` if the document is
|
|
984
|
+
* not present in the index.
|
|
985
|
+
*
|
|
986
|
+
* @param id The document ID
|
|
987
|
+
*/
|
|
988
|
+
getStoredFields(id) {
|
|
989
|
+
const shortId = this._idToShortId.get(id);
|
|
990
|
+
if (shortId == null) {
|
|
991
|
+
return void 0;
|
|
992
|
+
}
|
|
993
|
+
return this._storedFields.get(shortId);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Search for documents matching the given search query.
|
|
997
|
+
*
|
|
998
|
+
* The result is a list of scored document IDs matching the query, sorted by
|
|
999
|
+
* descending score, and each including data about which terms were matched and
|
|
1000
|
+
* in which fields.
|
|
1001
|
+
*
|
|
1002
|
+
* ### Basic usage:
|
|
1003
|
+
*
|
|
1004
|
+
* ```javascript
|
|
1005
|
+
* // Search for "zen art motorcycle" with default options: terms have to match
|
|
1006
|
+
* // exactly, and individual terms are joined with OR
|
|
1007
|
+
* miniSearch.search('zen art motorcycle')
|
|
1008
|
+
* // => [ { id: 2, score: 2.77258, match: { ... } }, { id: 4, score: 1.38629, match: { ... } } ]
|
|
1009
|
+
* ```
|
|
1010
|
+
*
|
|
1011
|
+
* ### Restrict search to specific fields:
|
|
1012
|
+
*
|
|
1013
|
+
* ```javascript
|
|
1014
|
+
* // Search only in the 'title' field
|
|
1015
|
+
* miniSearch.search('zen', { fields: ['title'] })
|
|
1016
|
+
* ```
|
|
1017
|
+
*
|
|
1018
|
+
* ### Field boosting:
|
|
1019
|
+
*
|
|
1020
|
+
* ```javascript
|
|
1021
|
+
* // Boost a field
|
|
1022
|
+
* miniSearch.search('zen', { boost: { title: 2 } })
|
|
1023
|
+
* ```
|
|
1024
|
+
*
|
|
1025
|
+
* ### Prefix search:
|
|
1026
|
+
*
|
|
1027
|
+
* ```javascript
|
|
1028
|
+
* // Search for "moto" with prefix search (it will match documents
|
|
1029
|
+
* // containing terms that start with "moto" or "neuro")
|
|
1030
|
+
* miniSearch.search('moto neuro', { prefix: true })
|
|
1031
|
+
* ```
|
|
1032
|
+
*
|
|
1033
|
+
* ### Fuzzy search:
|
|
1034
|
+
*
|
|
1035
|
+
* ```javascript
|
|
1036
|
+
* // Search for "ismael" with fuzzy search (it will match documents containing
|
|
1037
|
+
* // terms similar to "ismael", with a maximum edit distance of 0.2 term.length
|
|
1038
|
+
* // (rounded to nearest integer)
|
|
1039
|
+
* miniSearch.search('ismael', { fuzzy: 0.2 })
|
|
1040
|
+
* ```
|
|
1041
|
+
*
|
|
1042
|
+
* ### Combining strategies:
|
|
1043
|
+
*
|
|
1044
|
+
* ```javascript
|
|
1045
|
+
* // Mix of exact match, prefix search, and fuzzy search
|
|
1046
|
+
* miniSearch.search('ismael mob', {
|
|
1047
|
+
* prefix: true,
|
|
1048
|
+
* fuzzy: 0.2
|
|
1049
|
+
* })
|
|
1050
|
+
* ```
|
|
1051
|
+
*
|
|
1052
|
+
* ### Advanced prefix and fuzzy search:
|
|
1053
|
+
*
|
|
1054
|
+
* ```javascript
|
|
1055
|
+
* // Perform fuzzy and prefix search depending on the search term. Here
|
|
1056
|
+
* // performing prefix and fuzzy search only on terms longer than 3 characters
|
|
1057
|
+
* miniSearch.search('ismael mob', {
|
|
1058
|
+
* prefix: term => term.length > 3
|
|
1059
|
+
* fuzzy: term => term.length > 3 ? 0.2 : null
|
|
1060
|
+
* })
|
|
1061
|
+
* ```
|
|
1062
|
+
*
|
|
1063
|
+
* ### Combine with AND:
|
|
1064
|
+
*
|
|
1065
|
+
* ```javascript
|
|
1066
|
+
* // Combine search terms with AND (to match only documents that contain both
|
|
1067
|
+
* // "motorcycle" and "art")
|
|
1068
|
+
* miniSearch.search('motorcycle art', { combineWith: 'AND' })
|
|
1069
|
+
* ```
|
|
1070
|
+
*
|
|
1071
|
+
* ### Combine with AND_NOT:
|
|
1072
|
+
*
|
|
1073
|
+
* There is also an AND_NOT combinator, that finds documents that match the
|
|
1074
|
+
* first term, but do not match any of the other terms. This combinator is
|
|
1075
|
+
* rarely useful with simple queries, and is meant to be used with advanced
|
|
1076
|
+
* query combinations (see later for more details).
|
|
1077
|
+
*
|
|
1078
|
+
* ### Filtering results:
|
|
1079
|
+
*
|
|
1080
|
+
* ```javascript
|
|
1081
|
+
* // Filter only results in the 'fiction' category (assuming that 'category'
|
|
1082
|
+
* // is a stored field)
|
|
1083
|
+
* miniSearch.search('motorcycle art', {
|
|
1084
|
+
* filter: (result) => result.category === 'fiction'
|
|
1085
|
+
* })
|
|
1086
|
+
* ```
|
|
1087
|
+
*
|
|
1088
|
+
* ### Wildcard query
|
|
1089
|
+
*
|
|
1090
|
+
* Searching for an empty string (assuming the default tokenizer) returns no
|
|
1091
|
+
* results. Sometimes though, one needs to match all documents, like in a
|
|
1092
|
+
* "wildcard" search. This is possible by passing the special value
|
|
1093
|
+
* {@link MiniSearch.wildcard} as the query:
|
|
1094
|
+
*
|
|
1095
|
+
* ```javascript
|
|
1096
|
+
* // Return search results for all documents
|
|
1097
|
+
* miniSearch.search(MiniSearch.wildcard)
|
|
1098
|
+
* ```
|
|
1099
|
+
*
|
|
1100
|
+
* Note that search options such as `filter` and `boostDocument` are still
|
|
1101
|
+
* applied, influencing which results are returned, and their order:
|
|
1102
|
+
*
|
|
1103
|
+
* ```javascript
|
|
1104
|
+
* // Return search results for all documents in the 'fiction' category
|
|
1105
|
+
* miniSearch.search(MiniSearch.wildcard, {
|
|
1106
|
+
* filter: (result) => result.category === 'fiction'
|
|
1107
|
+
* })
|
|
1108
|
+
* ```
|
|
1109
|
+
*
|
|
1110
|
+
* ### Advanced combination of queries:
|
|
1111
|
+
*
|
|
1112
|
+
* It is possible to combine different subqueries with OR, AND, and AND_NOT,
|
|
1113
|
+
* and even with different search options, by passing a query expression
|
|
1114
|
+
* tree object as the first argument, instead of a string.
|
|
1115
|
+
*
|
|
1116
|
+
* ```javascript
|
|
1117
|
+
* // Search for documents that contain "zen" and ("motorcycle" or "archery")
|
|
1118
|
+
* miniSearch.search({
|
|
1119
|
+
* combineWith: 'AND',
|
|
1120
|
+
* queries: [
|
|
1121
|
+
* 'zen',
|
|
1122
|
+
* {
|
|
1123
|
+
* combineWith: 'OR',
|
|
1124
|
+
* queries: ['motorcycle', 'archery']
|
|
1125
|
+
* }
|
|
1126
|
+
* ]
|
|
1127
|
+
* })
|
|
1128
|
+
*
|
|
1129
|
+
* // Search for documents that contain ("apple" or "pear") but not "juice" and
|
|
1130
|
+
* // not "tree"
|
|
1131
|
+
* miniSearch.search({
|
|
1132
|
+
* combineWith: 'AND_NOT',
|
|
1133
|
+
* queries: [
|
|
1134
|
+
* {
|
|
1135
|
+
* combineWith: 'OR',
|
|
1136
|
+
* queries: ['apple', 'pear']
|
|
1137
|
+
* },
|
|
1138
|
+
* 'juice',
|
|
1139
|
+
* 'tree'
|
|
1140
|
+
* ]
|
|
1141
|
+
* })
|
|
1142
|
+
* ```
|
|
1143
|
+
*
|
|
1144
|
+
* Each node in the expression tree can be either a string, or an object that
|
|
1145
|
+
* supports all {@link SearchOptions} fields, plus a `queries` array field for
|
|
1146
|
+
* subqueries.
|
|
1147
|
+
*
|
|
1148
|
+
* Note that, while this can become complicated to do by hand for complex or
|
|
1149
|
+
* deeply nested queries, it provides a formalized expression tree API for
|
|
1150
|
+
* external libraries that implement a parser for custom query languages.
|
|
1151
|
+
*
|
|
1152
|
+
* @param query Search query
|
|
1153
|
+
* @param searchOptions Search options. Each option, if not given, defaults to the corresponding value of `searchOptions` given to the constructor, or to the library default.
|
|
1154
|
+
*/
|
|
1155
|
+
search(query, searchOptions = {}) {
|
|
1156
|
+
const { searchOptions: globalSearchOptions } = this._options;
|
|
1157
|
+
const searchOptionsWithDefaults = { ...globalSearchOptions, ...searchOptions };
|
|
1158
|
+
const rawResults = this.executeQuery(query, searchOptions);
|
|
1159
|
+
const results = [];
|
|
1160
|
+
for (const [docId, { score, terms, match }] of rawResults) {
|
|
1161
|
+
const quality = terms.length || 1;
|
|
1162
|
+
const result = {
|
|
1163
|
+
id: this._documentIds.get(docId),
|
|
1164
|
+
score: score * quality,
|
|
1165
|
+
terms: Object.keys(match),
|
|
1166
|
+
queryTerms: terms,
|
|
1167
|
+
match
|
|
1168
|
+
};
|
|
1169
|
+
Object.assign(result, this._storedFields.get(docId));
|
|
1170
|
+
if (searchOptionsWithDefaults.filter == null || searchOptionsWithDefaults.filter(result)) {
|
|
1171
|
+
results.push(result);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (query === _MiniSearch.wildcard && searchOptionsWithDefaults.boostDocument == null) {
|
|
1175
|
+
return results;
|
|
1176
|
+
}
|
|
1177
|
+
results.sort(byScore);
|
|
1178
|
+
return results;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Provide suggestions for the given search query
|
|
1182
|
+
*
|
|
1183
|
+
* The result is a list of suggested modified search queries, derived from the
|
|
1184
|
+
* given search query, each with a relevance score, sorted by descending score.
|
|
1185
|
+
*
|
|
1186
|
+
* By default, it uses the same options used for search, except that by
|
|
1187
|
+
* default it performs prefix search on the last term of the query, and
|
|
1188
|
+
* combine terms with `'AND'` (requiring all query terms to match). Custom
|
|
1189
|
+
* options can be passed as a second argument. Defaults can be changed upon
|
|
1190
|
+
* calling the {@link MiniSearch} constructor, by passing a
|
|
1191
|
+
* `autoSuggestOptions` option.
|
|
1192
|
+
*
|
|
1193
|
+
* ### Basic usage:
|
|
1194
|
+
*
|
|
1195
|
+
* ```javascript
|
|
1196
|
+
* // Get suggestions for 'neuro':
|
|
1197
|
+
* miniSearch.autoSuggest('neuro')
|
|
1198
|
+
* // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 0.46240 } ]
|
|
1199
|
+
* ```
|
|
1200
|
+
*
|
|
1201
|
+
* ### Multiple words:
|
|
1202
|
+
*
|
|
1203
|
+
* ```javascript
|
|
1204
|
+
* // Get suggestions for 'zen ar':
|
|
1205
|
+
* miniSearch.autoSuggest('zen ar')
|
|
1206
|
+
* // => [
|
|
1207
|
+
* // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
|
|
1208
|
+
* // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
|
|
1209
|
+
* // ]
|
|
1210
|
+
* ```
|
|
1211
|
+
*
|
|
1212
|
+
* ### Fuzzy suggestions:
|
|
1213
|
+
*
|
|
1214
|
+
* ```javascript
|
|
1215
|
+
* // Correct spelling mistakes using fuzzy search:
|
|
1216
|
+
* miniSearch.autoSuggest('neromancer', { fuzzy: 0.2 })
|
|
1217
|
+
* // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 1.03998 } ]
|
|
1218
|
+
* ```
|
|
1219
|
+
*
|
|
1220
|
+
* ### Filtering:
|
|
1221
|
+
*
|
|
1222
|
+
* ```javascript
|
|
1223
|
+
* // Get suggestions for 'zen ar', but only within the 'fiction' category
|
|
1224
|
+
* // (assuming that 'category' is a stored field):
|
|
1225
|
+
* miniSearch.autoSuggest('zen ar', {
|
|
1226
|
+
* filter: (result) => result.category === 'fiction'
|
|
1227
|
+
* })
|
|
1228
|
+
* // => [
|
|
1229
|
+
* // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
|
|
1230
|
+
* // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
|
|
1231
|
+
* // ]
|
|
1232
|
+
* ```
|
|
1233
|
+
*
|
|
1234
|
+
* @param queryString Query string to be expanded into suggestions
|
|
1235
|
+
* @param options Search options. The supported options and default values
|
|
1236
|
+
* are the same as for the {@link MiniSearch#search} method, except that by
|
|
1237
|
+
* default prefix search is performed on the last term in the query, and terms
|
|
1238
|
+
* are combined with `'AND'`.
|
|
1239
|
+
* @return A sorted array of suggestions sorted by relevance score.
|
|
1240
|
+
*/
|
|
1241
|
+
autoSuggest(queryString, options = {}) {
|
|
1242
|
+
options = { ...this._options.autoSuggestOptions, ...options };
|
|
1243
|
+
const suggestions = /* @__PURE__ */ new Map();
|
|
1244
|
+
for (const { score, terms } of this.search(queryString, options)) {
|
|
1245
|
+
const phrase = terms.join(" ");
|
|
1246
|
+
const suggestion = suggestions.get(phrase);
|
|
1247
|
+
if (suggestion != null) {
|
|
1248
|
+
suggestion.score += score;
|
|
1249
|
+
suggestion.count += 1;
|
|
1250
|
+
} else {
|
|
1251
|
+
suggestions.set(phrase, { score, terms, count: 1 });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const results = [];
|
|
1255
|
+
for (const [suggestion, { score, terms, count }] of suggestions) {
|
|
1256
|
+
results.push({ suggestion, terms, score: score / count });
|
|
1257
|
+
}
|
|
1258
|
+
results.sort(byScore);
|
|
1259
|
+
return results;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Total number of documents available to search
|
|
1263
|
+
*/
|
|
1264
|
+
get documentCount() {
|
|
1265
|
+
return this._documentCount;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Number of terms in the index
|
|
1269
|
+
*/
|
|
1270
|
+
get termCount() {
|
|
1271
|
+
return this._index.size;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Deserializes a JSON index (serialized with `JSON.stringify(miniSearch)`)
|
|
1275
|
+
* and instantiates a MiniSearch instance. It should be given the same options
|
|
1276
|
+
* originally used when serializing the index.
|
|
1277
|
+
*
|
|
1278
|
+
* ### Usage:
|
|
1279
|
+
*
|
|
1280
|
+
* ```javascript
|
|
1281
|
+
* // If the index was serialized with:
|
|
1282
|
+
* let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
1283
|
+
* miniSearch.addAll(documents)
|
|
1284
|
+
*
|
|
1285
|
+
* const json = JSON.stringify(miniSearch)
|
|
1286
|
+
* // It can later be deserialized like this:
|
|
1287
|
+
* miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
|
|
1288
|
+
* ```
|
|
1289
|
+
*
|
|
1290
|
+
* @param json JSON-serialized index
|
|
1291
|
+
* @param options configuration options, same as the constructor
|
|
1292
|
+
* @return An instance of MiniSearch deserialized from the given JSON.
|
|
1293
|
+
*/
|
|
1294
|
+
static loadJSON(json, options) {
|
|
1295
|
+
if (options == null) {
|
|
1296
|
+
throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
|
|
1297
|
+
}
|
|
1298
|
+
return this.loadJS(JSON.parse(json), options);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Async equivalent of {@link MiniSearch.loadJSON}
|
|
1302
|
+
*
|
|
1303
|
+
* This function is an alternative to {@link MiniSearch.loadJSON} that returns
|
|
1304
|
+
* a promise, and loads the index in batches, leaving pauses between them to avoid
|
|
1305
|
+
* blocking the main thread. It tends to be slower than the synchronous
|
|
1306
|
+
* version, but does not block the main thread, so it can be a better choice
|
|
1307
|
+
* when deserializing very large indexes.
|
|
1308
|
+
*
|
|
1309
|
+
* @param json JSON-serialized index
|
|
1310
|
+
* @param options configuration options, same as the constructor
|
|
1311
|
+
* @return A Promise that will resolve to an instance of MiniSearch deserialized from the given JSON.
|
|
1312
|
+
*/
|
|
1313
|
+
static async loadJSONAsync(json, options) {
|
|
1314
|
+
if (options == null) {
|
|
1315
|
+
throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
|
|
1316
|
+
}
|
|
1317
|
+
return this.loadJSAsync(JSON.parse(json), options);
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Returns the default value of an option. It will throw an error if no option
|
|
1321
|
+
* with the given name exists.
|
|
1322
|
+
*
|
|
1323
|
+
* @param optionName Name of the option
|
|
1324
|
+
* @return The default value of the given option
|
|
1325
|
+
*
|
|
1326
|
+
* ### Usage:
|
|
1327
|
+
*
|
|
1328
|
+
* ```javascript
|
|
1329
|
+
* // Get default tokenizer
|
|
1330
|
+
* MiniSearch.getDefault('tokenize')
|
|
1331
|
+
*
|
|
1332
|
+
* // Get default term processor
|
|
1333
|
+
* MiniSearch.getDefault('processTerm')
|
|
1334
|
+
*
|
|
1335
|
+
* // Unknown options will throw an error
|
|
1336
|
+
* MiniSearch.getDefault('notExisting')
|
|
1337
|
+
* // => throws 'MiniSearch: unknown option "notExisting"'
|
|
1338
|
+
* ```
|
|
1339
|
+
*/
|
|
1340
|
+
static getDefault(optionName) {
|
|
1341
|
+
if (defaultOptions.hasOwnProperty(optionName)) {
|
|
1342
|
+
return getOwnProperty(defaultOptions, optionName);
|
|
1343
|
+
} else {
|
|
1344
|
+
throw new Error(`MiniSearch: unknown option "${optionName}"`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* @ignore
|
|
1349
|
+
*/
|
|
1350
|
+
static loadJS(js, options) {
|
|
1351
|
+
const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
|
|
1352
|
+
const miniSearch = this.instantiateMiniSearch(js, options);
|
|
1353
|
+
miniSearch._documentIds = objectToNumericMap(documentIds);
|
|
1354
|
+
miniSearch._fieldLength = objectToNumericMap(fieldLength);
|
|
1355
|
+
miniSearch._storedFields = objectToNumericMap(storedFields);
|
|
1356
|
+
for (const [shortId, id] of miniSearch._documentIds) {
|
|
1357
|
+
miniSearch._idToShortId.set(id, shortId);
|
|
1358
|
+
}
|
|
1359
|
+
for (const [term, data] of index) {
|
|
1360
|
+
const dataMap = /* @__PURE__ */ new Map();
|
|
1361
|
+
for (const fieldId of Object.keys(data)) {
|
|
1362
|
+
let indexEntry = data[fieldId];
|
|
1363
|
+
if (serializationVersion === 1) {
|
|
1364
|
+
indexEntry = indexEntry.ds;
|
|
1365
|
+
}
|
|
1366
|
+
dataMap.set(parseInt(fieldId, 10), objectToNumericMap(indexEntry));
|
|
1367
|
+
}
|
|
1368
|
+
miniSearch._index.set(term, dataMap);
|
|
1369
|
+
}
|
|
1370
|
+
return miniSearch;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* @ignore
|
|
1374
|
+
*/
|
|
1375
|
+
static async loadJSAsync(js, options) {
|
|
1376
|
+
const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
|
|
1377
|
+
const miniSearch = this.instantiateMiniSearch(js, options);
|
|
1378
|
+
miniSearch._documentIds = await objectToNumericMapAsync(documentIds);
|
|
1379
|
+
miniSearch._fieldLength = await objectToNumericMapAsync(fieldLength);
|
|
1380
|
+
miniSearch._storedFields = await objectToNumericMapAsync(storedFields);
|
|
1381
|
+
for (const [shortId, id] of miniSearch._documentIds) {
|
|
1382
|
+
miniSearch._idToShortId.set(id, shortId);
|
|
1383
|
+
}
|
|
1384
|
+
let count = 0;
|
|
1385
|
+
for (const [term, data] of index) {
|
|
1386
|
+
const dataMap = /* @__PURE__ */ new Map();
|
|
1387
|
+
for (const fieldId of Object.keys(data)) {
|
|
1388
|
+
let indexEntry = data[fieldId];
|
|
1389
|
+
if (serializationVersion === 1) {
|
|
1390
|
+
indexEntry = indexEntry.ds;
|
|
1391
|
+
}
|
|
1392
|
+
dataMap.set(parseInt(fieldId, 10), await objectToNumericMapAsync(indexEntry));
|
|
1393
|
+
}
|
|
1394
|
+
if (++count % 1e3 === 0)
|
|
1395
|
+
await wait(0);
|
|
1396
|
+
miniSearch._index.set(term, dataMap);
|
|
1397
|
+
}
|
|
1398
|
+
return miniSearch;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* @ignore
|
|
1402
|
+
*/
|
|
1403
|
+
static instantiateMiniSearch(js, options) {
|
|
1404
|
+
const { documentCount, nextId, fieldIds, averageFieldLength, dirtCount, serializationVersion } = js;
|
|
1405
|
+
if (serializationVersion !== 1 && serializationVersion !== 2) {
|
|
1406
|
+
throw new Error("MiniSearch: cannot deserialize an index created with an incompatible version");
|
|
1407
|
+
}
|
|
1408
|
+
const miniSearch = new _MiniSearch(options);
|
|
1409
|
+
miniSearch._documentCount = documentCount;
|
|
1410
|
+
miniSearch._nextId = nextId;
|
|
1411
|
+
miniSearch._idToShortId = /* @__PURE__ */ new Map();
|
|
1412
|
+
miniSearch._fieldIds = fieldIds;
|
|
1413
|
+
miniSearch._avgFieldLength = averageFieldLength;
|
|
1414
|
+
miniSearch._dirtCount = dirtCount || 0;
|
|
1415
|
+
miniSearch._index = new SearchableMap();
|
|
1416
|
+
return miniSearch;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* @ignore
|
|
1420
|
+
*/
|
|
1421
|
+
executeQuery(query, searchOptions = {}) {
|
|
1422
|
+
if (query === _MiniSearch.wildcard) {
|
|
1423
|
+
return this.executeWildcardQuery(searchOptions);
|
|
1424
|
+
}
|
|
1425
|
+
if (typeof query !== "string") {
|
|
1426
|
+
const options2 = { ...searchOptions, ...query, queries: void 0 };
|
|
1427
|
+
const results2 = query.queries.map((subquery) => this.executeQuery(subquery, options2));
|
|
1428
|
+
return this.combineResults(results2, options2.combineWith);
|
|
1429
|
+
}
|
|
1430
|
+
const { tokenize, processTerm, searchOptions: globalSearchOptions } = this._options;
|
|
1431
|
+
const options = { tokenize, processTerm, ...globalSearchOptions, ...searchOptions };
|
|
1432
|
+
const { tokenize: searchTokenize, processTerm: searchProcessTerm } = options;
|
|
1433
|
+
const terms = searchTokenize(query).flatMap((term) => searchProcessTerm(term)).filter((term) => !!term);
|
|
1434
|
+
const queries = terms.map(termToQuerySpec(options));
|
|
1435
|
+
const results = queries.map((query2) => this.executeQuerySpec(query2, options));
|
|
1436
|
+
return this.combineResults(results, options.combineWith);
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* @ignore
|
|
1440
|
+
*/
|
|
1441
|
+
executeQuerySpec(query, searchOptions) {
|
|
1442
|
+
const options = { ...this._options.searchOptions, ...searchOptions };
|
|
1443
|
+
const boosts = (options.fields || this._options.fields).reduce((boosts2, field) => ({ ...boosts2, [field]: getOwnProperty(options.boost, field) || 1 }), {});
|
|
1444
|
+
const { boostDocument, weights, maxFuzzy, bm25: bm25params } = options;
|
|
1445
|
+
const { fuzzy: fuzzyWeight, prefix: prefixWeight } = { ...defaultSearchOptions.weights, ...weights };
|
|
1446
|
+
const data = this._index.get(query.term);
|
|
1447
|
+
const results = this.termResults(query.term, query.term, 1, query.termBoost, data, boosts, boostDocument, bm25params);
|
|
1448
|
+
let prefixMatches;
|
|
1449
|
+
let fuzzyMatches;
|
|
1450
|
+
if (query.prefix) {
|
|
1451
|
+
prefixMatches = this._index.atPrefix(query.term);
|
|
1452
|
+
}
|
|
1453
|
+
if (query.fuzzy) {
|
|
1454
|
+
const fuzzy = query.fuzzy === true ? 0.2 : query.fuzzy;
|
|
1455
|
+
const maxDistance = fuzzy < 1 ? Math.min(maxFuzzy, Math.round(query.term.length * fuzzy)) : fuzzy;
|
|
1456
|
+
if (maxDistance)
|
|
1457
|
+
fuzzyMatches = this._index.fuzzyGet(query.term, maxDistance);
|
|
1458
|
+
}
|
|
1459
|
+
if (prefixMatches) {
|
|
1460
|
+
for (const [term, data2] of prefixMatches) {
|
|
1461
|
+
const distance = term.length - query.term.length;
|
|
1462
|
+
if (!distance) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
fuzzyMatches === null || fuzzyMatches === void 0 ? void 0 : fuzzyMatches.delete(term);
|
|
1466
|
+
const weight = prefixWeight * term.length / (term.length + 0.3 * distance);
|
|
1467
|
+
this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (fuzzyMatches) {
|
|
1471
|
+
for (const term of fuzzyMatches.keys()) {
|
|
1472
|
+
const [data2, distance] = fuzzyMatches.get(term);
|
|
1473
|
+
if (!distance) {
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
const weight = fuzzyWeight * term.length / (term.length + distance);
|
|
1477
|
+
this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return results;
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* @ignore
|
|
1484
|
+
*/
|
|
1485
|
+
executeWildcardQuery(searchOptions) {
|
|
1486
|
+
const results = /* @__PURE__ */ new Map();
|
|
1487
|
+
const options = { ...this._options.searchOptions, ...searchOptions };
|
|
1488
|
+
for (const [shortId, id] of this._documentIds) {
|
|
1489
|
+
const score = options.boostDocument ? options.boostDocument(id, "", this._storedFields.get(shortId)) : 1;
|
|
1490
|
+
results.set(shortId, {
|
|
1491
|
+
score,
|
|
1492
|
+
terms: [],
|
|
1493
|
+
match: {}
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
return results;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* @ignore
|
|
1500
|
+
*/
|
|
1501
|
+
combineResults(results, combineWith = OR) {
|
|
1502
|
+
if (results.length === 0) {
|
|
1503
|
+
return /* @__PURE__ */ new Map();
|
|
1504
|
+
}
|
|
1505
|
+
const operator = combineWith.toLowerCase();
|
|
1506
|
+
const combinator = combinators[operator];
|
|
1507
|
+
if (!combinator) {
|
|
1508
|
+
throw new Error(`Invalid combination operator: ${combineWith}`);
|
|
1509
|
+
}
|
|
1510
|
+
return results.reduce(combinator) || /* @__PURE__ */ new Map();
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Allows serialization of the index to JSON, to possibly store it and later
|
|
1514
|
+
* deserialize it with {@link MiniSearch.loadJSON}.
|
|
1515
|
+
*
|
|
1516
|
+
* Normally one does not directly call this method, but rather call the
|
|
1517
|
+
* standard JavaScript `JSON.stringify()` passing the {@link MiniSearch}
|
|
1518
|
+
* instance, and JavaScript will internally call this method. Upon
|
|
1519
|
+
* deserialization, one must pass to {@link MiniSearch.loadJSON} the same
|
|
1520
|
+
* options used to create the original instance that was serialized.
|
|
1521
|
+
*
|
|
1522
|
+
* ### Usage:
|
|
1523
|
+
*
|
|
1524
|
+
* ```javascript
|
|
1525
|
+
* // Serialize the index:
|
|
1526
|
+
* let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
1527
|
+
* miniSearch.addAll(documents)
|
|
1528
|
+
* const json = JSON.stringify(miniSearch)
|
|
1529
|
+
*
|
|
1530
|
+
* // Later, to deserialize it:
|
|
1531
|
+
* miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
|
|
1532
|
+
* ```
|
|
1533
|
+
*
|
|
1534
|
+
* @return A plain-object serializable representation of the search index.
|
|
1535
|
+
*/
|
|
1536
|
+
toJSON() {
|
|
1537
|
+
const index = [];
|
|
1538
|
+
for (const [term, fieldIndex] of this._index) {
|
|
1539
|
+
const data = {};
|
|
1540
|
+
for (const [fieldId, freqs] of fieldIndex) {
|
|
1541
|
+
data[fieldId] = Object.fromEntries(freqs);
|
|
1542
|
+
}
|
|
1543
|
+
index.push([term, data]);
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
documentCount: this._documentCount,
|
|
1547
|
+
nextId: this._nextId,
|
|
1548
|
+
documentIds: Object.fromEntries(this._documentIds),
|
|
1549
|
+
fieldIds: this._fieldIds,
|
|
1550
|
+
fieldLength: Object.fromEntries(this._fieldLength),
|
|
1551
|
+
averageFieldLength: this._avgFieldLength,
|
|
1552
|
+
storedFields: Object.fromEntries(this._storedFields),
|
|
1553
|
+
dirtCount: this._dirtCount,
|
|
1554
|
+
index,
|
|
1555
|
+
serializationVersion: 2
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* @ignore
|
|
1560
|
+
*/
|
|
1561
|
+
termResults(sourceTerm, derivedTerm, termWeight, termBoost, fieldTermData, fieldBoosts, boostDocumentFn, bm25params, results = /* @__PURE__ */ new Map()) {
|
|
1562
|
+
if (fieldTermData == null)
|
|
1563
|
+
return results;
|
|
1564
|
+
for (const field of Object.keys(fieldBoosts)) {
|
|
1565
|
+
const fieldBoost = fieldBoosts[field];
|
|
1566
|
+
const fieldId = this._fieldIds[field];
|
|
1567
|
+
const fieldTermFreqs = fieldTermData.get(fieldId);
|
|
1568
|
+
if (fieldTermFreqs == null)
|
|
1569
|
+
continue;
|
|
1570
|
+
let matchingFields = fieldTermFreqs.size;
|
|
1571
|
+
const avgFieldLength = this._avgFieldLength[fieldId];
|
|
1572
|
+
for (const docId of fieldTermFreqs.keys()) {
|
|
1573
|
+
if (!this._documentIds.has(docId)) {
|
|
1574
|
+
this.removeTerm(fieldId, docId, derivedTerm);
|
|
1575
|
+
matchingFields -= 1;
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
const docBoost = boostDocumentFn ? boostDocumentFn(this._documentIds.get(docId), derivedTerm, this._storedFields.get(docId)) : 1;
|
|
1579
|
+
if (!docBoost)
|
|
1580
|
+
continue;
|
|
1581
|
+
const termFreq = fieldTermFreqs.get(docId);
|
|
1582
|
+
const fieldLength = this._fieldLength.get(docId)[fieldId];
|
|
1583
|
+
const rawScore = calcBM25Score(termFreq, matchingFields, this._documentCount, fieldLength, avgFieldLength, bm25params);
|
|
1584
|
+
const weightedScore = termWeight * termBoost * fieldBoost * docBoost * rawScore;
|
|
1585
|
+
const result = results.get(docId);
|
|
1586
|
+
if (result) {
|
|
1587
|
+
result.score += weightedScore;
|
|
1588
|
+
assignUniqueTerm(result.terms, sourceTerm);
|
|
1589
|
+
const match = getOwnProperty(result.match, derivedTerm);
|
|
1590
|
+
if (match) {
|
|
1591
|
+
match.push(field);
|
|
1592
|
+
} else {
|
|
1593
|
+
result.match[derivedTerm] = [field];
|
|
1594
|
+
}
|
|
1595
|
+
} else {
|
|
1596
|
+
results.set(docId, {
|
|
1597
|
+
score: weightedScore,
|
|
1598
|
+
terms: [sourceTerm],
|
|
1599
|
+
match: { [derivedTerm]: [field] }
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return results;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* @ignore
|
|
1608
|
+
*/
|
|
1609
|
+
addTerm(fieldId, documentId, term) {
|
|
1610
|
+
const indexData = this._index.fetch(term, createMap);
|
|
1611
|
+
let fieldIndex = indexData.get(fieldId);
|
|
1612
|
+
if (fieldIndex == null) {
|
|
1613
|
+
fieldIndex = /* @__PURE__ */ new Map();
|
|
1614
|
+
fieldIndex.set(documentId, 1);
|
|
1615
|
+
indexData.set(fieldId, fieldIndex);
|
|
1616
|
+
} else {
|
|
1617
|
+
const docs = fieldIndex.get(documentId);
|
|
1618
|
+
fieldIndex.set(documentId, (docs || 0) + 1);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* @ignore
|
|
1623
|
+
*/
|
|
1624
|
+
removeTerm(fieldId, documentId, term) {
|
|
1625
|
+
if (!this._index.has(term)) {
|
|
1626
|
+
this.warnDocumentChanged(documentId, fieldId, term);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const indexData = this._index.fetch(term, createMap);
|
|
1630
|
+
const fieldIndex = indexData.get(fieldId);
|
|
1631
|
+
if (fieldIndex == null || fieldIndex.get(documentId) == null) {
|
|
1632
|
+
this.warnDocumentChanged(documentId, fieldId, term);
|
|
1633
|
+
} else if (fieldIndex.get(documentId) <= 1) {
|
|
1634
|
+
if (fieldIndex.size <= 1) {
|
|
1635
|
+
indexData.delete(fieldId);
|
|
1636
|
+
} else {
|
|
1637
|
+
fieldIndex.delete(documentId);
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
fieldIndex.set(documentId, fieldIndex.get(documentId) - 1);
|
|
1641
|
+
}
|
|
1642
|
+
if (this._index.get(term).size === 0) {
|
|
1643
|
+
this._index.delete(term);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* @ignore
|
|
1648
|
+
*/
|
|
1649
|
+
warnDocumentChanged(shortDocumentId, fieldId, term) {
|
|
1650
|
+
for (const fieldName of Object.keys(this._fieldIds)) {
|
|
1651
|
+
if (this._fieldIds[fieldName] === fieldId) {
|
|
1652
|
+
this._options.logger("warn", `MiniSearch: document with ID ${this._documentIds.get(shortDocumentId)} has changed before removal: term "${term}" was not present in field "${fieldName}". Removing a document after it has changed can corrupt the index!`, "version_conflict");
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* @ignore
|
|
1659
|
+
*/
|
|
1660
|
+
addDocumentId(documentId) {
|
|
1661
|
+
const shortDocumentId = this._nextId;
|
|
1662
|
+
this._idToShortId.set(documentId, shortDocumentId);
|
|
1663
|
+
this._documentIds.set(shortDocumentId, documentId);
|
|
1664
|
+
this._documentCount += 1;
|
|
1665
|
+
this._nextId += 1;
|
|
1666
|
+
return shortDocumentId;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* @ignore
|
|
1670
|
+
*/
|
|
1671
|
+
addFields(fields) {
|
|
1672
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1673
|
+
this._fieldIds[fields[i]] = i;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* @ignore
|
|
1678
|
+
*/
|
|
1679
|
+
addFieldLength(documentId, fieldId, count, length) {
|
|
1680
|
+
let fieldLengths = this._fieldLength.get(documentId);
|
|
1681
|
+
if (fieldLengths == null)
|
|
1682
|
+
this._fieldLength.set(documentId, fieldLengths = []);
|
|
1683
|
+
fieldLengths[fieldId] = length;
|
|
1684
|
+
const averageFieldLength = this._avgFieldLength[fieldId] || 0;
|
|
1685
|
+
const totalFieldLength = averageFieldLength * count + length;
|
|
1686
|
+
this._avgFieldLength[fieldId] = totalFieldLength / (count + 1);
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* @ignore
|
|
1690
|
+
*/
|
|
1691
|
+
removeFieldLength(documentId, fieldId, count, length) {
|
|
1692
|
+
if (count === 1) {
|
|
1693
|
+
this._avgFieldLength[fieldId] = 0;
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const totalFieldLength = this._avgFieldLength[fieldId] * count - length;
|
|
1697
|
+
this._avgFieldLength[fieldId] = totalFieldLength / (count - 1);
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* @ignore
|
|
1701
|
+
*/
|
|
1702
|
+
saveStoredFields(documentId, doc) {
|
|
1703
|
+
const { storeFields, extractField } = this._options;
|
|
1704
|
+
if (storeFields == null || storeFields.length === 0) {
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
let documentFields = this._storedFields.get(documentId);
|
|
1708
|
+
if (documentFields == null)
|
|
1709
|
+
this._storedFields.set(documentId, documentFields = {});
|
|
1710
|
+
for (const fieldName of storeFields) {
|
|
1711
|
+
const fieldValue = extractField(doc, fieldName);
|
|
1712
|
+
if (fieldValue !== void 0)
|
|
1713
|
+
documentFields[fieldName] = fieldValue;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
MiniSearch.wildcard = Symbol("*");
|
|
1718
|
+
var getOwnProperty = (object, property) => Object.prototype.hasOwnProperty.call(object, property) ? object[property] : void 0;
|
|
1719
|
+
var combinators = {
|
|
1720
|
+
[OR]: (a, b) => {
|
|
1721
|
+
for (const docId of b.keys()) {
|
|
1722
|
+
const existing = a.get(docId);
|
|
1723
|
+
if (existing == null) {
|
|
1724
|
+
a.set(docId, b.get(docId));
|
|
1725
|
+
} else {
|
|
1726
|
+
const { score, terms, match } = b.get(docId);
|
|
1727
|
+
existing.score = existing.score + score;
|
|
1728
|
+
existing.match = Object.assign(existing.match, match);
|
|
1729
|
+
assignUniqueTerms(existing.terms, terms);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return a;
|
|
1733
|
+
},
|
|
1734
|
+
[AND]: (a, b) => {
|
|
1735
|
+
const combined = /* @__PURE__ */ new Map();
|
|
1736
|
+
for (const docId of b.keys()) {
|
|
1737
|
+
const existing = a.get(docId);
|
|
1738
|
+
if (existing == null)
|
|
1739
|
+
continue;
|
|
1740
|
+
const { score, terms, match } = b.get(docId);
|
|
1741
|
+
assignUniqueTerms(existing.terms, terms);
|
|
1742
|
+
combined.set(docId, {
|
|
1743
|
+
score: existing.score + score,
|
|
1744
|
+
terms: existing.terms,
|
|
1745
|
+
match: Object.assign(existing.match, match)
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
return combined;
|
|
1749
|
+
},
|
|
1750
|
+
[AND_NOT]: (a, b) => {
|
|
1751
|
+
for (const docId of b.keys())
|
|
1752
|
+
a.delete(docId);
|
|
1753
|
+
return a;
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
var defaultBM25params = { k: 1.2, b: 0.7, d: 0.5 };
|
|
1757
|
+
var calcBM25Score = (termFreq, matchingCount, totalCount, fieldLength, avgFieldLength, bm25params) => {
|
|
1758
|
+
const { k, b, d } = bm25params;
|
|
1759
|
+
const invDocFreq = Math.log(1 + (totalCount - matchingCount + 0.5) / (matchingCount + 0.5));
|
|
1760
|
+
return invDocFreq * (d + termFreq * (k + 1) / (termFreq + k * (1 - b + b * fieldLength / avgFieldLength)));
|
|
1761
|
+
};
|
|
1762
|
+
var termToQuerySpec = (options) => (term, i, terms) => {
|
|
1763
|
+
const fuzzy = typeof options.fuzzy === "function" ? options.fuzzy(term, i, terms) : options.fuzzy || false;
|
|
1764
|
+
const prefix = typeof options.prefix === "function" ? options.prefix(term, i, terms) : options.prefix === true;
|
|
1765
|
+
const termBoost = typeof options.boostTerm === "function" ? options.boostTerm(term, i, terms) : 1;
|
|
1766
|
+
return { term, fuzzy, prefix, termBoost };
|
|
1767
|
+
};
|
|
1768
|
+
var defaultOptions = {
|
|
1769
|
+
idField: "id",
|
|
1770
|
+
extractField: (document, fieldName) => document[fieldName],
|
|
1771
|
+
stringifyField: (fieldValue, fieldName) => fieldValue.toString(),
|
|
1772
|
+
tokenize: (text) => text.split(SPACE_OR_PUNCTUATION),
|
|
1773
|
+
processTerm: (term) => term.toLowerCase(),
|
|
1774
|
+
fields: void 0,
|
|
1775
|
+
searchOptions: void 0,
|
|
1776
|
+
storeFields: [],
|
|
1777
|
+
logger: (level, message) => {
|
|
1778
|
+
if (typeof (console === null || console === void 0 ? void 0 : console[level]) === "function")
|
|
1779
|
+
console[level](message);
|
|
1780
|
+
},
|
|
1781
|
+
autoVacuum: true
|
|
1782
|
+
};
|
|
1783
|
+
var defaultSearchOptions = {
|
|
1784
|
+
combineWith: OR,
|
|
1785
|
+
prefix: false,
|
|
1786
|
+
fuzzy: false,
|
|
1787
|
+
maxFuzzy: 6,
|
|
1788
|
+
boost: {},
|
|
1789
|
+
weights: { fuzzy: 0.45, prefix: 0.375 },
|
|
1790
|
+
bm25: defaultBM25params
|
|
1791
|
+
};
|
|
1792
|
+
var defaultAutoSuggestOptions = {
|
|
1793
|
+
combineWith: AND,
|
|
1794
|
+
prefix: (term, i, terms) => i === terms.length - 1
|
|
1795
|
+
};
|
|
1796
|
+
var defaultVacuumOptions = { batchSize: 1e3, batchWait: 10 };
|
|
1797
|
+
var defaultVacuumConditions = { minDirtFactor: 0.1, minDirtCount: 20 };
|
|
1798
|
+
var defaultAutoVacuumOptions = { ...defaultVacuumOptions, ...defaultVacuumConditions };
|
|
1799
|
+
var assignUniqueTerm = (target, term) => {
|
|
1800
|
+
if (!target.includes(term))
|
|
1801
|
+
target.push(term);
|
|
1802
|
+
};
|
|
1803
|
+
var assignUniqueTerms = (target, source) => {
|
|
1804
|
+
for (const term of source) {
|
|
1805
|
+
if (!target.includes(term))
|
|
1806
|
+
target.push(term);
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
var byScore = ({ score: a }, { score: b }) => b - a;
|
|
1810
|
+
var createMap = () => /* @__PURE__ */ new Map();
|
|
1811
|
+
var objectToNumericMap = (object) => {
|
|
1812
|
+
const map = /* @__PURE__ */ new Map();
|
|
1813
|
+
for (const key of Object.keys(object)) {
|
|
1814
|
+
map.set(parseInt(key, 10), object[key]);
|
|
1815
|
+
}
|
|
1816
|
+
return map;
|
|
1817
|
+
};
|
|
1818
|
+
var objectToNumericMapAsync = async (object) => {
|
|
1819
|
+
const map = /* @__PURE__ */ new Map();
|
|
1820
|
+
let count = 0;
|
|
1821
|
+
for (const key of Object.keys(object)) {
|
|
1822
|
+
map.set(parseInt(key, 10), object[key]);
|
|
1823
|
+
if (++count % 1e3 === 0) {
|
|
1824
|
+
await wait(0);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return map;
|
|
1828
|
+
};
|
|
1829
|
+
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1830
|
+
var SPACE_OR_PUNCTUATION = /[\n\r\p{Z}\p{P}]+/u;
|
|
1831
|
+
|
|
1832
|
+
// ../core/dist/index.js
|
|
1833
|
+
var rawDateSchema = z.object({
|
|
1834
|
+
year: z.number(),
|
|
1835
|
+
month: z.number().min(1).max(12),
|
|
1836
|
+
day: z.number().min(1).max(31)
|
|
1837
|
+
});
|
|
1838
|
+
var rawCourseCodeSchema = z.object({
|
|
1839
|
+
department: z.string(),
|
|
1840
|
+
courseNumber: z.number(),
|
|
1841
|
+
suffix: z.string(),
|
|
1842
|
+
affiliation: z.string()
|
|
1843
|
+
});
|
|
1844
|
+
var rawScheduleSchema = z.object({
|
|
1845
|
+
startTime: z.number(),
|
|
1846
|
+
// seconds since midnight
|
|
1847
|
+
endTime: z.number(),
|
|
1848
|
+
days: z.array(z.string()),
|
|
1849
|
+
locations: z.array(z.string())
|
|
1850
|
+
});
|
|
1851
|
+
var rawSectionSchema = z.object({
|
|
1852
|
+
courseAreas: z.array(z.string()),
|
|
1853
|
+
credits: z.number(),
|
|
1854
|
+
permCount: z.number().default(0),
|
|
1855
|
+
seatsTotal: z.number(),
|
|
1856
|
+
seatsFilled: z.number(),
|
|
1857
|
+
status: z.string(),
|
|
1858
|
+
// O | C | R | U (keep raw; map to labels downstream)
|
|
1859
|
+
startDate: rawDateSchema,
|
|
1860
|
+
endDate: rawDateSchema,
|
|
1861
|
+
instructors: z.array(z.object({ name: z.string() })),
|
|
1862
|
+
course: z.object({
|
|
1863
|
+
code: rawCourseCodeSchema,
|
|
1864
|
+
title: z.string(),
|
|
1865
|
+
description: z.string(),
|
|
1866
|
+
primaryAssociation: z.string(),
|
|
1867
|
+
potentialError: z.boolean().default(false)
|
|
1868
|
+
}),
|
|
1869
|
+
schedules: z.array(rawScheduleSchema),
|
|
1870
|
+
potentialError: z.boolean().default(false),
|
|
1871
|
+
identifier: z.object({
|
|
1872
|
+
department: z.string(),
|
|
1873
|
+
courseNumber: z.number(),
|
|
1874
|
+
suffix: z.string(),
|
|
1875
|
+
affiliation: z.string(),
|
|
1876
|
+
sectionNumber: z.number(),
|
|
1877
|
+
year: z.number(),
|
|
1878
|
+
term: z.string(),
|
|
1879
|
+
// FA | SP | SU
|
|
1880
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() }))
|
|
1881
|
+
})
|
|
1882
|
+
});
|
|
1883
|
+
var rawSectionsSchema = z.array(rawSectionSchema);
|
|
1884
|
+
var rawHistoryEntrySchema = z.object({
|
|
1885
|
+
code: rawCourseCodeSchema,
|
|
1886
|
+
terms: z.array(z.object({ year: z.number(), term: z.string() }))
|
|
1887
|
+
});
|
|
1888
|
+
var rawHistorySchema = z.array(rawHistoryEntrySchema);
|
|
1889
|
+
var rawTermSchema = z.object({ year: z.number(), term: z.string() });
|
|
1890
|
+
var rawCourseAreaSchema = z.object({
|
|
1891
|
+
area: z.string(),
|
|
1892
|
+
description: z.string()
|
|
1893
|
+
});
|
|
1894
|
+
var termSchema = z.string().regex(/^(FA|SP|SU)\d{4}$/, "Term must look like FA2026");
|
|
1895
|
+
var daySchema = z.enum(["M", "T", "W", "R", "F", "S", "U"]);
|
|
1896
|
+
var statusSchema = z.enum(["O", "C", "R", "U"]);
|
|
1897
|
+
var meetingSchema = z.object({
|
|
1898
|
+
days: z.array(daySchema),
|
|
1899
|
+
startMin: z.number(),
|
|
1900
|
+
// minutes since midnight (math)
|
|
1901
|
+
endMin: z.number(),
|
|
1902
|
+
startStr: z.string(),
|
|
1903
|
+
// "13:15" (quote)
|
|
1904
|
+
endStr: z.string(),
|
|
1905
|
+
start12: z.string(),
|
|
1906
|
+
// "1:15 PM" (display)
|
|
1907
|
+
end12: z.string(),
|
|
1908
|
+
locations: z.array(z.string())
|
|
1909
|
+
});
|
|
1910
|
+
var sectionSchema = z.object({
|
|
1911
|
+
id: z.string(),
|
|
1912
|
+
// "CSCI 051 PO-01 FA2026" (canonical, frozen v1)
|
|
1913
|
+
term: termSchema,
|
|
1914
|
+
year: z.number(),
|
|
1915
|
+
termCode: z.enum(["FA", "SP", "SU"]),
|
|
1916
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() })),
|
|
1917
|
+
dept: z.string(),
|
|
1918
|
+
number: z.number(),
|
|
1919
|
+
suffix: z.string(),
|
|
1920
|
+
school: z.string(),
|
|
1921
|
+
// affiliation, uppercase (PO/CM/HM/SC/PZ + KS/JP/…)
|
|
1922
|
+
sectionNo: z.number(),
|
|
1923
|
+
code: z.string(),
|
|
1924
|
+
// "CSCI 051" (dept + zero-padded + suffix, no school)
|
|
1925
|
+
title: z.string(),
|
|
1926
|
+
description: z.string(),
|
|
1927
|
+
areas: z.array(z.string()),
|
|
1928
|
+
instructors: z.array(z.string()),
|
|
1929
|
+
meetings: z.array(meetingSchema),
|
|
1930
|
+
credits: z.number(),
|
|
1931
|
+
status: statusSchema,
|
|
1932
|
+
statusLabel: z.string(),
|
|
1933
|
+
// Open | Closed | Reopened | Unknown
|
|
1934
|
+
seats: z.object({
|
|
1935
|
+
filled: z.number(),
|
|
1936
|
+
total: z.number(),
|
|
1937
|
+
available: z.number(),
|
|
1938
|
+
permCount: z.number()
|
|
1939
|
+
}),
|
|
1940
|
+
startDate: z.string(),
|
|
1941
|
+
// ISO
|
|
1942
|
+
endDate: z.string(),
|
|
1943
|
+
potentialError: z.boolean(),
|
|
1944
|
+
primaryAssociation: z.string(),
|
|
1945
|
+
affiliation: z.string()
|
|
1946
|
+
});
|
|
1947
|
+
var cacheMetaSchema = z.object({
|
|
1948
|
+
term: z.string(),
|
|
1949
|
+
fetched_at: z.string(),
|
|
1950
|
+
etag: z.string().nullable(),
|
|
1951
|
+
hash: z.string(),
|
|
1952
|
+
count: z.number(),
|
|
1953
|
+
source_url: z.string(),
|
|
1954
|
+
ttl_kind: z.string(),
|
|
1955
|
+
stale: z.boolean(),
|
|
1956
|
+
source: z.enum(["live", "stale-cache", "offline-file"]),
|
|
1957
|
+
quarantined: z.number().int().nonnegative().default(0),
|
|
1958
|
+
// Sections dropped because a later row produced the same canonical ID
|
|
1959
|
+
// (e.g. two half-semester rows collapsing onto one ID). Surfaced, never silent.
|
|
1960
|
+
duplicate_ids: z.number().int().nonnegative().optional()
|
|
1961
|
+
});
|
|
1962
|
+
var DEFAULT_API_BASE = "https://banana.hyperschedule.io/v4";
|
|
1963
|
+
var USER_AGENT = "5C-Index/0.1 (+research/edu; cache 60s)";
|
|
1964
|
+
function apiBase() {
|
|
1965
|
+
const override = process.env.FIVE_C_API_BASE?.trim();
|
|
1966
|
+
return override ? override.replace(/\/+$/, "") : DEFAULT_API_BASE;
|
|
1967
|
+
}
|
|
1968
|
+
async function sleep(ms) {
|
|
1969
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1970
|
+
}
|
|
1971
|
+
async function fetchJson(url, validate, opts = {}, fetchFn = globalThis.fetch) {
|
|
1972
|
+
const { etag, timeoutMs = 15e3, retries = 3 } = opts;
|
|
1973
|
+
let lastErr = null;
|
|
1974
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1975
|
+
const ctrl = new AbortController();
|
|
1976
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
1977
|
+
try {
|
|
1978
|
+
const res = await fetchFn(url, {
|
|
1979
|
+
signal: ctrl.signal,
|
|
1980
|
+
headers: {
|
|
1981
|
+
Accept: "application/json",
|
|
1982
|
+
"User-Agent": USER_AGENT,
|
|
1983
|
+
...etag ? { "If-None-Match": etag } : {}
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
if (res.status === 304) {
|
|
1987
|
+
clearTimeout(timer);
|
|
1988
|
+
return { data: void 0, etag: etag ?? null, notModified: true };
|
|
1989
|
+
}
|
|
1990
|
+
if (res.status === 404) {
|
|
1991
|
+
const err = new Error(`Not found: ${url}`);
|
|
1992
|
+
err.code = "E_NOT_FOUND_UPSTREAM";
|
|
1993
|
+
throw err;
|
|
1994
|
+
}
|
|
1995
|
+
if (res.status === 429 || res.status >= 500) {
|
|
1996
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
1997
|
+
const wait2 = retryAfter ? Math.min(Number.parseInt(retryAfter, 10) * 1e3 || 2e3, 15e3) : Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3);
|
|
1998
|
+
lastErr = new Error(`Upstream ${res.status} for ${url}`);
|
|
1999
|
+
if (attempt < retries) {
|
|
2000
|
+
clearTimeout(timer);
|
|
2001
|
+
await sleep(wait2);
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
throw lastErr;
|
|
2005
|
+
}
|
|
2006
|
+
if (!res.ok) {
|
|
2007
|
+
const err = new Error(`Upstream ${res.status} for ${url}`);
|
|
2008
|
+
err.code = "E_FETCH";
|
|
2009
|
+
throw err;
|
|
2010
|
+
}
|
|
2011
|
+
const json = await res.json();
|
|
2012
|
+
clearTimeout(timer);
|
|
2013
|
+
return {
|
|
2014
|
+
data: validate(json),
|
|
2015
|
+
etag: res.headers.get("ETag"),
|
|
2016
|
+
notModified: false
|
|
2017
|
+
};
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
clearTimeout(timer);
|
|
2020
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") throw err;
|
|
2021
|
+
if (err.name === "AbortError") {
|
|
2022
|
+
lastErr = new Error(`Timeout fetching ${url}`);
|
|
2023
|
+
} else {
|
|
2024
|
+
lastErr = err;
|
|
2025
|
+
}
|
|
2026
|
+
if (attempt < retries && (!err.code || err.code === "E_FETCH")) {
|
|
2027
|
+
if (err.code === "E_FETCH") throw err;
|
|
2028
|
+
await sleep(Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3));
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
throw lastErr;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
throw lastErr;
|
|
2035
|
+
}
|
|
2036
|
+
function fetchAllTerms(opts, fetchFn) {
|
|
2037
|
+
return fetchJson(
|
|
2038
|
+
`${apiBase()}/term/all`,
|
|
2039
|
+
(u) => rawTermSchema.array().parse(u),
|
|
2040
|
+
opts,
|
|
2041
|
+
fetchFn
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
function fetchCurrentTerm(opts, fetchFn) {
|
|
2045
|
+
return fetchJson(
|
|
2046
|
+
`${apiBase()}/term/current`,
|
|
2047
|
+
(u) => rawTermSchema.parse(u),
|
|
2048
|
+
opts,
|
|
2049
|
+
fetchFn
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
function fetchOfferingHistory(term, opts, fetchFn) {
|
|
2053
|
+
return fetchJson(
|
|
2054
|
+
`${apiBase()}/offering-history/${term}`,
|
|
2055
|
+
(u) => rawHistorySchema.parse(u),
|
|
2056
|
+
opts,
|
|
2057
|
+
fetchFn
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
var SCHOOL_ALIASES = {
|
|
2061
|
+
pomona: "PO",
|
|
2062
|
+
"pomona college": "PO",
|
|
2063
|
+
po: "PO",
|
|
2064
|
+
// Registrar data uses CM (not CMC) — all Claremont McKenna inputs map to CM.
|
|
2065
|
+
cm: "CM",
|
|
2066
|
+
cmc: "CM",
|
|
2067
|
+
"claremont mckenna": "CM",
|
|
2068
|
+
"claremont mckenna college": "CM",
|
|
2069
|
+
mckenna: "CM",
|
|
2070
|
+
"harvey mudd": "HM",
|
|
2071
|
+
"harvey mudd college": "HM",
|
|
2072
|
+
mudd: "HM",
|
|
2073
|
+
hmc: "HM",
|
|
2074
|
+
hm: "HM",
|
|
2075
|
+
scripps: "SC",
|
|
2076
|
+
"scripps college": "SC",
|
|
2077
|
+
sc: "SC",
|
|
2078
|
+
pitzer: "PZ",
|
|
2079
|
+
"pitzer college": "PZ",
|
|
2080
|
+
pz: "PZ",
|
|
2081
|
+
cgu: "CG",
|
|
2082
|
+
cg: "CG",
|
|
2083
|
+
keck: "KS",
|
|
2084
|
+
ks: "KS"
|
|
2085
|
+
};
|
|
2086
|
+
function normalizeSchool(input) {
|
|
2087
|
+
const key = input.trim().toLowerCase();
|
|
2088
|
+
if (SCHOOL_ALIASES[key]) return SCHOOL_ALIASES[key];
|
|
2089
|
+
return input.trim().toUpperCase();
|
|
2090
|
+
}
|
|
2091
|
+
var DISTINCTIVE_SCHOOL_WORDS = {
|
|
2092
|
+
mudd: "HM",
|
|
2093
|
+
hmc: "HM",
|
|
2094
|
+
pomona: "PO",
|
|
2095
|
+
pitzer: "PZ",
|
|
2096
|
+
scripps: "SC",
|
|
2097
|
+
mckenna: "CM",
|
|
2098
|
+
cmc: "CM",
|
|
2099
|
+
keck: "KS"
|
|
2100
|
+
};
|
|
2101
|
+
function detectSchoolMention(text) {
|
|
2102
|
+
const words = text.toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
2103
|
+
for (const w of words) {
|
|
2104
|
+
const hit = DISTINCTIVE_SCHOOL_WORDS[w];
|
|
2105
|
+
if (hit) return hit;
|
|
2106
|
+
}
|
|
2107
|
+
return null;
|
|
2108
|
+
}
|
|
2109
|
+
function secToMin(sec) {
|
|
2110
|
+
return Math.floor(sec / 60);
|
|
2111
|
+
}
|
|
2112
|
+
function minToStr(min) {
|
|
2113
|
+
const h = Math.floor(min / 60);
|
|
2114
|
+
const m = min % 60;
|
|
2115
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
2116
|
+
}
|
|
2117
|
+
function minTo12(min) {
|
|
2118
|
+
const h24 = Math.floor(min / 60);
|
|
2119
|
+
const m = min % 60;
|
|
2120
|
+
const suffix = h24 >= 12 ? "PM" : "AM";
|
|
2121
|
+
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
|
2122
|
+
return `${h12}:${String(m).padStart(2, "0")} ${suffix}`;
|
|
2123
|
+
}
|
|
2124
|
+
function sectionId(args) {
|
|
2125
|
+
const num = String(args.number).padStart(3, "0");
|
|
2126
|
+
const sec = String(args.sectionNo).padStart(2, "0");
|
|
2127
|
+
return `${args.dept.toUpperCase()} ${num}${args.suffix.toUpperCase()} ${args.school.toUpperCase()}-${sec} ${args.term.toUpperCase()}`;
|
|
2128
|
+
}
|
|
2129
|
+
function courseCode(dept, number, suffix) {
|
|
2130
|
+
return `${dept.toUpperCase()} ${String(number).padStart(3, "0")}${suffix.toUpperCase()}`;
|
|
2131
|
+
}
|
|
2132
|
+
var CODE_RE = /^([A-Za-z]{2,4})?\s*(\d{1,3}[A-Za-z]?)?(?:\s*[- ]?\s*([A-Za-z]{2}))?(?:\s*[- ]?\s*0?(\d{1,2}))?\s*$/;
|
|
2133
|
+
var TERM_RE = /\b((?:FA|SP|SU)\s*\d{2,4}|(?:Fall|Spring|Summer)\s*\d{2,4}|\d{4}\s*(?:FA|SP|SU))\b/i;
|
|
2134
|
+
function parseCode(input) {
|
|
2135
|
+
const out = {};
|
|
2136
|
+
let rest = input.trim();
|
|
2137
|
+
const termMatch = rest.match(TERM_RE);
|
|
2138
|
+
if (termMatch) {
|
|
2139
|
+
const normalized = normalizeTerm(termMatch[1]);
|
|
2140
|
+
if (normalized) out.term = normalized;
|
|
2141
|
+
rest = rest.replace(termMatch[0], " ").trim();
|
|
2142
|
+
}
|
|
2143
|
+
const m = rest.match(CODE_RE);
|
|
2144
|
+
if (!m || !m[1] && !m[2]) return out;
|
|
2145
|
+
if (m[1]) out.dept = m[1].toUpperCase();
|
|
2146
|
+
if (m[2]) {
|
|
2147
|
+
const numPart = m[2].match(/^(\d+)([A-Za-z]?)$/);
|
|
2148
|
+
if (numPart) {
|
|
2149
|
+
out.number = Number.parseInt(numPart[1], 10);
|
|
2150
|
+
out.numberRaw = numPart[1];
|
|
2151
|
+
if (numPart[2]) out.suffix = numPart[2].toUpperCase();
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
if (m[3]) out.school = normalizeSchool(m[3]);
|
|
2155
|
+
if (m[4]) out.sectionNo = Number.parseInt(m[4], 10);
|
|
2156
|
+
return out;
|
|
2157
|
+
}
|
|
2158
|
+
function normalizeTerm(input) {
|
|
2159
|
+
const t = input.trim().toUpperCase().replace(/[\s;_-]+/g, "");
|
|
2160
|
+
const seasonMap = {
|
|
2161
|
+
FA: "FA",
|
|
2162
|
+
F: "FA",
|
|
2163
|
+
FALL: "FA",
|
|
2164
|
+
SP: "SP",
|
|
2165
|
+
S: "SP",
|
|
2166
|
+
SPRING: "SP",
|
|
2167
|
+
P: "SP",
|
|
2168
|
+
SU: "SU",
|
|
2169
|
+
SUMMER: "SU",
|
|
2170
|
+
U: "SU"
|
|
2171
|
+
};
|
|
2172
|
+
let m = t.match(/^(FA|SP|SU|FALL|SPRING|SUMMER|F|S|P|U)(\d{2}|\d{4})$/);
|
|
2173
|
+
if (m) {
|
|
2174
|
+
const season = seasonMap[m[1]];
|
|
2175
|
+
const year = m[2].length === 2 ? `20${m[2]}` : m[2];
|
|
2176
|
+
return `${season}${year}`;
|
|
2177
|
+
}
|
|
2178
|
+
m = t.match(/^(\d{4})(FA|SP|SU|FALL|SPRING|SUMMER)$/);
|
|
2179
|
+
if (m) {
|
|
2180
|
+
const season = m[2] === "FALL" ? "FA" : m[2] === "SPRING" ? "SP" : m[2] === "SUMMER" ? "SU" : m[2];
|
|
2181
|
+
return `${season}${m[1]}`;
|
|
2182
|
+
}
|
|
2183
|
+
m = t.match(/^(\d{4})(FA|SP|SU)$/);
|
|
2184
|
+
if (m) return `${m[2]}${m[1]}`;
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
function termLabel(term) {
|
|
2188
|
+
const m = term.match(/^(FA|SP|SU)(\d{4})$/);
|
|
2189
|
+
if (!m) return term;
|
|
2190
|
+
const season = m[1] === "FA" ? "Fall" : m[1] === "SP" ? "Spring" : "Summer";
|
|
2191
|
+
return `${season} ${m[2]}`;
|
|
2192
|
+
}
|
|
2193
|
+
function isRegistrable(status) {
|
|
2194
|
+
return status === "O" || status === "R";
|
|
2195
|
+
}
|
|
2196
|
+
var SEASON_ORDER = { SP: 0, SU: 1, FA: 2 };
|
|
2197
|
+
function termRecency(term) {
|
|
2198
|
+
const m = term.match(/^(FA|SP|SU)(\d{4})$/);
|
|
2199
|
+
if (!m) return Number.NaN;
|
|
2200
|
+
return Number(m[2]) * 10 + (SEASON_ORDER[m[1]] ?? 0);
|
|
2201
|
+
}
|
|
2202
|
+
function compareTermRecencyDesc(a, b) {
|
|
2203
|
+
const ra = termRecency(a);
|
|
2204
|
+
const rb = termRecency(b);
|
|
2205
|
+
if (Number.isNaN(ra) && Number.isNaN(rb)) return a.localeCompare(b);
|
|
2206
|
+
if (Number.isNaN(ra)) return 1;
|
|
2207
|
+
if (Number.isNaN(rb)) return -1;
|
|
2208
|
+
return rb - ra;
|
|
2209
|
+
}
|
|
2210
|
+
var DAY_ALIASES = {
|
|
2211
|
+
M: "M",
|
|
2212
|
+
MON: "M",
|
|
2213
|
+
T: "T",
|
|
2214
|
+
TU: "T",
|
|
2215
|
+
TUE: "T",
|
|
2216
|
+
W: "W",
|
|
2217
|
+
WED: "W",
|
|
2218
|
+
R: "R",
|
|
2219
|
+
TH: "R",
|
|
2220
|
+
THU: "R",
|
|
2221
|
+
F: "F",
|
|
2222
|
+
FRI: "F",
|
|
2223
|
+
S: "S",
|
|
2224
|
+
SA: "S",
|
|
2225
|
+
SAT: "S",
|
|
2226
|
+
U: "U",
|
|
2227
|
+
SU: "U",
|
|
2228
|
+
SUN: "U"
|
|
2229
|
+
};
|
|
2230
|
+
function normalizeDays(input) {
|
|
2231
|
+
const t = input.trim().toUpperCase();
|
|
2232
|
+
if (t === "DAILY" || t === "WEEKDAYS") return ["M", "T", "W", "R", "F"];
|
|
2233
|
+
if (t === "WEEKENDS") return ["S", "U"];
|
|
2234
|
+
if (t === "ANY") return [];
|
|
2235
|
+
const cleaned = t.replace(/[^A-Z]/g, " ");
|
|
2236
|
+
const tokens = cleaned.split(/\s+/).filter(Boolean);
|
|
2237
|
+
const out = [];
|
|
2238
|
+
for (const tok of tokens) {
|
|
2239
|
+
let rest = tok;
|
|
2240
|
+
const matched = [];
|
|
2241
|
+
let ok = true;
|
|
2242
|
+
while (rest.length > 0) {
|
|
2243
|
+
const three = DAY_ALIASES[rest.slice(0, 3)];
|
|
2244
|
+
const two = DAY_ALIASES[rest.slice(0, 2)];
|
|
2245
|
+
const one = DAY_ALIASES[rest.slice(0, 1)];
|
|
2246
|
+
if (three && rest.length >= 3 && ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].includes(
|
|
2247
|
+
rest.slice(0, 3)
|
|
2248
|
+
)) {
|
|
2249
|
+
matched.push(three);
|
|
2250
|
+
rest = rest.slice(3);
|
|
2251
|
+
} else if (two && ["TU", "TH", "SA", "SU", "MO", "WE", "FR"].includes(rest.slice(0, 2))) {
|
|
2252
|
+
matched.push(two);
|
|
2253
|
+
rest = rest.slice(2);
|
|
2254
|
+
} else if (one) {
|
|
2255
|
+
matched.push(one);
|
|
2256
|
+
rest = rest.slice(1);
|
|
2257
|
+
} else {
|
|
2258
|
+
ok = false;
|
|
2259
|
+
break;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
if (!ok) return null;
|
|
2263
|
+
out.push(...matched);
|
|
2264
|
+
}
|
|
2265
|
+
const order = ["M", "T", "W", "R", "F", "S", "U"];
|
|
2266
|
+
const unique = [...new Set(out)].sort(
|
|
2267
|
+
(a, b) => order.indexOf(a) - order.indexOf(b)
|
|
2268
|
+
);
|
|
2269
|
+
return unique.length === 0 ? null : unique;
|
|
2270
|
+
}
|
|
2271
|
+
function parseTime(input) {
|
|
2272
|
+
let t = input.trim().toLowerCase();
|
|
2273
|
+
t = t.replace(/^(after|before|at|around)\s+/, "");
|
|
2274
|
+
if (t === "morning" || t === "mornings") return 8 * 60;
|
|
2275
|
+
if (t === "afternoon" || t === "afternoons") return 12 * 60;
|
|
2276
|
+
if (t === "evening" || t === "evenings" || t === "night") return 17 * 60;
|
|
2277
|
+
if (t === "noon") return 12 * 60;
|
|
2278
|
+
if (t === "midnight") return 0;
|
|
2279
|
+
const m = t.match(/^(\d{1,4})(?::(\d{2}))?\s*([ap])\.?m?\.?$/);
|
|
2280
|
+
if (m) {
|
|
2281
|
+
let h;
|
|
2282
|
+
let min = m[2] ? Number.parseInt(m[2], 10) : 0;
|
|
2283
|
+
if (m[1].length > 2 && !m[2]) {
|
|
2284
|
+
h = Number.parseInt(m[1].slice(0, -2), 10);
|
|
2285
|
+
min = Number.parseInt(m[1].slice(-2), 10);
|
|
2286
|
+
} else {
|
|
2287
|
+
h = Number.parseInt(m[1], 10);
|
|
2288
|
+
}
|
|
2289
|
+
const ap = m[3];
|
|
2290
|
+
if (h < 1 || h > 12 || min > 59) return null;
|
|
2291
|
+
if (ap === "p" && h !== 12) h += 12;
|
|
2292
|
+
if (ap === "a" && h === 12) h = 0;
|
|
2293
|
+
return h * 60 + min;
|
|
2294
|
+
}
|
|
2295
|
+
const m24 = t.match(/^(\d{1,2})(?::(\d{2}))?$/);
|
|
2296
|
+
if (m24) {
|
|
2297
|
+
const h = Number.parseInt(m24[1], 10);
|
|
2298
|
+
const min = m24[2] ? Number.parseInt(m24[2], 10) : 0;
|
|
2299
|
+
if (h > 23 || min > 59) return null;
|
|
2300
|
+
return h * 60 + min;
|
|
2301
|
+
}
|
|
2302
|
+
const m4 = t.match(/^(\d{3,4})$/);
|
|
2303
|
+
if (m4) {
|
|
2304
|
+
const h = Number.parseInt(m4[1].slice(0, -2), 10);
|
|
2305
|
+
const min = Number.parseInt(m4[1].slice(-2), 10);
|
|
2306
|
+
if (h > 23 || min > 59) return null;
|
|
2307
|
+
return h * 60 + min;
|
|
2308
|
+
}
|
|
2309
|
+
return null;
|
|
2310
|
+
}
|
|
2311
|
+
var STATUS_LABELS = {
|
|
2312
|
+
O: "Open",
|
|
2313
|
+
C: "Closed",
|
|
2314
|
+
R: "Reopened",
|
|
2315
|
+
U: "Unknown"
|
|
2316
|
+
};
|
|
2317
|
+
function toISO(d) {
|
|
2318
|
+
const mm = String(d.month).padStart(2, "0");
|
|
2319
|
+
const dd = String(d.day).padStart(2, "0");
|
|
2320
|
+
return `${d.year}-${mm}-${dd}`;
|
|
2321
|
+
}
|
|
2322
|
+
var VALID_DAYS = /* @__PURE__ */ new Set(["M", "T", "W", "R", "F", "S", "U"]);
|
|
2323
|
+
function normalizeSection(raw) {
|
|
2324
|
+
const id = raw.identifier;
|
|
2325
|
+
const term = `${id.term}${id.year}`;
|
|
2326
|
+
const school = id.affiliation.toUpperCase();
|
|
2327
|
+
const status = ["O", "C", "R", "U"].includes(raw.status) ? raw.status : "U";
|
|
2328
|
+
const meetings = [];
|
|
2329
|
+
let droppedMeetings = false;
|
|
2330
|
+
for (const s of raw.schedules.filter(
|
|
2331
|
+
(x) => !(x.startTime === 0 && x.endTime === 0 && x.days.length === 0)
|
|
2332
|
+
)) {
|
|
2333
|
+
const startMin = secToMin(s.startTime);
|
|
2334
|
+
const endMin = secToMin(s.endTime);
|
|
2335
|
+
if (endMin <= startMin) {
|
|
2336
|
+
droppedMeetings = true;
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
meetings.push({
|
|
2340
|
+
days: s.days.filter((d) => VALID_DAYS.has(d)),
|
|
2341
|
+
startMin,
|
|
2342
|
+
endMin,
|
|
2343
|
+
startStr: minToStr(startMin),
|
|
2344
|
+
endStr: minToStr(endMin),
|
|
2345
|
+
start12: minTo12(startMin),
|
|
2346
|
+
end12: minTo12(endMin),
|
|
2347
|
+
locations: s.locations.map((l) => l.trim()).filter((l) => l.length > 0)
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
const available = Math.max(0, raw.seatsTotal - raw.seatsFilled);
|
|
2351
|
+
return {
|
|
2352
|
+
id: sectionId({
|
|
2353
|
+
dept: id.department,
|
|
2354
|
+
number: id.courseNumber,
|
|
2355
|
+
suffix: id.suffix,
|
|
2356
|
+
school,
|
|
2357
|
+
sectionNo: id.sectionNumber,
|
|
2358
|
+
term
|
|
2359
|
+
}),
|
|
2360
|
+
term,
|
|
2361
|
+
year: id.year,
|
|
2362
|
+
termCode: ["FA", "SP", "SU"].includes(id.term) ? id.term : "FA",
|
|
2363
|
+
half: id.half,
|
|
2364
|
+
dept: id.department.toUpperCase(),
|
|
2365
|
+
number: id.courseNumber,
|
|
2366
|
+
suffix: id.suffix.toUpperCase(),
|
|
2367
|
+
school,
|
|
2368
|
+
sectionNo: id.sectionNumber,
|
|
2369
|
+
code: courseCode(id.department, id.courseNumber, id.suffix),
|
|
2370
|
+
title: raw.course.title,
|
|
2371
|
+
description: raw.course.description,
|
|
2372
|
+
areas: raw.courseAreas,
|
|
2373
|
+
instructors: raw.instructors.map((i) => i.name.replace(/\s+/g, " ").trim()),
|
|
2374
|
+
meetings,
|
|
2375
|
+
credits: raw.credits,
|
|
2376
|
+
status,
|
|
2377
|
+
statusLabel: STATUS_LABELS[status] ?? "Unknown",
|
|
2378
|
+
seats: {
|
|
2379
|
+
filled: raw.seatsFilled,
|
|
2380
|
+
total: raw.seatsTotal,
|
|
2381
|
+
available,
|
|
2382
|
+
permCount: raw.permCount
|
|
2383
|
+
},
|
|
2384
|
+
startDate: toISO(raw.startDate),
|
|
2385
|
+
endDate: toISO(raw.endDate),
|
|
2386
|
+
potentialError: raw.potentialError || raw.course.potentialError || droppedMeetings,
|
|
2387
|
+
primaryAssociation: raw.course.primaryAssociation,
|
|
2388
|
+
affiliation: school
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
var SEAT_TTL_MS = 6e4;
|
|
2392
|
+
var SCHEDULE_TTL_MS = 30 * 6e4;
|
|
2393
|
+
var HISTORY_TTL_MS = 24 * 60 * 6e4;
|
|
2394
|
+
var CURRENT_TERM_TTL_MS = 5 * 6e4;
|
|
2395
|
+
var NEGATIVE_TTL_MS = 5 * 6e4;
|
|
2396
|
+
var HISTORY_MEM_CAP = 8;
|
|
2397
|
+
function cacheDir() {
|
|
2398
|
+
const override = process.env.FIVE_C_DATA_DIR ?? process.env.FIVE_C_CACHE_DIR ?? null;
|
|
2399
|
+
return override ?? join(homedir(), ".5c-index", "cache");
|
|
2400
|
+
}
|
|
2401
|
+
function files(term) {
|
|
2402
|
+
const dir = cacheDir();
|
|
2403
|
+
return {
|
|
2404
|
+
dir,
|
|
2405
|
+
data: join(dir, `${term}.json`),
|
|
2406
|
+
meta: join(dir, `${term}.meta.json`),
|
|
2407
|
+
history: join(dir, `${term}.history.json`),
|
|
2408
|
+
neg: join(dir, `${term}.neg.json`)
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
function currentTermFile() {
|
|
2412
|
+
return join(cacheDir(), "current-term.json");
|
|
2413
|
+
}
|
|
2414
|
+
function sha1(s) {
|
|
2415
|
+
return createHash("sha1").update(s).digest("hex");
|
|
2416
|
+
}
|
|
2417
|
+
var mem = /* @__PURE__ */ new Map();
|
|
2418
|
+
var memHistory = /* @__PURE__ */ new Map();
|
|
2419
|
+
var negative = /* @__PURE__ */ new Map();
|
|
2420
|
+
var inflightRefresh = /* @__PURE__ */ new Map();
|
|
2421
|
+
var inflightHistory = /* @__PURE__ */ new Map();
|
|
2422
|
+
var currentTermMem = null;
|
|
2423
|
+
function getCurrentTermMem() {
|
|
2424
|
+
return currentTermMem;
|
|
2425
|
+
}
|
|
2426
|
+
function setCurrentTermMem(value, at) {
|
|
2427
|
+
currentTermMem = { value, at };
|
|
2428
|
+
}
|
|
2429
|
+
function isStale(meta) {
|
|
2430
|
+
const t = Date.parse(meta.fetched_at);
|
|
2431
|
+
if (!Number.isFinite(t)) return true;
|
|
2432
|
+
const age = Date.now() - t;
|
|
2433
|
+
if (!Number.isFinite(age) || age < 0) return true;
|
|
2434
|
+
return age > SEAT_TTL_MS;
|
|
2435
|
+
}
|
|
2436
|
+
function historyIsStale(fetched_at) {
|
|
2437
|
+
const t = Date.parse(fetched_at);
|
|
2438
|
+
if (!Number.isFinite(t)) return true;
|
|
2439
|
+
const age = Date.now() - t;
|
|
2440
|
+
if (!Number.isFinite(age) || age < 0) return true;
|
|
2441
|
+
return age > HISTORY_TTL_MS;
|
|
2442
|
+
}
|
|
2443
|
+
function nowISO() {
|
|
2444
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
2445
|
+
}
|
|
2446
|
+
function sleep2(ms) {
|
|
2447
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2448
|
+
}
|
|
2449
|
+
async function atomicWrite(path, content) {
|
|
2450
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
2451
|
+
await writeFile(tmp, content);
|
|
2452
|
+
try {
|
|
2453
|
+
const fh = await open(tmp, "r");
|
|
2454
|
+
try {
|
|
2455
|
+
await fh.sync();
|
|
2456
|
+
} finally {
|
|
2457
|
+
await fh.close();
|
|
2458
|
+
}
|
|
2459
|
+
await rename(tmp, path);
|
|
2460
|
+
} catch {
|
|
2461
|
+
try {
|
|
2462
|
+
await unlink(tmp);
|
|
2463
|
+
} catch {
|
|
2464
|
+
}
|
|
2465
|
+
throw new Error(`Atomic write failed for ${path}`);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
function countTolerance(metaCount) {
|
|
2469
|
+
return Math.max(10, Math.floor(metaCount * 0.1));
|
|
2470
|
+
}
|
|
2471
|
+
function validateSnapshot(sections, meta) {
|
|
2472
|
+
const m = cacheMetaSchema.safeParse(meta);
|
|
2473
|
+
if (!m.success) return null;
|
|
2474
|
+
const s = sectionSchema.array().safeParse(sections);
|
|
2475
|
+
if (!s.success) return null;
|
|
2476
|
+
if (s.data.length === 0) return null;
|
|
2477
|
+
if (Math.abs(s.data.length - m.data.count) > countTolerance(m.data.count)) {
|
|
2478
|
+
return null;
|
|
2479
|
+
}
|
|
2480
|
+
return { sections: s.data, meta: m.data };
|
|
2481
|
+
}
|
|
2482
|
+
function quarantineRawSections(data) {
|
|
2483
|
+
const whole = rawSectionsSchema.safeParse(data);
|
|
2484
|
+
if (whole.success) return { raw: whole.data, quarantined: 0 };
|
|
2485
|
+
if (!Array.isArray(data)) return { raw: [], quarantined: 0 };
|
|
2486
|
+
const good = [];
|
|
2487
|
+
let quarantined = 0;
|
|
2488
|
+
for (const item of data) {
|
|
2489
|
+
const r = rawSectionSchema.safeParse(item);
|
|
2490
|
+
if (r.success) good.push(r.data);
|
|
2491
|
+
else quarantined++;
|
|
2492
|
+
}
|
|
2493
|
+
return { raw: good, quarantined };
|
|
2494
|
+
}
|
|
2495
|
+
async function readSnapshot(T) {
|
|
2496
|
+
const f = files(T);
|
|
2497
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2498
|
+
let metaRaw;
|
|
2499
|
+
let dataRaw;
|
|
2500
|
+
try {
|
|
2501
|
+
metaRaw = await readFile(f.meta, "utf8");
|
|
2502
|
+
dataRaw = await readFile(f.data, "utf8");
|
|
2503
|
+
} catch {
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
let metaParsed;
|
|
2507
|
+
try {
|
|
2508
|
+
metaParsed = JSON.parse(metaRaw);
|
|
2509
|
+
} catch {
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
2512
|
+
const meta = metaParsed;
|
|
2513
|
+
if (typeof meta?.hash === "string" && sha1(dataRaw) !== meta.hash) {
|
|
2514
|
+
if (attempt === 0) {
|
|
2515
|
+
await sleep2(50);
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2518
|
+
return null;
|
|
2519
|
+
}
|
|
2520
|
+
let sectionsRaw;
|
|
2521
|
+
try {
|
|
2522
|
+
sectionsRaw = JSON.parse(dataRaw);
|
|
2523
|
+
} catch {
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2526
|
+
return validateSnapshot(sectionsRaw, metaParsed);
|
|
2527
|
+
}
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
async function loadTerm(term, opts = {}) {
|
|
2531
|
+
const T = term.toUpperCase();
|
|
2532
|
+
const cached = mem.get(T);
|
|
2533
|
+
if (cached) {
|
|
2534
|
+
if (cached.sections.length === 0) {
|
|
2535
|
+
mem.delete(T);
|
|
2536
|
+
} else if (!isStale(cached.meta)) {
|
|
2537
|
+
return cached;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
const disk = await readSnapshot(T);
|
|
2541
|
+
if (disk) {
|
|
2542
|
+
disk.meta.stale = isStale(disk.meta);
|
|
2543
|
+
disk.meta.source = disk.meta.stale ? "stale-cache" : "live";
|
|
2544
|
+
mem.set(T, disk);
|
|
2545
|
+
if (disk.meta.stale && opts.allowStale !== false) {
|
|
2546
|
+
void refreshTerm(T, { fetchFn: opts.fetchFn }).catch(() => {
|
|
2547
|
+
});
|
|
2548
|
+
return disk;
|
|
2549
|
+
}
|
|
2550
|
+
if (!disk.meta.stale) return disk;
|
|
2551
|
+
} else {
|
|
2552
|
+
const staleMem = mem.get(T);
|
|
2553
|
+
if (staleMem && staleMem.sections.length > 0 && opts.allowStale !== false) {
|
|
2554
|
+
staleMem.meta.stale = true;
|
|
2555
|
+
staleMem.meta.source = "stale-cache";
|
|
2556
|
+
void refreshTerm(T, { fetchFn: opts.fetchFn }).catch(() => {
|
|
2557
|
+
});
|
|
2558
|
+
return staleMem;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
return refreshTerm(T, { fetchFn: opts.fetchFn });
|
|
2562
|
+
}
|
|
2563
|
+
async function readNegative(T) {
|
|
2564
|
+
const f = files(T);
|
|
2565
|
+
try {
|
|
2566
|
+
const raw = await readFile(f.neg, "utf8");
|
|
2567
|
+
const at = JSON.parse(raw).at;
|
|
2568
|
+
if (typeof at === "number" && Number.isFinite(at)) {
|
|
2569
|
+
if (Date.now() - at < NEGATIVE_TTL_MS) {
|
|
2570
|
+
negative.set(T, at);
|
|
2571
|
+
return at;
|
|
2572
|
+
}
|
|
2573
|
+
void unlink(f.neg).catch(() => {
|
|
2574
|
+
});
|
|
2575
|
+
negative.delete(T);
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
} catch {
|
|
2579
|
+
}
|
|
2580
|
+
const memAt = negative.get(T);
|
|
2581
|
+
if (memAt !== void 0) {
|
|
2582
|
+
if (Date.now() - memAt < NEGATIVE_TTL_MS) return memAt;
|
|
2583
|
+
negative.delete(T);
|
|
2584
|
+
}
|
|
2585
|
+
return null;
|
|
2586
|
+
}
|
|
2587
|
+
async function writeNegative(T) {
|
|
2588
|
+
const at = Date.now();
|
|
2589
|
+
negative.set(T, at);
|
|
2590
|
+
try {
|
|
2591
|
+
const f = files(T);
|
|
2592
|
+
await mkdir(f.dir, { recursive: true });
|
|
2593
|
+
await atomicWrite(f.neg, JSON.stringify({ at }));
|
|
2594
|
+
} catch {
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
async function clearNegative(T) {
|
|
2598
|
+
negative.delete(T);
|
|
2599
|
+
try {
|
|
2600
|
+
await unlink(files(T).neg);
|
|
2601
|
+
} catch {
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
function termNotFound(T) {
|
|
2605
|
+
return Object.assign(new Error(`Term ${T} not available upstream (404)`), {
|
|
2606
|
+
code: "E_TERM_NOT_FOUND"
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
async function refreshTerm(term, opts = {}) {
|
|
2610
|
+
const T = term.toUpperCase();
|
|
2611
|
+
if (!opts.force) {
|
|
2612
|
+
const neg = await readNegative(T);
|
|
2613
|
+
if (neg !== null) {
|
|
2614
|
+
throw Object.assign(
|
|
2615
|
+
new Error(`Term ${T} recently 404'd upstream; retry shortly`),
|
|
2616
|
+
{ code: "E_TERM_NOT_FOUND" }
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
const key = opts.force ? `${T}:force` : T;
|
|
2621
|
+
const inflight = inflightRefresh.get(key);
|
|
2622
|
+
if (inflight) return inflight;
|
|
2623
|
+
const p = refreshTermInner(T, opts).then((entry) => {
|
|
2624
|
+
inflightRefresh.delete(key);
|
|
2625
|
+
return entry;
|
|
2626
|
+
}).catch((err) => {
|
|
2627
|
+
inflightRefresh.delete(key);
|
|
2628
|
+
throw err;
|
|
2629
|
+
});
|
|
2630
|
+
inflightRefresh.set(key, p);
|
|
2631
|
+
return p;
|
|
2632
|
+
}
|
|
2633
|
+
async function refreshTermInner(T, opts) {
|
|
2634
|
+
const f = files(T);
|
|
2635
|
+
let prevEtag = null;
|
|
2636
|
+
try {
|
|
2637
|
+
const metaRaw = await readFile(f.meta, "utf8");
|
|
2638
|
+
prevEtag = JSON.parse(metaRaw).etag ?? null;
|
|
2639
|
+
} catch {
|
|
2640
|
+
}
|
|
2641
|
+
async function doFetch(etag) {
|
|
2642
|
+
return fetchJson(
|
|
2643
|
+
`${apiBase()}/sections/${T}`,
|
|
2644
|
+
(u) => u,
|
|
2645
|
+
{ etag: opts.force ? null : etag },
|
|
2646
|
+
opts.fetchFn
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
let res;
|
|
2650
|
+
try {
|
|
2651
|
+
res = await doFetch(prevEtag);
|
|
2652
|
+
} catch (err) {
|
|
2653
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") {
|
|
2654
|
+
await writeNegative(T);
|
|
2655
|
+
const disk2 = await readSnapshot(T);
|
|
2656
|
+
if (disk2) {
|
|
2657
|
+
disk2.meta.stale = true;
|
|
2658
|
+
disk2.meta.source = "offline-file";
|
|
2659
|
+
mem.set(T, disk2);
|
|
2660
|
+
return disk2;
|
|
2661
|
+
}
|
|
2662
|
+
throw termNotFound(T);
|
|
2663
|
+
}
|
|
2664
|
+
const disk = await readSnapshot(T);
|
|
2665
|
+
if (disk) {
|
|
2666
|
+
disk.meta.stale = true;
|
|
2667
|
+
disk.meta.source = "offline-file";
|
|
2668
|
+
mem.set(T, disk);
|
|
2669
|
+
return disk;
|
|
2670
|
+
}
|
|
2671
|
+
throw err;
|
|
2672
|
+
}
|
|
2673
|
+
if (res === void 0 || res.notModified) {
|
|
2674
|
+
const disk = await readSnapshot(T);
|
|
2675
|
+
if (!disk) {
|
|
2676
|
+
try {
|
|
2677
|
+
res = await doFetch(null);
|
|
2678
|
+
} catch (err) {
|
|
2679
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") {
|
|
2680
|
+
await writeNegative(T);
|
|
2681
|
+
throw termNotFound(T);
|
|
2682
|
+
}
|
|
2683
|
+
throw err;
|
|
2684
|
+
}
|
|
2685
|
+
if (res === void 0 || res.notModified) {
|
|
2686
|
+
throw termNotFound(T);
|
|
2687
|
+
}
|
|
2688
|
+
return persistFresh(T, res.data, res.etag);
|
|
2689
|
+
}
|
|
2690
|
+
disk.meta.fetched_at = nowISO();
|
|
2691
|
+
disk.meta.stale = false;
|
|
2692
|
+
disk.meta.source = "live";
|
|
2693
|
+
await atomicWrite(f.meta, JSON.stringify(disk.meta, null, 1));
|
|
2694
|
+
mem.set(T, disk);
|
|
2695
|
+
return disk;
|
|
2696
|
+
}
|
|
2697
|
+
return persistFresh(T, res.data, res.etag);
|
|
2698
|
+
}
|
|
2699
|
+
async function persistFresh(T, data, etag) {
|
|
2700
|
+
const f = files(T);
|
|
2701
|
+
const { raw, quarantined } = quarantineRawSections(data);
|
|
2702
|
+
if (raw.length === 0) {
|
|
2703
|
+
const disk = await readSnapshot(T);
|
|
2704
|
+
if (disk) {
|
|
2705
|
+
disk.meta.stale = true;
|
|
2706
|
+
disk.meta.source = "offline-file";
|
|
2707
|
+
mem.set(T, disk);
|
|
2708
|
+
return disk;
|
|
2709
|
+
}
|
|
2710
|
+
throw Object.assign(
|
|
2711
|
+
new Error(`Upstream payload for term ${T} contained no usable sections`),
|
|
2712
|
+
{ code: "E_FETCH" }
|
|
2713
|
+
);
|
|
2714
|
+
}
|
|
2715
|
+
const sections = raw.map(normalizeSection).filter((s) => s.term === T);
|
|
2716
|
+
if (sections.length === 0) {
|
|
2717
|
+
const disk = await readSnapshot(T);
|
|
2718
|
+
if (disk) {
|
|
2719
|
+
disk.meta.stale = true;
|
|
2720
|
+
disk.meta.source = "offline-file";
|
|
2721
|
+
mem.set(T, disk);
|
|
2722
|
+
return disk;
|
|
2723
|
+
}
|
|
2724
|
+
throw Object.assign(
|
|
2725
|
+
new Error(`No sections for term ${T} after term filter`),
|
|
2726
|
+
{ code: "E_FETCH" }
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2730
|
+
const deduped = [];
|
|
2731
|
+
let duplicateIds = 0;
|
|
2732
|
+
for (const s of sections) {
|
|
2733
|
+
if (seen.has(s.id)) {
|
|
2734
|
+
duplicateIds++;
|
|
2735
|
+
continue;
|
|
2736
|
+
}
|
|
2737
|
+
seen.add(s.id);
|
|
2738
|
+
deduped.push(s);
|
|
2739
|
+
}
|
|
2740
|
+
const prevDisk = await readSnapshot(T);
|
|
2741
|
+
if (prevDisk) {
|
|
2742
|
+
const total = raw.length + quarantined;
|
|
2743
|
+
const badRatio = total > 0 ? quarantined / total : 0;
|
|
2744
|
+
const droppedBelowHalf = deduped.length < Math.ceil(prevDisk.sections.length / 2);
|
|
2745
|
+
if (quarantined >= 10 && badRatio > 0.05 || droppedBelowHalf) {
|
|
2746
|
+
prevDisk.meta.stale = true;
|
|
2747
|
+
prevDisk.meta.source = "offline-file";
|
|
2748
|
+
mem.set(T, prevDisk);
|
|
2749
|
+
throw Object.assign(
|
|
2750
|
+
new Error(
|
|
2751
|
+
`Refusing snapshot for ${T}: ${quarantined} quarantined row(s), count ${prevDisk.sections.length} \u2192 ${deduped.length}. Keeping last-good snapshot.`
|
|
2752
|
+
),
|
|
2753
|
+
{ code: "E_FETCH" }
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
const dataText = JSON.stringify(deduped);
|
|
2758
|
+
const meta = {
|
|
2759
|
+
term: T,
|
|
2760
|
+
fetched_at: nowISO(),
|
|
2761
|
+
etag,
|
|
2762
|
+
hash: sha1(dataText),
|
|
2763
|
+
count: deduped.length,
|
|
2764
|
+
source_url: `${apiBase()}/sections/${T}`,
|
|
2765
|
+
ttl_kind: "seats:60s/schedules:30min",
|
|
2766
|
+
stale: false,
|
|
2767
|
+
source: "live",
|
|
2768
|
+
quarantined,
|
|
2769
|
+
duplicate_ids: duplicateIds
|
|
2770
|
+
};
|
|
2771
|
+
await mkdir(f.dir, { recursive: true });
|
|
2772
|
+
await atomicWrite(f.data, dataText);
|
|
2773
|
+
await atomicWrite(f.meta, JSON.stringify(meta, null, 1));
|
|
2774
|
+
const entry = { sections: deduped, meta };
|
|
2775
|
+
mem.set(T, entry);
|
|
2776
|
+
await clearNegative(T);
|
|
2777
|
+
try {
|
|
2778
|
+
if (currentTermMem?.value === T) {
|
|
2779
|
+
await writeCurrentTermFile(T);
|
|
2780
|
+
} else {
|
|
2781
|
+
const cur = await readCurrentTermFile();
|
|
2782
|
+
if (cur?.value === T) await writeCurrentTermFile(T);
|
|
2783
|
+
}
|
|
2784
|
+
} catch {
|
|
2785
|
+
}
|
|
2786
|
+
return entry;
|
|
2787
|
+
}
|
|
2788
|
+
async function readCurrentTermFile() {
|
|
2789
|
+
try {
|
|
2790
|
+
const raw = await readFile(currentTermFile(), "utf8");
|
|
2791
|
+
const parsed = JSON.parse(raw);
|
|
2792
|
+
if (typeof parsed?.value !== "string" || !/^(FA|SP|SU)\d{4}$/.test(parsed.value) || typeof parsed?.at !== "number" || !Number.isFinite(parsed.at)) {
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
return { value: parsed.value, at: parsed.at };
|
|
2796
|
+
} catch {
|
|
2797
|
+
return null;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
async function writeCurrentTermFile(value) {
|
|
2801
|
+
const payload = { value, at: Date.now() };
|
|
2802
|
+
try {
|
|
2803
|
+
await mkdir(cacheDir(), { recursive: true });
|
|
2804
|
+
await atomicWrite(currentTermFile(), JSON.stringify(payload));
|
|
2805
|
+
} catch {
|
|
2806
|
+
}
|
|
2807
|
+
setCurrentTermMem(payload.value, payload.at);
|
|
2808
|
+
return payload;
|
|
2809
|
+
}
|
|
2810
|
+
function memHistorySet(T, payload) {
|
|
2811
|
+
memHistory.delete(T);
|
|
2812
|
+
memHistory.set(T, payload);
|
|
2813
|
+
while (memHistory.size > HISTORY_MEM_CAP) {
|
|
2814
|
+
const oldest = memHistory.keys().next();
|
|
2815
|
+
if (oldest.done) break;
|
|
2816
|
+
memHistory.delete(oldest.value);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
async function loadHistory(term, opts = {}) {
|
|
2820
|
+
const T = term.toUpperCase();
|
|
2821
|
+
const cached = memHistory.get(T);
|
|
2822
|
+
if (cached && !historyIsStale(cached.fetched_at)) {
|
|
2823
|
+
memHistory.delete(T);
|
|
2824
|
+
memHistory.set(T, cached);
|
|
2825
|
+
return { ...cached, stale: false };
|
|
2826
|
+
}
|
|
2827
|
+
const inflight = inflightHistory.get(T);
|
|
2828
|
+
if (inflight) return inflight;
|
|
2829
|
+
const p = loadHistoryInner(T, opts, cached ?? null).then((entry) => {
|
|
2830
|
+
inflightHistory.delete(T);
|
|
2831
|
+
return entry;
|
|
2832
|
+
}).catch((err) => {
|
|
2833
|
+
inflightHistory.delete(T);
|
|
2834
|
+
throw err;
|
|
2835
|
+
});
|
|
2836
|
+
inflightHistory.set(T, p);
|
|
2837
|
+
return p;
|
|
2838
|
+
}
|
|
2839
|
+
async function loadHistoryInner(T, opts, staleMem) {
|
|
2840
|
+
const f = files(T);
|
|
2841
|
+
let staleDisk = null;
|
|
2842
|
+
try {
|
|
2843
|
+
const raw = await readFile(f.history, "utf8");
|
|
2844
|
+
const parsed = JSON.parse(raw);
|
|
2845
|
+
const entries = rawHistorySchema.safeParse(parsed?.entries);
|
|
2846
|
+
if (entries.success && typeof parsed?.fetched_at === "string" && !historyIsStale(parsed.fetched_at)) {
|
|
2847
|
+
const payload = { entries: entries.data, fetched_at: parsed.fetched_at };
|
|
2848
|
+
memHistorySet(T, payload);
|
|
2849
|
+
return { ...payload, stale: false };
|
|
2850
|
+
}
|
|
2851
|
+
if (entries.success && typeof parsed?.fetched_at === "string") {
|
|
2852
|
+
staleDisk = { entries: entries.data, fetched_at: parsed.fetched_at };
|
|
2853
|
+
}
|
|
2854
|
+
} catch {
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const res = await fetchOfferingHistory(T, {}, opts.fetchFn);
|
|
2858
|
+
const entries = rawHistorySchema.parse(res.data);
|
|
2859
|
+
const payload = { entries, fetched_at: nowISO() };
|
|
2860
|
+
await mkdir(f.dir, { recursive: true });
|
|
2861
|
+
await atomicWrite(f.history, JSON.stringify(payload));
|
|
2862
|
+
memHistorySet(T, payload);
|
|
2863
|
+
return { ...payload, stale: false };
|
|
2864
|
+
} catch (err) {
|
|
2865
|
+
if (staleMem) {
|
|
2866
|
+
memHistorySet(T, staleMem);
|
|
2867
|
+
return { ...staleMem, stale: true };
|
|
2868
|
+
}
|
|
2869
|
+
if (staleDisk) {
|
|
2870
|
+
memHistorySet(T, staleDisk);
|
|
2871
|
+
return { ...staleDisk, stale: true };
|
|
2872
|
+
}
|
|
2873
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") {
|
|
2874
|
+
throw termNotFound(T);
|
|
2875
|
+
}
|
|
2876
|
+
throw err;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
var WALK = {
|
|
2880
|
+
PO: { PO: 0, SC: 5, CM: 8, HM: 10, PZ: 12 },
|
|
2881
|
+
SC: { PO: 5, SC: 0, CM: 5, HM: 8, PZ: 10 },
|
|
2882
|
+
CM: { PO: 8, SC: 5, CM: 0, HM: 6, PZ: 8 },
|
|
2883
|
+
HM: { PO: 10, SC: 8, CM: 6, HM: 0, PZ: 5 },
|
|
2884
|
+
PZ: { PO: 12, SC: 10, CM: 8, HM: 5, PZ: 0 }
|
|
2885
|
+
};
|
|
2886
|
+
function walkNeed(a, b) {
|
|
2887
|
+
if (a === "BLOCK" || b === "BLOCK") return 0;
|
|
2888
|
+
return WALK[a]?.[b] ?? (a === b ? 0 : 10);
|
|
2889
|
+
}
|
|
2890
|
+
function meetingsOverlap(a, b) {
|
|
2891
|
+
if (!a.days.some((d) => b.days.includes(d))) return false;
|
|
2892
|
+
return a.startMin < b.endMin && b.startMin < a.endMin;
|
|
2893
|
+
}
|
|
2894
|
+
function overlapMinutes(a, b) {
|
|
2895
|
+
if (!a.days.some((d) => b.days.includes(d))) return 0;
|
|
2896
|
+
return Math.max(
|
|
2897
|
+
0,
|
|
2898
|
+
Math.min(a.endMin, b.endMin) - Math.max(a.startMin, b.startMin)
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
function dateOverlap(a, b) {
|
|
2902
|
+
if (!a.half && !b.half) return true;
|
|
2903
|
+
return a.startDate <= b.endDate && b.startDate <= a.endDate;
|
|
2904
|
+
}
|
|
2905
|
+
function sectionsOverlap(a, b) {
|
|
2906
|
+
if (!dateOverlap(a, b)) return false;
|
|
2907
|
+
for (const ma of a.meetings) {
|
|
2908
|
+
for (const mb of b.meetings) {
|
|
2909
|
+
if (meetingsOverlap(ma, mb)) return true;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
return false;
|
|
2913
|
+
}
|
|
2914
|
+
function findConflicts(sections, passingMinutes = 10) {
|
|
2915
|
+
const conflicts = [];
|
|
2916
|
+
const warnings = [];
|
|
2917
|
+
for (let i = 0; i < sections.length; i++) {
|
|
2918
|
+
for (let j = i + 1; j < sections.length; j++) {
|
|
2919
|
+
const a = sections[i];
|
|
2920
|
+
const b = sections[j];
|
|
2921
|
+
if (a.meetings.length === 0 || b.meetings.length === 0) {
|
|
2922
|
+
warnings.push(
|
|
2923
|
+
`Time TBA for ${a.meetings.length === 0 ? a.id : b.id}; skipped in conflict check.`
|
|
2924
|
+
);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
if (!dateOverlap(a, b)) continue;
|
|
2928
|
+
for (const ma of a.meetings) {
|
|
2929
|
+
for (const mb of b.meetings) {
|
|
2930
|
+
const common = ma.days.filter((d) => mb.days.includes(d));
|
|
2931
|
+
if (common.length === 0) continue;
|
|
2932
|
+
const ov = overlapMinutes(ma, mb);
|
|
2933
|
+
if (ov > 0) {
|
|
2934
|
+
const start = Math.max(ma.startMin, mb.startMin);
|
|
2935
|
+
const end = Math.min(ma.endMin, mb.endMin);
|
|
2936
|
+
conflicts.push({
|
|
2937
|
+
a: a.id,
|
|
2938
|
+
b: b.id,
|
|
2939
|
+
days: common,
|
|
2940
|
+
overlap: `${minToStr(start)}-${minToStr(end)}`,
|
|
2941
|
+
overlap_minutes: ov,
|
|
2942
|
+
severity: "hard"
|
|
2943
|
+
});
|
|
2944
|
+
} else {
|
|
2945
|
+
const gap = mb.startMin >= ma.endMin ? mb.startMin - ma.endMin : ma.startMin >= mb.endMin ? ma.startMin - mb.endMin : 0;
|
|
2946
|
+
if (gap < passingMinutes) {
|
|
2947
|
+
conflicts.push({
|
|
2948
|
+
a: a.id,
|
|
2949
|
+
b: b.id,
|
|
2950
|
+
days: common,
|
|
2951
|
+
overlap: `gap ${gap}min (${minToStr(ma.startMin)}-${minToStr(ma.endMin)} vs ${minToStr(mb.startMin)}-${minToStr(mb.endMin)})`,
|
|
2952
|
+
overlap_minutes: 0,
|
|
2953
|
+
severity: "tight"
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
return { conflicts, warnings };
|
|
2962
|
+
}
|
|
2963
|
+
function travelWarnings(sections, passingMinutes = 10) {
|
|
2964
|
+
const out = [];
|
|
2965
|
+
const days = ["M", "T", "W", "R", "F", "S", "U"];
|
|
2966
|
+
for (const day of days) {
|
|
2967
|
+
const blocks = sections.flatMap(
|
|
2968
|
+
(s) => s.meetings.filter((m) => m.days.includes(day)).map((m) => ({ s, m }))
|
|
2969
|
+
);
|
|
2970
|
+
blocks.sort((x, y) => x.m.startMin - y.m.startMin);
|
|
2971
|
+
for (let i = 0; i + 1 < blocks.length; i++) {
|
|
2972
|
+
const prev = blocks[i];
|
|
2973
|
+
const next = blocks[i + 1];
|
|
2974
|
+
if (prev.s.id === next.s.id) continue;
|
|
2975
|
+
if (!dateOverlap(prev.s, next.s)) continue;
|
|
2976
|
+
if (prev.m.endMin > next.m.startMin) continue;
|
|
2977
|
+
const gap = next.m.startMin - prev.m.endMin;
|
|
2978
|
+
const sameLoc = !!prev.m.locations[0] && !!next.m.locations[0] && prev.m.locations[0].toLowerCase() === next.m.locations[0].toLowerCase();
|
|
2979
|
+
const need = sameLoc ? 0 : walkNeed(prev.s.school, next.s.school);
|
|
2980
|
+
if (prev.s.school !== next.s.school && !sameLoc && (gap < need || gap <= passingMinutes)) {
|
|
2981
|
+
out.push({
|
|
2982
|
+
from: prev.s.id,
|
|
2983
|
+
to: next.s.id,
|
|
2984
|
+
from_school: prev.s.school,
|
|
2985
|
+
to_school: next.s.school,
|
|
2986
|
+
gap_minutes: gap,
|
|
2987
|
+
message: gap < need ? `TIGHT: only ~${gap}min from ${prev.s.school} to ${next.s.school} on ${day} (${minToStr(prev.m.endMin)} \u2192 ${minToStr(next.m.startMin)}), needs ~${need}min.` : `Cross-campus ${prev.s.school}\u2192${next.s.school} on ${day} with ~${gap}min gap.`
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
return out;
|
|
2993
|
+
}
|
|
2994
|
+
function totalCredits(sections) {
|
|
2995
|
+
return Math.round(sections.reduce((sum, s) => sum + s.credits, 0) * 100) / 100;
|
|
2996
|
+
}
|
|
2997
|
+
function weeklyGrid(sections) {
|
|
2998
|
+
const days = ["M", "T", "W", "R", "F", "S", "U"];
|
|
2999
|
+
const lines = [];
|
|
3000
|
+
for (const day of days) {
|
|
3001
|
+
const blocks = sections.flatMap(
|
|
3002
|
+
(s) => s.meetings.filter((m) => m.days.includes(day)).map((m) => ({ s, m }))
|
|
3003
|
+
);
|
|
3004
|
+
blocks.sort((a, b) => a.m.startMin - b.m.startMin);
|
|
3005
|
+
if (blocks.length === 0) {
|
|
3006
|
+
lines.push(`${day}: \u2014`);
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
const cells = blocks.map(
|
|
3010
|
+
(b) => `${minToStr(b.m.startMin)}-${minToStr(b.m.endMin)} ${b.s.code} (${b.s.school})`
|
|
3011
|
+
).join(" | ");
|
|
3012
|
+
lines.push(`${day}: ${cells}`);
|
|
3013
|
+
}
|
|
3014
|
+
return lines.join("\n");
|
|
3015
|
+
}
|
|
3016
|
+
function checkSchedule(sections, opts = {}) {
|
|
3017
|
+
const passing = opts.passingMinutes ?? 10;
|
|
3018
|
+
const { conflicts, warnings } = findConflicts(sections, passing);
|
|
3019
|
+
const travel = travelWarnings(sections, passing);
|
|
3020
|
+
const seat_problems = sections.filter((s) => s.school !== "BLOCK").filter((s) => !isRegistrable(s.status) || s.seats.available <= 0).map((s) => ({
|
|
3021
|
+
id: s.id,
|
|
3022
|
+
reason: !isRegistrable(s.status) ? `Closed by registrar (status ${s.status}; cap ${s.seats.total}, filled ${s.seats.filled})` : `No remaining capacity (status ${s.status}; filled ${s.seats.filled}/${s.seats.total})`
|
|
3023
|
+
}));
|
|
3024
|
+
const skipped_tba = sections.filter((s) => s.meetings.length === 0).map((s) => s.id);
|
|
3025
|
+
for (const s of sections) {
|
|
3026
|
+
if (s.potentialError) {
|
|
3027
|
+
warnings.push(
|
|
3028
|
+
`${s.id}: source data flagged potential_error; treat its times as unreliable.`
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
const hard = conflicts.filter((c) => c.severity === "hard");
|
|
3033
|
+
return {
|
|
3034
|
+
ok: hard.length === 0 && seat_problems.length === 0,
|
|
3035
|
+
complete: skipped_tba.length === 0 && sections.every((s) => !s.potentialError),
|
|
3036
|
+
skipped_tba,
|
|
3037
|
+
total_credits: totalCredits(sections),
|
|
3038
|
+
class_count: sections.length,
|
|
3039
|
+
conflicts,
|
|
3040
|
+
travel_warnings: travel,
|
|
3041
|
+
seat_problems,
|
|
3042
|
+
warnings,
|
|
3043
|
+
weekly_grid: weeklyGrid(sections)
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
var FilterError = class extends Error {
|
|
3047
|
+
code = "E_BAD_FILTER";
|
|
3048
|
+
hint;
|
|
3049
|
+
constructor(message, hint) {
|
|
3050
|
+
super(message);
|
|
3051
|
+
this.hint = hint;
|
|
3052
|
+
}
|
|
3053
|
+
};
|
|
3054
|
+
var KNOWN_SCHOOLS = [
|
|
3055
|
+
"PO",
|
|
3056
|
+
"CM",
|
|
3057
|
+
"HM",
|
|
3058
|
+
"SC",
|
|
3059
|
+
"PZ",
|
|
3060
|
+
"CG",
|
|
3061
|
+
"KS",
|
|
3062
|
+
"JP",
|
|
3063
|
+
"JT",
|
|
3064
|
+
"JM",
|
|
3065
|
+
"AF",
|
|
3066
|
+
"CH",
|
|
3067
|
+
"AA"
|
|
3068
|
+
];
|
|
3069
|
+
var SCHOOLS_HINT = `Valid: ${KNOWN_SCHOOLS.join(" ")} (codes or names like Pomona, CMC, Harvey Mudd).`;
|
|
3070
|
+
function normalizeFilters(raw) {
|
|
3071
|
+
const out = { normalized: {}, warnings: [] };
|
|
3072
|
+
const list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
3073
|
+
const schools = list(raw.school);
|
|
3074
|
+
if (schools.length > 0) {
|
|
3075
|
+
out.schools = schools.map((s) => {
|
|
3076
|
+
const code = normalizeSchool(s);
|
|
3077
|
+
if (!KNOWN_SCHOOLS.includes(code)) {
|
|
3078
|
+
throw new FilterError(`Unknown school '${s}'.`, SCHOOLS_HINT);
|
|
3079
|
+
}
|
|
3080
|
+
return code;
|
|
3081
|
+
});
|
|
3082
|
+
out.normalized.school = out.schools;
|
|
3083
|
+
}
|
|
3084
|
+
const depts = list(raw.dept);
|
|
3085
|
+
if (depts.length > 0) {
|
|
3086
|
+
out.departments = depts.map((d) => {
|
|
3087
|
+
const clean = d.trim().toUpperCase();
|
|
3088
|
+
if (!/^[A-Z]{2,4}$/.test(clean)) {
|
|
3089
|
+
throw new FilterError(
|
|
3090
|
+
`Bad --dept '${d}'.`,
|
|
3091
|
+
"Use 2\u20134 letter codes, e.g. --dept CSCI MATH."
|
|
3092
|
+
);
|
|
3093
|
+
}
|
|
3094
|
+
return clean;
|
|
3095
|
+
});
|
|
3096
|
+
out.normalized.dept = out.departments;
|
|
3097
|
+
}
|
|
3098
|
+
if (raw.days) {
|
|
3099
|
+
const days = normalizeDays(raw.days);
|
|
3100
|
+
if (!days) {
|
|
3101
|
+
throw new FilterError(
|
|
3102
|
+
`Unknown days '${raw.days}'.`,
|
|
3103
|
+
"Valid: M T W R F S U combos like MWF, TR, T/R, TuTh. R=Thursday."
|
|
3104
|
+
);
|
|
3105
|
+
}
|
|
3106
|
+
out.days = days;
|
|
3107
|
+
out.normalized.days = days.join("");
|
|
3108
|
+
}
|
|
3109
|
+
if (raw.after) {
|
|
3110
|
+
const min = parseTime(raw.after);
|
|
3111
|
+
if (min === null) {
|
|
3112
|
+
throw new FilterError(
|
|
3113
|
+
`Unparseable time '${raw.after}'.`,
|
|
3114
|
+
"Try --after 1pm, --after 13:00, --after 10:00am."
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
out.afterMin = min;
|
|
3118
|
+
out.normalized.after = raw.after;
|
|
3119
|
+
}
|
|
3120
|
+
if (raw.before) {
|
|
3121
|
+
const min = parseTime(raw.before);
|
|
3122
|
+
if (min === null) {
|
|
3123
|
+
throw new FilterError(
|
|
3124
|
+
`Unparseable time '${raw.before}'.`,
|
|
3125
|
+
"Try --before 5pm, --before 17:00."
|
|
3126
|
+
);
|
|
3127
|
+
}
|
|
3128
|
+
out.beforeMin = min;
|
|
3129
|
+
out.normalized.before = raw.before;
|
|
3130
|
+
}
|
|
3131
|
+
if (out.afterMin !== void 0 && out.beforeMin !== void 0 && out.afterMin > out.beforeMin) {
|
|
3132
|
+
throw new FilterError(
|
|
3133
|
+
`Inverted time window: after ${raw.after} but before ${raw.before}.`,
|
|
3134
|
+
"The window must satisfy after <= before."
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
if (raw.instructor) {
|
|
3138
|
+
out.instructor = raw.instructor.trim();
|
|
3139
|
+
out.normalized.instructor = out.instructor;
|
|
3140
|
+
}
|
|
3141
|
+
const areas = list(raw.area);
|
|
3142
|
+
if (areas.length > 0) {
|
|
3143
|
+
out.areas = areas.map((a) => a.trim().toUpperCase());
|
|
3144
|
+
out.normalized.area = out.areas;
|
|
3145
|
+
}
|
|
3146
|
+
if (raw.credits) {
|
|
3147
|
+
const m = raw.credits.match(/^(\d+(?:\.\d+)?)(?:\s*-\s*(\d+(?:\.\d+)?))?$/);
|
|
3148
|
+
if (!m) {
|
|
3149
|
+
throw new FilterError(
|
|
3150
|
+
`Unparseable credits '${raw.credits}'.`,
|
|
3151
|
+
"Try --credits 1 or --credits 0.5-1."
|
|
3152
|
+
);
|
|
3153
|
+
}
|
|
3154
|
+
out.creditsMin = Number.parseFloat(m[1]);
|
|
3155
|
+
out.creditsMax = m[2] ? Number.parseFloat(m[2]) : Number.parseFloat(m[1]);
|
|
3156
|
+
}
|
|
3157
|
+
if (raw.creditsMin !== void 0) out.creditsMin = raw.creditsMin;
|
|
3158
|
+
if (raw.creditsMax !== void 0) out.creditsMax = raw.creditsMax;
|
|
3159
|
+
if (raw.open !== void 0) out.openOnly = raw.open;
|
|
3160
|
+
if (raw.excludeConflictsWith && raw.excludeConflictsWith.length > 0) {
|
|
3161
|
+
out.excludeConflictIds = raw.excludeConflictsWith;
|
|
3162
|
+
}
|
|
3163
|
+
return out;
|
|
3164
|
+
}
|
|
3165
|
+
function matchesFilters(s, f) {
|
|
3166
|
+
if (f.excludeConflictIds && f.excludeConflictIds.length > 0 && f.excludeConflictIds.includes(s.id)) {
|
|
3167
|
+
return false;
|
|
3168
|
+
}
|
|
3169
|
+
if (f.schools && f.schools.length > 0 && !f.schools.includes(s.school))
|
|
3170
|
+
return false;
|
|
3171
|
+
if (f.departments && f.departments.length > 0 && !f.departments.includes(s.dept)) {
|
|
3172
|
+
return false;
|
|
3173
|
+
}
|
|
3174
|
+
if (f.codePrefix) {
|
|
3175
|
+
const flat = `${s.dept}${String(s.number).padStart(3, "0")}${s.suffix}`;
|
|
3176
|
+
if (!flat.startsWith(f.codePrefix.toUpperCase())) return false;
|
|
3177
|
+
}
|
|
3178
|
+
if (f.days && f.days.length > 0) {
|
|
3179
|
+
const sectionDays = new Set(s.meetings.flatMap((m) => m.days));
|
|
3180
|
+
if (s.meetings.length === 0) return false;
|
|
3181
|
+
for (const d of f.days) if (!sectionDays.has(d)) return false;
|
|
3182
|
+
}
|
|
3183
|
+
const afterMin = f.afterMin;
|
|
3184
|
+
if (afterMin !== void 0) {
|
|
3185
|
+
if (s.meetings.length === 0) return false;
|
|
3186
|
+
if (!s.meetings.every((m) => m.startMin >= afterMin)) return false;
|
|
3187
|
+
}
|
|
3188
|
+
const beforeMin = f.beforeMin;
|
|
3189
|
+
if (beforeMin !== void 0) {
|
|
3190
|
+
if (s.meetings.length === 0) return false;
|
|
3191
|
+
if (!s.meetings.every((m) => m.endMin <= beforeMin)) return false;
|
|
3192
|
+
}
|
|
3193
|
+
if (f.instructor) {
|
|
3194
|
+
const q = f.instructor.toLowerCase();
|
|
3195
|
+
if (!s.instructors.some((i) => i.toLowerCase().includes(q))) return false;
|
|
3196
|
+
}
|
|
3197
|
+
if (f.areas && f.areas.length > 0) {
|
|
3198
|
+
const upper = s.areas.map((a) => a.toUpperCase());
|
|
3199
|
+
if (!f.areas.some((a) => upper.includes(a))) return false;
|
|
3200
|
+
}
|
|
3201
|
+
if (f.creditsMin !== void 0 && s.credits < f.creditsMin) return false;
|
|
3202
|
+
if (f.creditsMax !== void 0 && s.credits > f.creditsMax) return false;
|
|
3203
|
+
if (f.openOnly && !isRegistrable(s.status)) return false;
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
function emptyHistory(codeQuery, note, provenance) {
|
|
3207
|
+
return {
|
|
3208
|
+
code: codeQuery.trim().toUpperCase().slice(0, 32),
|
|
3209
|
+
offerings: [],
|
|
3210
|
+
rotation_note: note,
|
|
3211
|
+
verdict: "No history found.",
|
|
3212
|
+
...provenance
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
function courseHistory(entries, sections, codeQuery, opts = {}) {
|
|
3216
|
+
const limit = opts.limit ?? 6;
|
|
3217
|
+
const provenance = {
|
|
3218
|
+
fetched_at: opts.fetched_at ?? "",
|
|
3219
|
+
stale: opts.stale ?? false,
|
|
3220
|
+
snapshot_term: opts.snapshot_term ?? "",
|
|
3221
|
+
enrichment: opts.enrichment ?? null
|
|
3222
|
+
};
|
|
3223
|
+
const parsed = parseCode(codeQuery);
|
|
3224
|
+
if (!parsed.dept && parsed.number === void 0) {
|
|
3225
|
+
return emptyHistory(
|
|
3226
|
+
codeQuery,
|
|
3227
|
+
`No offering history found for '${codeQuery}'. Try a course code like 'CSCI 151'.`,
|
|
3228
|
+
provenance
|
|
3229
|
+
);
|
|
3230
|
+
}
|
|
3231
|
+
if (parsed.number === void 0) {
|
|
3232
|
+
return emptyHistory(
|
|
3233
|
+
codeQuery,
|
|
3234
|
+
"History needs a course number, e.g. 'CSCI 151'. Department-only queries match too many courses.",
|
|
3235
|
+
provenance
|
|
3236
|
+
);
|
|
3237
|
+
}
|
|
3238
|
+
const matched = entries.filter(
|
|
3239
|
+
(e) => e.code.department.toUpperCase() === parsed.dept && e.code.courseNumber === parsed.number && (parsed.suffix ? (e.code.suffix ?? "").toUpperCase() === parsed.suffix : true)
|
|
3240
|
+
);
|
|
3241
|
+
if (matched.length === 0) {
|
|
3242
|
+
return emptyHistory(
|
|
3243
|
+
codeQuery,
|
|
3244
|
+
`No offering history found for '${codeQuery}'.`,
|
|
3245
|
+
provenance
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
const byAff = /* @__PURE__ */ new Map();
|
|
3249
|
+
for (const e of matched) {
|
|
3250
|
+
const aff = e.code.affiliation.toUpperCase();
|
|
3251
|
+
const list = byAff.get(aff) ?? [];
|
|
3252
|
+
list.push(e);
|
|
3253
|
+
byAff.set(aff, list);
|
|
3254
|
+
}
|
|
3255
|
+
const campusFilter = (opts.campuses ?? []).map(normalizeSchool);
|
|
3256
|
+
const wantedSchools = parsed.school ? [parsed.school] : campusFilter.length > 0 ? campusFilter : [...byAff.keys()];
|
|
3257
|
+
const available = wantedSchools.filter((s) => byAff.has(s));
|
|
3258
|
+
if (available.length === 0) {
|
|
3259
|
+
return emptyHistory(
|
|
3260
|
+
codeQuery,
|
|
3261
|
+
`No offering history for ${parsed.dept} ${String(parsed.number).padStart(3, "0")} at ${wantedSchools.join(", ")}; it exists at ${[...byAff.keys()].join(", ")}.`,
|
|
3262
|
+
provenance
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
const chosen = available.map((school) => {
|
|
3266
|
+
const list = [...byAff.get(school) ?? []].sort(
|
|
3267
|
+
(a, b) => b.terms.length - a.terms.length
|
|
3268
|
+
);
|
|
3269
|
+
return { school, entry: list[0] };
|
|
3270
|
+
});
|
|
3271
|
+
const byTerm = /* @__PURE__ */ new Map();
|
|
3272
|
+
for (const s of sections) {
|
|
3273
|
+
const group = byTerm.get(s.term) ?? [];
|
|
3274
|
+
group.push(s);
|
|
3275
|
+
byTerm.set(s.term, group);
|
|
3276
|
+
}
|
|
3277
|
+
const enrichFor = (school, termKey) => (byTerm.get(termKey) ?? []).filter(
|
|
3278
|
+
(s) => s.dept === parsed.dept && s.number === parsed.number && (!parsed.suffix || s.suffix === parsed.suffix) && s.school === school
|
|
3279
|
+
);
|
|
3280
|
+
const allTermKeys = chosen.flatMap(
|
|
3281
|
+
({ school, entry }) => entry.terms.map((t) => ({ school, key: `${t.term}${t.year}` }))
|
|
3282
|
+
);
|
|
3283
|
+
const code = `${parsed.dept} ${String(parsed.number).padStart(3, "0")}${parsed.suffix ?? ""}`;
|
|
3284
|
+
const sortedKeys = [...new Set(allTermKeys.map((x) => x.key))].sort(
|
|
3285
|
+
compareTermRecencyDesc
|
|
3286
|
+
);
|
|
3287
|
+
const offerings = [];
|
|
3288
|
+
for (const key of sortedKeys.slice(0, limit)) {
|
|
3289
|
+
for (const { school, entry } of chosen) {
|
|
3290
|
+
if (!entry.terms.some((t) => `${t.term}${t.year}` === key)) continue;
|
|
3291
|
+
const inTerm = enrichFor(school, key);
|
|
3292
|
+
const instructors = [...new Set(inTerm.flatMap((s) => s.instructors))];
|
|
3293
|
+
const first = inTerm[0];
|
|
3294
|
+
offerings.push({
|
|
3295
|
+
term: key,
|
|
3296
|
+
instructors,
|
|
3297
|
+
campus: first?.school ?? school,
|
|
3298
|
+
seats: first ? { capacity: first.seats.total, enrolled: first.seats.filled } : null
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
const windowKeys = [...new Set(allTermKeys.map((x) => x.key))].sort(
|
|
3303
|
+
compareTermRecencyDesc
|
|
3304
|
+
);
|
|
3305
|
+
const span = windowKeys.length > 1 ? `${termLabel(windowKeys[windowKeys.length - 1])}\u2013${termLabel(windowKeys[0])}` : termLabel(windowKeys[0] ?? "");
|
|
3306
|
+
const fa = windowKeys.filter((k) => k.startsWith("FA")).length;
|
|
3307
|
+
const sp = windowKeys.filter((k) => k.startsWith("SP")).length;
|
|
3308
|
+
const total = windowKeys.length;
|
|
3309
|
+
let cadence = "irregularly";
|
|
3310
|
+
if (fa > 0 && sp === 0) cadence = "fall-only";
|
|
3311
|
+
else if (sp > 0 && fa === 0) cadence = "spring-only";
|
|
3312
|
+
else if (fa > 0 && sp > 0) cadence = "both semesters";
|
|
3313
|
+
const shortWindow = total <= 2 ? " Short window; treat any rotation pattern as weak evidence." : "";
|
|
3314
|
+
if (chosen.length > 1) {
|
|
3315
|
+
const schools = chosen.map((c) => c.school).join(", ");
|
|
3316
|
+
return {
|
|
3317
|
+
code,
|
|
3318
|
+
offerings,
|
|
3319
|
+
rotation_note: `${code} has separate histories at ${schools}; offerings below are labeled by campus. Specify a school for a single-course verdict.`,
|
|
3320
|
+
verdict: `History exists at ${schools}; specify a school (e.g. '${code} ${chosen[0].school}') for a per-course verdict.`,
|
|
3321
|
+
...provenance
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
let verdict;
|
|
3325
|
+
if (fa > 0 && sp === 0) {
|
|
3326
|
+
verdict = `Fall-only in observed history (FA ${fa}, SP 0; window ${span}).${shortWindow}`;
|
|
3327
|
+
} else if (sp > 0 && fa === 0) {
|
|
3328
|
+
verdict = `Spring-only in observed history (SP ${sp}, FA 0; window ${span}).${shortWindow}`;
|
|
3329
|
+
} else if (sp > fa + 1) {
|
|
3330
|
+
verdict = `Usually spring (SP ${sp} vs FA ${fa} in window ${span}).${shortWindow}`;
|
|
3331
|
+
} else if (fa > sp + 1) {
|
|
3332
|
+
verdict = `Usually fall (FA ${fa} vs SP ${sp} in window ${span}).${shortWindow}`;
|
|
3333
|
+
} else {
|
|
3334
|
+
verdict = `Offered both semesters (FA ${fa}, SP ${sp} in window ${span}).${shortWindow}`;
|
|
3335
|
+
}
|
|
3336
|
+
const rotation_note = `Offered in ${total} term(s) across ${span} in available history (${cadence}: FA ${fa}, SP ${sp}). Instructors are only known for terms with a local snapshot.`;
|
|
3337
|
+
return { code, offerings, rotation_note, verdict, ...provenance };
|
|
3338
|
+
}
|
|
3339
|
+
function toDoc(s) {
|
|
3340
|
+
return {
|
|
3341
|
+
id: s.id,
|
|
3342
|
+
code: `${s.dept} ${String(s.number).padStart(3, "0")}${s.suffix} ${s.school}`,
|
|
3343
|
+
title: s.title,
|
|
3344
|
+
instructors: s.instructors.join(" "),
|
|
3345
|
+
areas: s.areas.join(" "),
|
|
3346
|
+
description: s.description
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
function codeAwareTokenize(text) {
|
|
3350
|
+
return text.toLowerCase().replace(/([a-z])(\d)/g, "$1 $2").split(/[^a-z0-9]+/).filter((t) => t.length > 0);
|
|
3351
|
+
}
|
|
3352
|
+
var BOOST = {
|
|
3353
|
+
code: 5,
|
|
3354
|
+
title: 3,
|
|
3355
|
+
instructors: 2.5,
|
|
3356
|
+
areas: 1.5,
|
|
3357
|
+
description: 1
|
|
3358
|
+
};
|
|
3359
|
+
var MiniSearchProvider = class {
|
|
3360
|
+
mini;
|
|
3361
|
+
byId = /* @__PURE__ */ new Map();
|
|
3362
|
+
docs = [];
|
|
3363
|
+
built = false;
|
|
3364
|
+
constructor() {
|
|
3365
|
+
this.mini = new MiniSearch({
|
|
3366
|
+
fields: ["code", "title", "instructors", "areas", "description"],
|
|
3367
|
+
storeFields: ["id", "code", "title"],
|
|
3368
|
+
searchOptions: {
|
|
3369
|
+
boost: BOOST,
|
|
3370
|
+
prefix: true,
|
|
3371
|
+
fuzzy: 0.3,
|
|
3372
|
+
combineWith: "AND"
|
|
3373
|
+
},
|
|
3374
|
+
tokenize: codeAwareTokenize,
|
|
3375
|
+
processTerm: (t) => t.toLowerCase()
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
build(sections) {
|
|
3379
|
+
this.setSections(sections);
|
|
3380
|
+
this.ensureIndex();
|
|
3381
|
+
}
|
|
3382
|
+
/** Cheap path: byId only, no MiniSearch build (~65-90ms saved). */
|
|
3383
|
+
setSections(sections) {
|
|
3384
|
+
this.byId = new Map(sections.map((s) => [s.id, s]));
|
|
3385
|
+
this.docs = sections.map(toDoc);
|
|
3386
|
+
this.built = false;
|
|
3387
|
+
this.mini.removeAll();
|
|
3388
|
+
}
|
|
3389
|
+
/** Build the MiniSearch index on first full-text need. Idempotent. */
|
|
3390
|
+
ensureIndex() {
|
|
3391
|
+
if (this.built) return;
|
|
3392
|
+
this.mini.removeAll();
|
|
3393
|
+
if (this.docs.length > 0) this.mini.addAll(this.docs);
|
|
3394
|
+
this.built = true;
|
|
3395
|
+
}
|
|
3396
|
+
get indexBuilt() {
|
|
3397
|
+
return this.built;
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* Candidate-union pipeline. Deterministic pre-passes (exact code, code
|
|
3401
|
+
* prefix, dept) contribute scored candidates; free-text queries union
|
|
3402
|
+
* title-phrase candidates with full-text results. Hard filters apply
|
|
3403
|
+
* ONCE over the union, so a filter narrower than a pre-pass can never
|
|
3404
|
+
* silently delete valid matches. Code-shaped queries stay strict: a
|
|
3405
|
+
* dept+number that matches nothing is "not found", never fuzzy soup.
|
|
3406
|
+
*/
|
|
3407
|
+
query(q, f) {
|
|
3408
|
+
const query = q.trim();
|
|
3409
|
+
const tokens = codeAwareTokenize(query);
|
|
3410
|
+
const parsed = parseCode(query);
|
|
3411
|
+
const all = [...this.byId.values()];
|
|
3412
|
+
const cand = /* @__PURE__ */ new Map();
|
|
3413
|
+
const add = (s, score, why) => {
|
|
3414
|
+
const cur = cand.get(s.id);
|
|
3415
|
+
if (!cur) {
|
|
3416
|
+
cand.set(s.id, { s, score, why: [...why] });
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
if (score > cur.score) cur.score = score;
|
|
3420
|
+
for (const w of why) if (!cur.why.includes(w)) cur.why.push(w);
|
|
3421
|
+
};
|
|
3422
|
+
const codeShaped = !!parsed.dept && parsed.number !== void 0;
|
|
3423
|
+
if (codeShaped) {
|
|
3424
|
+
const num = String(parsed.number).padStart(3, "0");
|
|
3425
|
+
const exact = all.filter(
|
|
3426
|
+
(s) => s.dept === parsed.dept && String(s.number).padStart(3, "0") === num && s.suffix === (parsed.suffix ?? "") && (!parsed.school || s.school === parsed.school)
|
|
3427
|
+
);
|
|
3428
|
+
if (exact.length > 0) {
|
|
3429
|
+
for (const s of exact) add(s, 100, ["exact-code"]);
|
|
3430
|
+
return this.finalize([...cand.values()], f, tokens);
|
|
3431
|
+
}
|
|
3432
|
+
const prefix = `${parsed.dept}${parsed.number}`.toLowerCase();
|
|
3433
|
+
const prefixed = all.filter(
|
|
3434
|
+
(s) => s.dept === parsed.dept && (!parsed.suffix || s.suffix === parsed.suffix) && (!parsed.school || s.school === parsed.school) && `${s.dept}${s.number}`.toLowerCase().startsWith(prefix)
|
|
3435
|
+
);
|
|
3436
|
+
if (prefixed.length > 0) {
|
|
3437
|
+
for (const s of prefixed) add(s, 50, ["code-prefix"]);
|
|
3438
|
+
return this.finalize([...cand.values()], f, tokens);
|
|
3439
|
+
}
|
|
3440
|
+
return this.finalize([], f, tokens);
|
|
3441
|
+
}
|
|
3442
|
+
if (parsed.dept && parsed.number === void 0) {
|
|
3443
|
+
const deptHit = all.filter(
|
|
3444
|
+
(s) => s.dept === parsed.dept && (!parsed.school || s.school === parsed.school)
|
|
3445
|
+
);
|
|
3446
|
+
for (const s of deptHit) add(s, 50, ["dept"]);
|
|
3447
|
+
}
|
|
3448
|
+
if (query.length === 0) {
|
|
3449
|
+
for (const s of all) add(s, 1, ["browse"]);
|
|
3450
|
+
return this.finalize([...cand.values()], f, tokens);
|
|
3451
|
+
}
|
|
3452
|
+
const phrase = query.replace(/^"|"$/g, "").toLowerCase();
|
|
3453
|
+
if (phrase.length >= 4) {
|
|
3454
|
+
for (const s of all.filter((x) => x.title.toLowerCase().includes(phrase)))
|
|
3455
|
+
add(s, 30, ["title-phrase"]);
|
|
3456
|
+
}
|
|
3457
|
+
this.ensureIndex();
|
|
3458
|
+
let results = this.mini.search(query);
|
|
3459
|
+
let orFallback = false;
|
|
3460
|
+
if (results.length === 0 && tokens.length > 1) {
|
|
3461
|
+
results = this.mini.search(query, { combineWith: "OR" });
|
|
3462
|
+
orFallback = true;
|
|
3463
|
+
}
|
|
3464
|
+
const ql = query.toLowerCase();
|
|
3465
|
+
for (const r of results) {
|
|
3466
|
+
const s = this.byId.get(r.id);
|
|
3467
|
+
if (!s) continue;
|
|
3468
|
+
const why = [];
|
|
3469
|
+
if (s.title.toLowerCase().includes(ql)) why.push(`title:${ql}`);
|
|
3470
|
+
if (s.dept.toLowerCase() === ql || s.code.toLowerCase().startsWith(ql)) {
|
|
3471
|
+
why.push(`code:${ql}`);
|
|
3472
|
+
}
|
|
3473
|
+
if (s.instructors.some((i) => i.toLowerCase().includes(ql))) {
|
|
3474
|
+
why.push(`instructor:${ql}`);
|
|
3475
|
+
}
|
|
3476
|
+
let score = r.score;
|
|
3477
|
+
if (orFallback) {
|
|
3478
|
+
const coverage = Object.keys(r.match ?? {}).length;
|
|
3479
|
+
score = r.score * 0.5 * (1 + coverage);
|
|
3480
|
+
why.push("or-fallback");
|
|
3481
|
+
}
|
|
3482
|
+
add(s, score, why.length > 0 ? why : ["text"]);
|
|
3483
|
+
}
|
|
3484
|
+
return this.finalize([...cand.values()], f, tokens);
|
|
3485
|
+
}
|
|
3486
|
+
finalize(scored, f, queryTokens = []) {
|
|
3487
|
+
const out = [];
|
|
3488
|
+
for (const { s, score, why } of scored) {
|
|
3489
|
+
if (!matchesFilters(s, f)) continue;
|
|
3490
|
+
let adj = score;
|
|
3491
|
+
if (queryTokens.length > 0) {
|
|
3492
|
+
const titleTokens = new Set(codeAwareTokenize(s.title));
|
|
3493
|
+
if (queryTokens.every((t) => titleTokens.has(t))) {
|
|
3494
|
+
adj += 8;
|
|
3495
|
+
why.push("title-words");
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
if (s.status === "O") adj += 5;
|
|
3499
|
+
else if (s.status === "C") adj -= 10;
|
|
3500
|
+
else if (s.status === "R") adj += 2;
|
|
3501
|
+
if (s.seats.available >= 3) adj += 3;
|
|
3502
|
+
else if (s.seats.available > 0) adj += 1;
|
|
3503
|
+
if (s.potentialError) adj -= 5;
|
|
3504
|
+
out.push({
|
|
3505
|
+
section: s,
|
|
3506
|
+
score: adj,
|
|
3507
|
+
match: why,
|
|
3508
|
+
fetched_at: "",
|
|
3509
|
+
stale: false
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
out.sort(
|
|
3513
|
+
(a, b) => b.score - a.score || b.section.seats.available - a.section.seats.available || a.section.id.localeCompare(b.section.id)
|
|
3514
|
+
);
|
|
3515
|
+
return out;
|
|
3516
|
+
}
|
|
3517
|
+
toJSON() {
|
|
3518
|
+
this.ensureIndex();
|
|
3519
|
+
return JSON.stringify({
|
|
3520
|
+
index: this.mini.toJSON(),
|
|
3521
|
+
ids: [...this.byId.keys()]
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
get size() {
|
|
3525
|
+
return this.byId.size;
|
|
3526
|
+
}
|
|
3527
|
+
};
|
|
3528
|
+
function mergedDays(s) {
|
|
3529
|
+
const order = ["M", "T", "W", "R", "F", "S", "U"];
|
|
3530
|
+
const set = new Set(s.meetings.flatMap((m) => m.days));
|
|
3531
|
+
return order.filter((d) => set.has(d)).join("");
|
|
3532
|
+
}
|
|
3533
|
+
function toCompactHit(h, fetched_at, explain, stale = false) {
|
|
3534
|
+
const s = h.section;
|
|
3535
|
+
return {
|
|
3536
|
+
id: s.id,
|
|
3537
|
+
term: s.term,
|
|
3538
|
+
school: s.school,
|
|
3539
|
+
code: s.code,
|
|
3540
|
+
section: String(s.sectionNo).padStart(2, "0"),
|
|
3541
|
+
title: s.title,
|
|
3542
|
+
instructors: s.instructors,
|
|
3543
|
+
// Summary across meetings; the per-block truth is in meetings[].
|
|
3544
|
+
days: mergedDays(s),
|
|
3545
|
+
start: s.meetings.length > 0 ? s.meetings[0].startStr : null,
|
|
3546
|
+
end: s.meetings.length > 0 ? s.meetings[0].endStr : null,
|
|
3547
|
+
meetings: s.meetings.length === 0 ? [] : s.meetings.map((m) => ({
|
|
3548
|
+
days: m.days.join(""),
|
|
3549
|
+
start: m.startStr,
|
|
3550
|
+
end: m.endStr,
|
|
3551
|
+
location: m.locations[0] ?? null
|
|
3552
|
+
})),
|
|
3553
|
+
credits: s.credits,
|
|
3554
|
+
potential_error: s.potentialError,
|
|
3555
|
+
seats_open: s.seats.available,
|
|
3556
|
+
seats_total: s.seats.total,
|
|
3557
|
+
status: s.status,
|
|
3558
|
+
match: {
|
|
3559
|
+
score: Math.round(h.score * 100) / 100,
|
|
3560
|
+
why: explain ? h.match : h.match.slice(0, 1)
|
|
3561
|
+
},
|
|
3562
|
+
fetched_at,
|
|
3563
|
+
stale
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
function paginate(hits, opts) {
|
|
3567
|
+
const total = hits.length;
|
|
3568
|
+
const slice = hits.slice(opts.offset, opts.offset + opts.limit);
|
|
3569
|
+
const has_more = opts.offset + opts.limit < total;
|
|
3570
|
+
const hint = total === 0 ? "No matches. Try fewer filters, a different term, or drop --open." : opts.offset >= total ? `Offset ${opts.offset} is past the end (total ${total}); re-run with --offset 0 or narrow filters.` : has_more ? `${total} hits, showing ${slice.length}. Re-run with --offset ${opts.offset + opts.limit} or narrow filters.` : null;
|
|
3571
|
+
return {
|
|
3572
|
+
term: opts.term,
|
|
3573
|
+
total,
|
|
3574
|
+
limit: opts.limit,
|
|
3575
|
+
offset: opts.offset,
|
|
3576
|
+
has_more,
|
|
3577
|
+
next_offset: has_more ? opts.offset + opts.limit : null,
|
|
3578
|
+
hits: slice.map(
|
|
3579
|
+
(h) => toCompactHit(h, opts.fetched_at, opts.explain, opts.stale)
|
|
3580
|
+
),
|
|
3581
|
+
hint,
|
|
3582
|
+
suggestions: opts.suggestions ?? [],
|
|
3583
|
+
fetched_at: opts.fetched_at,
|
|
3584
|
+
stale: opts.stale,
|
|
3585
|
+
snapshot_id: opts.snapshot_id ?? null
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
var providers = /* @__PURE__ */ new Map();
|
|
3589
|
+
function getProvider(term, sections, hash) {
|
|
3590
|
+
const key = `${term}:${hash}`;
|
|
3591
|
+
const hit = providers.get(key);
|
|
3592
|
+
if (hit) return hit;
|
|
3593
|
+
if (providers.size >= 4) {
|
|
3594
|
+
const oldest = providers.keys().next();
|
|
3595
|
+
if (!oldest.done) providers.delete(oldest.value);
|
|
3596
|
+
}
|
|
3597
|
+
const provider = new MiniSearchProvider();
|
|
3598
|
+
provider.setSections(sections);
|
|
3599
|
+
providers.set(key, provider);
|
|
3600
|
+
return provider;
|
|
3601
|
+
}
|
|
3602
|
+
function snapshotMeta(m) {
|
|
3603
|
+
return {
|
|
3604
|
+
fetched_at: m.fetched_at,
|
|
3605
|
+
stale: m.stale,
|
|
3606
|
+
quarantined: m.quarantined ?? 0,
|
|
3607
|
+
duplicate_ids: m.duplicate_ids ?? 0
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3610
|
+
function combinedMeta(metas) {
|
|
3611
|
+
const first = metas[0];
|
|
3612
|
+
if (!first) {
|
|
3613
|
+
return { fetched_at: "", stale: false, quarantined: 0, duplicate_ids: 0 };
|
|
3614
|
+
}
|
|
3615
|
+
return {
|
|
3616
|
+
fetched_at: first.fetched_at,
|
|
3617
|
+
stale: metas.some((m) => m.stale),
|
|
3618
|
+
quarantined: metas.reduce((n, m) => n + (m.quarantined ?? 0), 0),
|
|
3619
|
+
duplicate_ids: metas.reduce((n, m) => n + (m.duplicate_ids ?? 0), 0)
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
function badTermError(input) {
|
|
3623
|
+
return Object.assign(
|
|
3624
|
+
new Error(`Unparseable term '${input}'. Use a term like FA2026.`),
|
|
3625
|
+
{
|
|
3626
|
+
code: "E_BAD_TERM",
|
|
3627
|
+
hint: "Use a term like FA2026 \u2014 run list_terms to see available terms."
|
|
3628
|
+
}
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3631
|
+
function usageError(message, hint) {
|
|
3632
|
+
return Object.assign(new Error(message), { code: "E_USAGE", hint });
|
|
3633
|
+
}
|
|
3634
|
+
async function resolveTerm(raw, fetchFn) {
|
|
3635
|
+
if (raw === void 0) return defaultTerm(fetchFn);
|
|
3636
|
+
const norm = normalizeTerm(raw);
|
|
3637
|
+
if (!norm) throw badTermError(raw);
|
|
3638
|
+
return norm.toUpperCase();
|
|
3639
|
+
}
|
|
3640
|
+
async function loadTermStrict(term, fetchFn, allowFallback) {
|
|
3641
|
+
try {
|
|
3642
|
+
const value = await loadTerm(term, { fetchFn });
|
|
3643
|
+
return value;
|
|
3644
|
+
} catch (err) {
|
|
3645
|
+
const e = err;
|
|
3646
|
+
if (e.code !== "E_TERM_NOT_FOUND") throw err;
|
|
3647
|
+
if (!allowFallback) {
|
|
3648
|
+
try {
|
|
3649
|
+
const { available: available2 } = await listTerms({ limit: 8, fetchFn });
|
|
3650
|
+
e.available = available2.map(
|
|
3651
|
+
(t) => t.code
|
|
3652
|
+
);
|
|
3653
|
+
} catch {
|
|
3654
|
+
}
|
|
3655
|
+
e.hint = "Term not published upstream. Run list_terms for available terms, or opt into serving the newest available term.";
|
|
3656
|
+
throw e;
|
|
3657
|
+
}
|
|
3658
|
+
const { available } = await listTerms({ limit: 8, fetchFn });
|
|
3659
|
+
const served = available[0]?.code;
|
|
3660
|
+
if (!served || served === term) throw err;
|
|
3661
|
+
const value = await loadTerm(served, { fetchFn });
|
|
3662
|
+
return {
|
|
3663
|
+
...value,
|
|
3664
|
+
fallback: { requested: term, served },
|
|
3665
|
+
warning: `Term ${term} is not published; showing ${served} instead.`
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
function isCompleteIdentity(parsed) {
|
|
3670
|
+
return !!parsed.dept && parsed.number !== void 0 && !!parsed.school && parsed.sectionNo !== void 0;
|
|
3671
|
+
}
|
|
3672
|
+
function resolveInput(input, parsed, targetTerm, sections, strict) {
|
|
3673
|
+
if (isCompleteIdentity(parsed)) {
|
|
3674
|
+
const canonical = sectionId({
|
|
3675
|
+
dept: parsed.dept,
|
|
3676
|
+
number: parsed.number,
|
|
3677
|
+
suffix: parsed.suffix ?? "",
|
|
3678
|
+
school: parsed.school,
|
|
3679
|
+
sectionNo: parsed.sectionNo,
|
|
3680
|
+
term: targetTerm
|
|
3681
|
+
});
|
|
3682
|
+
const exact = sections.find((s) => s.id === canonical);
|
|
3683
|
+
return exact ? { section: exact } : {};
|
|
3684
|
+
}
|
|
3685
|
+
if (strict) return {};
|
|
3686
|
+
if (!parsed.dept && parsed.number === void 0) return {};
|
|
3687
|
+
const matches = sections.filter(
|
|
3688
|
+
(s) => (!parsed.dept || s.dept === parsed.dept) && (parsed.number === void 0 || s.number === parsed.number) && (!parsed.suffix || s.suffix === parsed.suffix) && (!parsed.school || s.school === parsed.school) && (parsed.sectionNo === void 0 || s.sectionNo === parsed.sectionNo)
|
|
3689
|
+
);
|
|
3690
|
+
if (matches.length === 1) return { section: matches[0] };
|
|
3691
|
+
if (matches.length > 1) return { ambiguous: matches.map((s) => s.id) };
|
|
3692
|
+
return {};
|
|
3693
|
+
}
|
|
3694
|
+
async function loadTermsForInputs(termKeys, fetchFn, allowFallback) {
|
|
3695
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
3696
|
+
const terms = [];
|
|
3697
|
+
let fallback;
|
|
3698
|
+
let warning;
|
|
3699
|
+
for (const t of termKeys) {
|
|
3700
|
+
const v = await loadTermStrict(t, fetchFn, allowFallback);
|
|
3701
|
+
if (v.fallback && !fallback) {
|
|
3702
|
+
fallback = v.fallback;
|
|
3703
|
+
warning = v.warning;
|
|
3704
|
+
}
|
|
3705
|
+
loaded.set(v.meta.term, { sections: v.sections, meta: v.meta });
|
|
3706
|
+
terms.push({
|
|
3707
|
+
term: v.meta.term,
|
|
3708
|
+
fetched_at: v.meta.fetched_at,
|
|
3709
|
+
stale: v.meta.stale
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
return { loaded, terms, ...fallback ? { fallback, warning } : {} };
|
|
3713
|
+
}
|
|
3714
|
+
function validateIdList(ids) {
|
|
3715
|
+
if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string")) {
|
|
3716
|
+
throw usageError(
|
|
3717
|
+
"Expected an array of section ID strings.",
|
|
3718
|
+
"Pass IDs like 'CSCI 005 HM-01 FA2026'."
|
|
3719
|
+
);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
async function searchClasses(query, opts = {}) {
|
|
3723
|
+
const filters = normalizeFilters({
|
|
3724
|
+
school: opts.schools,
|
|
3725
|
+
dept: opts.depts,
|
|
3726
|
+
days: opts.days,
|
|
3727
|
+
after: opts.after,
|
|
3728
|
+
before: opts.before,
|
|
3729
|
+
instructor: opts.instructor,
|
|
3730
|
+
area: opts.areas,
|
|
3731
|
+
credits: opts.credits,
|
|
3732
|
+
open: opts.open,
|
|
3733
|
+
excludeConflictsWith: opts.excludeConflictsWith
|
|
3734
|
+
});
|
|
3735
|
+
if (opts.code !== void 0 && opts.code.trim() !== "") {
|
|
3736
|
+
const parsed = parseCode(opts.code);
|
|
3737
|
+
if (!parsed.dept && parsed.number === void 0) {
|
|
3738
|
+
throw new FilterError(
|
|
3739
|
+
`Unparseable --code '${opts.code}'.`,
|
|
3740
|
+
"Try --code 'CSCI 051', --code CSCI, or --code 'CSCI 051 PO'."
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3743
|
+
if (parsed.dept) {
|
|
3744
|
+
let prefix = parsed.dept;
|
|
3745
|
+
if (parsed.numberRaw !== void 0) {
|
|
3746
|
+
prefix += parsed.numberRaw.length === 2 ? parsed.numberRaw : parsed.numberRaw.padStart(3, "0");
|
|
3747
|
+
}
|
|
3748
|
+
if (parsed.suffix) prefix += parsed.suffix;
|
|
3749
|
+
filters.codePrefix = prefix;
|
|
3750
|
+
if (parsed.school && !filters.schools) {
|
|
3751
|
+
filters.schools = [parsed.school];
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
const requested = await resolveTerm(opts.term, opts.fetchFn);
|
|
3756
|
+
const { sections, meta, fallback, warning } = await loadTermStrict(
|
|
3757
|
+
requested,
|
|
3758
|
+
opts.fetchFn,
|
|
3759
|
+
opts.allowTermFallback ?? false
|
|
3760
|
+
);
|
|
3761
|
+
const served = fallback?.served ?? requested;
|
|
3762
|
+
const provider = getProvider(served, sections, meta.hash);
|
|
3763
|
+
let hits = provider.query(query ?? "", filters);
|
|
3764
|
+
hits = sortHits(hits, opts.sort ?? "relevance");
|
|
3765
|
+
if (filters.excludeConflictIds && filters.excludeConflictIds.length > 0) {
|
|
3766
|
+
const anchors = filters.excludeConflictIds;
|
|
3767
|
+
const missing = anchors.filter((id) => !sections.some((s) => s.id === id));
|
|
3768
|
+
if (missing.length > 0) {
|
|
3769
|
+
throw Object.assign(
|
|
3770
|
+
new Error(
|
|
3771
|
+
`Unknown exclusion anchor(s): ${missing.join(", ")}. Anchors must be canonical IDs in the searched term.`
|
|
3772
|
+
),
|
|
3773
|
+
{
|
|
3774
|
+
code: "E_NOT_FOUND",
|
|
3775
|
+
not_found: missing,
|
|
3776
|
+
hint: "Resolve IDs via search first, then pass them verbatim."
|
|
3777
|
+
}
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
const anchored = sections.filter((s) => anchors.includes(s.id));
|
|
3781
|
+
hits = hits.filter(
|
|
3782
|
+
(h) => !anchors.includes(h.section.id) && !anchored.some(
|
|
3783
|
+
(a) => a.id !== h.section.id && sectionsOverlap(a, h.section)
|
|
3784
|
+
)
|
|
3785
|
+
);
|
|
3786
|
+
}
|
|
3787
|
+
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
|
|
3788
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
3789
|
+
const suggestions = recallSuggestions(query ?? "", filters, sections);
|
|
3790
|
+
if (resultWillBeEmptyCodeQuery(query ?? "", hits)) {
|
|
3791
|
+
const parsed = parseCode(query ?? "");
|
|
3792
|
+
const label = `${parsed.dept} ${String(parsed.number).padStart(3, "0")}${parsed.suffix ?? ""}`.trim();
|
|
3793
|
+
suggestions.unshift(
|
|
3794
|
+
`No ${label} in ${served} \u2014 check the course number, or browse with --dept ${parsed.dept} (departments:["${parsed.dept}"]).`
|
|
3795
|
+
);
|
|
3796
|
+
}
|
|
3797
|
+
const result = paginate(hits, {
|
|
3798
|
+
limit,
|
|
3799
|
+
offset,
|
|
3800
|
+
term: served,
|
|
3801
|
+
fetched_at: meta.fetched_at,
|
|
3802
|
+
stale: meta.stale,
|
|
3803
|
+
explain: opts.explain ?? false,
|
|
3804
|
+
snapshot_id: meta.hash.slice(0, 12),
|
|
3805
|
+
suggestions
|
|
3806
|
+
});
|
|
3807
|
+
return {
|
|
3808
|
+
result,
|
|
3809
|
+
normalized: { ...filters.normalized, term: served },
|
|
3810
|
+
meta: snapshotMeta(meta),
|
|
3811
|
+
...fallback ? { fallback, warning } : {}
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
var MAX_IDS = 20;
|
|
3815
|
+
async function getClasses(ids, opts = {}) {
|
|
3816
|
+
validateIdList(ids);
|
|
3817
|
+
if (ids.length === 0) {
|
|
3818
|
+
throw usageError(
|
|
3819
|
+
"get needs at least one ID.",
|
|
3820
|
+
"Pass 1-20 canonical section IDs."
|
|
3821
|
+
);
|
|
3822
|
+
}
|
|
3823
|
+
if (ids.length > MAX_IDS) {
|
|
3824
|
+
throw usageError(
|
|
3825
|
+
`Too many IDs (${ids.length}); the cap is ${MAX_IDS}.`,
|
|
3826
|
+
"Split the batch into multiple calls."
|
|
3827
|
+
);
|
|
3828
|
+
}
|
|
3829
|
+
const explicitTerm = opts.term !== void 0 ? await resolveTerm(opts.term, opts.fetchFn) : void 0;
|
|
3830
|
+
const inputs = ids.map((id) => ({ id, parsed: parseCode(id) }));
|
|
3831
|
+
const needDefault = explicitTerm === void 0 && inputs.some((x) => !x.parsed.term);
|
|
3832
|
+
const defTerm = needDefault ? await defaultTerm(opts.fetchFn) : null;
|
|
3833
|
+
const termKey = (x) => {
|
|
3834
|
+
const t = x.parsed.term ?? explicitTerm ?? defTerm;
|
|
3835
|
+
if (!t) throw badTermError("");
|
|
3836
|
+
return t;
|
|
3837
|
+
};
|
|
3838
|
+
const distinctTerms = [...new Set(inputs.map(termKey))];
|
|
3839
|
+
const { loaded, terms, fallback, warning } = await loadTermsForInputs(
|
|
3840
|
+
distinctTerms,
|
|
3841
|
+
opts.fetchFn,
|
|
3842
|
+
opts.allowTermFallback ?? false
|
|
3843
|
+
);
|
|
3844
|
+
const classes = [];
|
|
3845
|
+
const not_found = [];
|
|
3846
|
+
const ambiguous = [];
|
|
3847
|
+
const suggestions = [];
|
|
3848
|
+
for (const x of inputs) {
|
|
3849
|
+
const t = termKey(x);
|
|
3850
|
+
const { sections } = loaded.get(t) ?? { sections: [] };
|
|
3851
|
+
const res = resolveInput(x.id, x.parsed, t, sections, false);
|
|
3852
|
+
const section = res.section;
|
|
3853
|
+
if (section) {
|
|
3854
|
+
if (!classes.some((c) => c.id === section.id)) classes.push(section);
|
|
3855
|
+
continue;
|
|
3856
|
+
}
|
|
3857
|
+
if (res.ambiguous && res.ambiguous.length > 0) {
|
|
3858
|
+
ambiguous.push({ input: x.id, section_ids: res.ambiguous });
|
|
3859
|
+
continue;
|
|
3860
|
+
}
|
|
3861
|
+
not_found.push(x.id);
|
|
3862
|
+
const sug = suggestIds(sections, x.id);
|
|
3863
|
+
if (sug.length > 0) suggestions.push({ input: x.id, ids: sug });
|
|
3864
|
+
}
|
|
3865
|
+
return {
|
|
3866
|
+
result: { classes, not_found, ambiguous, suggestions, terms },
|
|
3867
|
+
meta: combinedMeta([...loaded.values()].map((v) => v.meta)),
|
|
3868
|
+
terms,
|
|
3869
|
+
...fallback ? { fallback, warning } : {}
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
async function checkScheduleByIds(ids, opts = {}) {
|
|
3873
|
+
validateIdList(ids);
|
|
3874
|
+
if (ids.length === 0) {
|
|
3875
|
+
throw usageError(
|
|
3876
|
+
"check needs at least one ID.",
|
|
3877
|
+
"Pass canonical section IDs, e.g. 'CSCI 005 HM-01 FA2026'."
|
|
3878
|
+
);
|
|
3879
|
+
}
|
|
3880
|
+
if (ids.length > MAX_IDS) {
|
|
3881
|
+
throw usageError(
|
|
3882
|
+
`Too many IDs (${ids.length}); the cap is ${MAX_IDS}.`,
|
|
3883
|
+
"Split the schedule into multiple checks."
|
|
3884
|
+
);
|
|
3885
|
+
}
|
|
3886
|
+
const explicitTerm = opts.term !== void 0 ? await resolveTerm(opts.term, opts.fetchFn) : void 0;
|
|
3887
|
+
const inputs = ids.map((id) => ({ id, parsed: parseCode(id) }));
|
|
3888
|
+
const needDefault = explicitTerm === void 0 && inputs.some((x) => !x.parsed.term);
|
|
3889
|
+
const defTerm = needDefault ? await defaultTerm(opts.fetchFn) : null;
|
|
3890
|
+
const termKey = (x) => {
|
|
3891
|
+
const t = x.parsed.term ?? explicitTerm ?? defTerm;
|
|
3892
|
+
if (!t) throw badTermError("");
|
|
3893
|
+
return t;
|
|
3894
|
+
};
|
|
3895
|
+
const distinctTerms = [...new Set(inputs.map(termKey))];
|
|
3896
|
+
const { loaded, terms, fallback, warning } = await loadTermsForInputs(
|
|
3897
|
+
distinctTerms,
|
|
3898
|
+
opts.fetchFn,
|
|
3899
|
+
opts.allowTermFallback ?? false
|
|
3900
|
+
);
|
|
3901
|
+
const resolved = [];
|
|
3902
|
+
const failed = [];
|
|
3903
|
+
for (const x of inputs) {
|
|
3904
|
+
const t = termKey(x);
|
|
3905
|
+
const { sections } = loaded.get(t) ?? { sections: [] };
|
|
3906
|
+
const res = resolveInput(x.id, x.parsed, t, sections, true);
|
|
3907
|
+
const section = res.section;
|
|
3908
|
+
if (section) {
|
|
3909
|
+
if (!resolved.some((s) => s.id === section.id)) resolved.push(section);
|
|
3910
|
+
} else {
|
|
3911
|
+
failed.push(x.id);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
if (failed.length > 0) {
|
|
3915
|
+
throw Object.assign(
|
|
3916
|
+
new Error(
|
|
3917
|
+
`Unknown or incomplete section ID(s): ${failed.join(", ")}. Schedule checks need full IDs (dept, number, school, section, term).`
|
|
3918
|
+
),
|
|
3919
|
+
{
|
|
3920
|
+
code: "E_NOT_FOUND",
|
|
3921
|
+
not_found: failed,
|
|
3922
|
+
hint: "Search first, then pass IDs like 'CSCI 005 HM-01 FA2026' verbatim."
|
|
3923
|
+
}
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3926
|
+
const resolvedTerms = [...new Set(resolved.map((s) => s.term))];
|
|
3927
|
+
if (resolvedTerms.length > 1) {
|
|
3928
|
+
throw usageError(
|
|
3929
|
+
`check is term-scoped; these IDs span ${resolvedTerms.join(", ")}.`,
|
|
3930
|
+
"Run one check per term."
|
|
3931
|
+
);
|
|
3932
|
+
}
|
|
3933
|
+
const result = checkSchedule(resolved, {
|
|
3934
|
+
passingMinutes: opts.passingMinutes
|
|
3935
|
+
});
|
|
3936
|
+
return {
|
|
3937
|
+
result,
|
|
3938
|
+
sections: resolved,
|
|
3939
|
+
resolved: resolved.map((s) => s.id),
|
|
3940
|
+
meta: combinedMeta([...loaded.values()].map((v) => v.meta)),
|
|
3941
|
+
terms,
|
|
3942
|
+
...fallback ? { fallback, warning } : {}
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
async function listTerms(opts = {}) {
|
|
3946
|
+
const res = await fetchAllTerms({}, opts.fetchFn);
|
|
3947
|
+
let current = "FA2026";
|
|
3948
|
+
try {
|
|
3949
|
+
const cur = await fetchCurrentTerm({}, opts.fetchFn);
|
|
3950
|
+
current = `${cur.data.term}${cur.data.year}`;
|
|
3951
|
+
} catch {
|
|
3952
|
+
const all = res.data.map((t) => `${t.term}${t.year}`).sort(compareTermRecencyDesc);
|
|
3953
|
+
current = all[0] ?? current;
|
|
3954
|
+
}
|
|
3955
|
+
const limit = opts.limit ?? 6;
|
|
3956
|
+
const infos = res.data.map((t) => {
|
|
3957
|
+
const code = `${t.term}${t.year}`;
|
|
3958
|
+
return {
|
|
3959
|
+
code,
|
|
3960
|
+
label: termLabel(code),
|
|
3961
|
+
year: t.year,
|
|
3962
|
+
term: t.term,
|
|
3963
|
+
is_current: code === current
|
|
3964
|
+
};
|
|
3965
|
+
}).sort((a, b) => compareTermRecencyDesc(a.code, b.code));
|
|
3966
|
+
const sliced = infos.slice(0, limit);
|
|
3967
|
+
if (!sliced.some((t) => t.is_current)) {
|
|
3968
|
+
const cur = infos.find((t) => t.is_current);
|
|
3969
|
+
if (cur) {
|
|
3970
|
+
if (sliced.length === limit) sliced.pop();
|
|
3971
|
+
sliced.unshift(cur);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
return { current, available: sliced };
|
|
3975
|
+
}
|
|
3976
|
+
async function getCourseHistory(code, opts = {}) {
|
|
3977
|
+
const requested = await resolveTerm(opts.term, opts.fetchFn);
|
|
3978
|
+
const { sections, meta, fallback, warning } = await loadTermStrict(
|
|
3979
|
+
requested,
|
|
3980
|
+
opts.fetchFn,
|
|
3981
|
+
opts.allowTermFallback ?? false
|
|
3982
|
+
);
|
|
3983
|
+
const served = fallback?.served ?? requested;
|
|
3984
|
+
const provenance = {
|
|
3985
|
+
// fetched_at/stale describe the HISTORY source; enrichment describes
|
|
3986
|
+
// the sections snapshot. Two sources, two clocks.
|
|
3987
|
+
snapshot_term: served,
|
|
3988
|
+
enrichment: {
|
|
3989
|
+
term: served,
|
|
3990
|
+
fetched_at: meta.fetched_at,
|
|
3991
|
+
stale: meta.stale
|
|
3992
|
+
}
|
|
3993
|
+
};
|
|
3994
|
+
let entries = [];
|
|
3995
|
+
let historyFreshness = { fetched_at: "", stale: false };
|
|
3996
|
+
try {
|
|
3997
|
+
const h = await loadHistory(served, { fetchFn: opts.fetchFn });
|
|
3998
|
+
entries = h.entries;
|
|
3999
|
+
historyFreshness = { fetched_at: h.fetched_at, stale: h.stale };
|
|
4000
|
+
} catch (err) {
|
|
4001
|
+
if (err.code !== "E_TERM_NOT_FOUND") throw err;
|
|
4002
|
+
}
|
|
4003
|
+
const result = courseHistory(entries, sections, code, {
|
|
4004
|
+
campuses: opts.campuses,
|
|
4005
|
+
limit: opts.limit,
|
|
4006
|
+
fetched_at: historyFreshness.fetched_at,
|
|
4007
|
+
stale: historyFreshness.stale,
|
|
4008
|
+
...provenance
|
|
4009
|
+
});
|
|
4010
|
+
if (result.offerings.length > 0)
|
|
4011
|
+
return { ...result, ...fallback ? { fallback, warning } : {} };
|
|
4012
|
+
try {
|
|
4013
|
+
const { available } = await listTerms({ limit: 8, fetchFn: opts.fetchFn });
|
|
4014
|
+
const seen = /* @__PURE__ */ new Set([served]);
|
|
4015
|
+
for (const t of available.map((x) => x.code)) {
|
|
4016
|
+
if (seen.has(t)) continue;
|
|
4017
|
+
seen.add(t);
|
|
4018
|
+
try {
|
|
4019
|
+
const {
|
|
4020
|
+
entries: e2,
|
|
4021
|
+
fetched_at: hf,
|
|
4022
|
+
stale: hs
|
|
4023
|
+
} = await loadHistory(t, {
|
|
4024
|
+
fetchFn: opts.fetchFn
|
|
4025
|
+
});
|
|
4026
|
+
const probe = courseHistory(e2, [], code, {
|
|
4027
|
+
campuses: opts.campuses,
|
|
4028
|
+
limit: opts.limit,
|
|
4029
|
+
fetched_at: hf,
|
|
4030
|
+
stale: hs
|
|
4031
|
+
});
|
|
4032
|
+
if (probe.offerings.length > 0) {
|
|
4033
|
+
const { sections: s2, meta: m2 } = await loadTerm(t, {
|
|
4034
|
+
fetchFn: opts.fetchFn
|
|
4035
|
+
});
|
|
4036
|
+
const r2 = courseHistory(e2, s2, code, {
|
|
4037
|
+
campuses: opts.campuses,
|
|
4038
|
+
limit: opts.limit,
|
|
4039
|
+
fetched_at: hf,
|
|
4040
|
+
stale: hs,
|
|
4041
|
+
snapshot_term: t,
|
|
4042
|
+
enrichment: {
|
|
4043
|
+
term: t,
|
|
4044
|
+
fetched_at: m2.fetched_at,
|
|
4045
|
+
stale: m2.stale
|
|
4046
|
+
}
|
|
4047
|
+
});
|
|
4048
|
+
return {
|
|
4049
|
+
...r2,
|
|
4050
|
+
rotation_note: `${r2.rotation_note} (Not offered in ${served}; history resolved via ${t}.)`,
|
|
4051
|
+
...fallback ? { fallback, warning } : {}
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
} catch {
|
|
4055
|
+
continue;
|
|
4056
|
+
}
|
|
4057
|
+
if (seen.size >= 5) break;
|
|
4058
|
+
}
|
|
4059
|
+
} catch {
|
|
4060
|
+
}
|
|
4061
|
+
return { ...result, ...fallback ? { fallback, warning } : {} };
|
|
4062
|
+
}
|
|
4063
|
+
async function defaultTerm(fetchFn) {
|
|
4064
|
+
const now = Date.now();
|
|
4065
|
+
const mem2 = getCurrentTermMem();
|
|
4066
|
+
if (mem2 && now - mem2.at < CURRENT_TERM_TTL_MS) {
|
|
4067
|
+
return mem2.value;
|
|
4068
|
+
}
|
|
4069
|
+
let staleFile = null;
|
|
4070
|
+
try {
|
|
4071
|
+
const file = await readCurrentTermFile();
|
|
4072
|
+
if (file) {
|
|
4073
|
+
if (now - file.at < CURRENT_TERM_TTL_MS) {
|
|
4074
|
+
setCurrentTermMem(file.value, file.at);
|
|
4075
|
+
return file.value;
|
|
4076
|
+
}
|
|
4077
|
+
staleFile = file.value;
|
|
4078
|
+
}
|
|
4079
|
+
} catch {
|
|
4080
|
+
}
|
|
4081
|
+
try {
|
|
4082
|
+
const cur = await fetchCurrentTerm({}, fetchFn);
|
|
4083
|
+
const value = `${cur.data.term}${cur.data.year}`;
|
|
4084
|
+
await writeCurrentTermFile(value);
|
|
4085
|
+
return value;
|
|
4086
|
+
} catch {
|
|
4087
|
+
const known = mem2?.value ?? staleFile;
|
|
4088
|
+
if (known) return known;
|
|
4089
|
+
throw Object.assign(
|
|
4090
|
+
new Error(
|
|
4091
|
+
"Cannot determine the current term: network unavailable and no cached value."
|
|
4092
|
+
),
|
|
4093
|
+
{
|
|
4094
|
+
code: "E_NO_CURRENT_TERM",
|
|
4095
|
+
hint: "Pass an explicit term like --term FA2026, or run `5c terms` once while online."
|
|
4096
|
+
}
|
|
4097
|
+
);
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
function suggestIds(sections, id, n = 3) {
|
|
4101
|
+
const parsed = parseCode(id);
|
|
4102
|
+
const scored = sections.map((s) => {
|
|
4103
|
+
let score = 0;
|
|
4104
|
+
if (parsed.dept && s.dept === parsed.dept) score += 3;
|
|
4105
|
+
if (parsed.number !== void 0 && s.number === parsed.number) score += 3;
|
|
4106
|
+
if (parsed.school && s.school === parsed.school) score += 2;
|
|
4107
|
+
return { s, score };
|
|
4108
|
+
});
|
|
4109
|
+
return scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score || a.s.id.localeCompare(b.s.id)).slice(0, n).map((x) => x.s.id);
|
|
4110
|
+
}
|
|
4111
|
+
function sortHits(hits, sort) {
|
|
4112
|
+
const arr = [...hits];
|
|
4113
|
+
if (sort === "time") {
|
|
4114
|
+
arr.sort(
|
|
4115
|
+
(a, b) => earliestStart(a.section) - earliestStart(b.section) || a.section.id.localeCompare(b.section.id)
|
|
4116
|
+
);
|
|
4117
|
+
} else if (sort === "seats") {
|
|
4118
|
+
arr.sort(
|
|
4119
|
+
(a, b) => b.section.seats.available - a.section.seats.available || a.section.id.localeCompare(b.section.id)
|
|
4120
|
+
);
|
|
4121
|
+
} else if (sort === "code") {
|
|
4122
|
+
arr.sort((a, b) => a.section.id.localeCompare(b.section.id));
|
|
4123
|
+
}
|
|
4124
|
+
return arr;
|
|
4125
|
+
}
|
|
4126
|
+
function earliestStart(s) {
|
|
4127
|
+
const starts = s.meetings.map((m) => m.startMin);
|
|
4128
|
+
return starts.length > 0 ? Math.min(...starts) : Number.MAX_SAFE_INTEGER;
|
|
4129
|
+
}
|
|
4130
|
+
var DEPT_NAMES = [
|
|
4131
|
+
["computer science", "CSCI"],
|
|
4132
|
+
["mathematics", "MATH"],
|
|
4133
|
+
["economics", "ECON"],
|
|
4134
|
+
["psychology", "PSYC"],
|
|
4135
|
+
["physics", "PHYS"],
|
|
4136
|
+
["chemistry", "CHEM"],
|
|
4137
|
+
["biology", "BIOL"],
|
|
4138
|
+
["english", "ENGL"],
|
|
4139
|
+
["history", "HIST"],
|
|
4140
|
+
["philosophy", "PHIL"],
|
|
4141
|
+
["sociology", "SOC"],
|
|
4142
|
+
["anthropology", "ANTH"],
|
|
4143
|
+
["neuroscience", "NEUR"],
|
|
4144
|
+
["astronomy", "ASTR"],
|
|
4145
|
+
["geology", "GEOL"],
|
|
4146
|
+
["politics", "POLI"],
|
|
4147
|
+
["statistics", "MATH"],
|
|
4148
|
+
["ethics", "PHIL"],
|
|
4149
|
+
["arabic", "ARBC"],
|
|
4150
|
+
["spanish", "SPAN"],
|
|
4151
|
+
["portuguese", "PORT"],
|
|
4152
|
+
["film", "MS"],
|
|
4153
|
+
["media", "MS"],
|
|
4154
|
+
["music", "MUS"],
|
|
4155
|
+
["math", "MATH"],
|
|
4156
|
+
["art", "ART"]
|
|
4157
|
+
];
|
|
4158
|
+
function resultWillBeEmptyCodeQuery(query, hits) {
|
|
4159
|
+
if (hits.length > 0) return false;
|
|
4160
|
+
const parsed = parseCode(query);
|
|
4161
|
+
return !!parsed.dept && parsed.number !== void 0;
|
|
4162
|
+
}
|
|
4163
|
+
function recallSuggestions(query, filters, sections) {
|
|
4164
|
+
const out = [];
|
|
4165
|
+
const padded = ` ${query.toLowerCase().trim()} `;
|
|
4166
|
+
if (padded.trim().length === 0) return out;
|
|
4167
|
+
const asCode = parseCode(query);
|
|
4168
|
+
const codeShaped = !!asCode.dept && asCode.number !== void 0;
|
|
4169
|
+
if (!codeShaped && (!filters.departments || filters.departments.length === 0)) {
|
|
4170
|
+
for (const [name, code] of DEPT_NAMES) {
|
|
4171
|
+
if (padded.includes(` ${name} `) && sections.some((s) => s.dept === code)) {
|
|
4172
|
+
out.push(
|
|
4173
|
+
`'${query.trim()}' matches department ${code} \u2014 retry with --dept ${code} (departments:["${code}"]) for full recall.`
|
|
4174
|
+
);
|
|
4175
|
+
break;
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
if (!filters.schools || filters.schools.length === 0) {
|
|
4180
|
+
const mentioned = detectSchoolMention(query);
|
|
4181
|
+
if (mentioned) {
|
|
4182
|
+
out.push(
|
|
4183
|
+
`Query mentions a college \u2014 retry with --school ${mentioned} to narrow, or leave blank for all 5Cs.`
|
|
4184
|
+
);
|
|
4185
|
+
}
|
|
4186
|
+
}
|
|
4187
|
+
return out;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
// src/server.ts
|
|
4191
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4192
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4193
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4194
|
+
|
|
4195
|
+
// src/errors.ts
|
|
4196
|
+
var RETRYABLE = /* @__PURE__ */ new Set(["E_FETCH", "E_NO_CURRENT_TERM"]);
|
|
4197
|
+
function recoveryOf(e) {
|
|
4198
|
+
switch (e.code) {
|
|
4199
|
+
case "E_TERM_NOT_FOUND":
|
|
4200
|
+
case "E_BAD_TERM":
|
|
4201
|
+
return e.available?.length ? `Recovery: call list_terms and retry with one of ${e.available.join(", ")}, or pass allow_term_fallback: true to serve the newest.` : "Recovery: call list_terms and retry with an available term.";
|
|
4202
|
+
case "E_NO_CURRENT_TERM":
|
|
4203
|
+
return "Recovery: pass an explicit term; retry once the network is reachable.";
|
|
4204
|
+
case "E_NOT_FOUND":
|
|
4205
|
+
return "Recovery: search_classes to resolve the ID, then pass it verbatim. Loose or partial IDs are never guessed at.";
|
|
4206
|
+
case "E_BAD_FILTER":
|
|
4207
|
+
case "E_USAGE":
|
|
4208
|
+
return "Recovery: fix the named argument and retry; nothing was fetched.";
|
|
4209
|
+
default:
|
|
4210
|
+
return RETRYABLE.has(e.code ?? "") ? "Recovery: retry once. If it fails again, report what you have and its timestamp." : "Recovery: report the failure; do not substitute another term's data.";
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
function toolError(err) {
|
|
4214
|
+
const e = err ?? {};
|
|
4215
|
+
const parts = [e.message ?? String(err)];
|
|
4216
|
+
if (e.not_found?.length) parts.push(`Unresolved: ${e.not_found.join(", ")}.`);
|
|
4217
|
+
if (e.hint && e.hint !== e.message) parts.push(e.hint);
|
|
4218
|
+
parts.push(recoveryOf(e));
|
|
4219
|
+
if (e.code) parts.push(`(code: ${e.code})`);
|
|
4220
|
+
return {
|
|
4221
|
+
isError: true,
|
|
4222
|
+
content: [{ type: "text", text: parts.join(" ") }]
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// src/schemas.ts
|
|
4227
|
+
import { z as z2 } from "zod";
|
|
4228
|
+
var SCHOOLS = [
|
|
4229
|
+
"PO",
|
|
4230
|
+
"CM",
|
|
4231
|
+
"CMC",
|
|
4232
|
+
"HM",
|
|
4233
|
+
"SC",
|
|
4234
|
+
"PZ",
|
|
4235
|
+
"CG",
|
|
4236
|
+
"KS",
|
|
4237
|
+
"JP",
|
|
4238
|
+
"JT",
|
|
4239
|
+
"JM",
|
|
4240
|
+
"AF",
|
|
4241
|
+
"CH",
|
|
4242
|
+
"AA"
|
|
4243
|
+
];
|
|
4244
|
+
var DAYS = ["M", "T", "W", "R", "F", "S", "U"];
|
|
4245
|
+
var SORTS = ["relevance", "time", "seats", "code"];
|
|
4246
|
+
var fallbackSchema = z2.object({
|
|
4247
|
+
requested: z2.string(),
|
|
4248
|
+
served: z2.string()
|
|
4249
|
+
}).strict();
|
|
4250
|
+
var termProvenanceSchema = z2.object({
|
|
4251
|
+
term: z2.string(),
|
|
4252
|
+
fetched_at: z2.string(),
|
|
4253
|
+
stale: z2.boolean()
|
|
4254
|
+
}).strict();
|
|
4255
|
+
var ingestFields = {
|
|
4256
|
+
fetched_at: z2.string().describe("When this process obtained the snapshot."),
|
|
4257
|
+
stale: z2.boolean().describe("Snapshot past the seat TTL; treat seats as indicative."),
|
|
4258
|
+
quarantined: z2.number().int().describe("Rows dropped at ingest. Nonzero means incomplete coverage."),
|
|
4259
|
+
duplicate_ids: z2.number().int().describe("Rows collapsed onto an existing canonical ID at ingest.")
|
|
4260
|
+
};
|
|
4261
|
+
var searchInputSchema = z2.object({
|
|
4262
|
+
query: z2.string().optional().describe(
|
|
4263
|
+
"Free text over title/description/code/instructor. Empty = browse with filters."
|
|
4264
|
+
),
|
|
4265
|
+
term: z2.string().optional().describe(
|
|
4266
|
+
"Term code, e.g. 'FA2026'. Defaults to current. Call list_terms if unsure."
|
|
4267
|
+
),
|
|
4268
|
+
schools: z2.array(z2.enum(SCHOOLS)).optional().describe(
|
|
4269
|
+
"Filter to colleges (5Cs + joint: KS JP JT JM AF CH AA; CG Claremont Graduate). CM is canonical for Claremont McKenna; CMC accepted as alias."
|
|
4270
|
+
),
|
|
4271
|
+
departments: z2.array(z2.string()).optional().describe("Dept codes, e.g. ['ECON','CSCI']. Case-insensitive."),
|
|
4272
|
+
code: z2.string().optional().describe(
|
|
4273
|
+
"Course-code prefix. 'CSCI 051' matches that course; 'CSCI 05' matches the 050-059 family; 'CSCI' matches the department."
|
|
4274
|
+
),
|
|
4275
|
+
days: z2.array(z2.enum(DAYS)).optional().describe(
|
|
4276
|
+
"Must meet on ALL listed days, within the whole time window. R=Thursday."
|
|
4277
|
+
),
|
|
4278
|
+
starts_after: z2.string().optional().describe(
|
|
4279
|
+
"Earliest start 24h 'HH:MM', e.g. '13:00'. Applies to every meeting of a hit."
|
|
4280
|
+
),
|
|
4281
|
+
ends_before: z2.string().optional().describe(
|
|
4282
|
+
"Latest end 24h 'HH:MM'. An inverted window (start after end) is an error, not an empty result."
|
|
4283
|
+
),
|
|
4284
|
+
instructor: z2.string().optional().describe("Instructor name substring."),
|
|
4285
|
+
course_areas: z2.array(z2.string()).optional().describe(
|
|
4286
|
+
"GE/area codes as the registrar writes them, e.g. ['QR']. Codes are per-college; a code from one campus does not apply at another."
|
|
4287
|
+
),
|
|
4288
|
+
availability: z2.enum(["any", "has_seat"]).default("any").describe(
|
|
4289
|
+
"has_seat = registrar status says registrable (Open/Reopened). Computed capacity is reported, not used as eligibility."
|
|
4290
|
+
),
|
|
4291
|
+
credits_min: z2.number().min(0).max(99).optional().describe("Minimum credits; pairs with credits_max as a range."),
|
|
4292
|
+
credits_max: z2.number().min(0).max(99).optional().describe("Maximum credits; pairs with credits_min as a range."),
|
|
4293
|
+
sort: z2.enum(SORTS).default("relevance").describe(
|
|
4294
|
+
"Result order: relevance (default), time (earliest first), seats (most open first), code."
|
|
4295
|
+
),
|
|
4296
|
+
explain: z2.boolean().default(false).describe("Include multi-signal match explanations per hit."),
|
|
4297
|
+
exclude_conflicts_with: z2.array(z2.string()).optional().describe(
|
|
4298
|
+
"Canonical section IDs in the searched term; overlapping hits are dropped. An unresolvable anchor is an error, never a silent no-op."
|
|
4299
|
+
),
|
|
4300
|
+
allow_term_fallback: z2.boolean().default(false).describe(
|
|
4301
|
+
"When the requested term is unpublished, serve the newest available term instead and say so. Default false: an unpublished term is an error naming the available terms."
|
|
4302
|
+
),
|
|
4303
|
+
limit: z2.number().int().min(1).max(50).default(10),
|
|
4304
|
+
offset: z2.number().int().min(0).default(0)
|
|
4305
|
+
}).strict();
|
|
4306
|
+
var getInputSchema = z2.object({
|
|
4307
|
+
ids: z2.array(z2.string()).min(1).max(20).describe("Canonical section IDs, e.g. 'CSCI 051 PO-01 FA2026'."),
|
|
4308
|
+
term: z2.string().optional().describe("Term hint when IDs carry no term."),
|
|
4309
|
+
allow_term_fallback: z2.boolean().default(false)
|
|
4310
|
+
}).strict();
|
|
4311
|
+
var checkInputSchema = z2.object({
|
|
4312
|
+
ids: z2.array(z2.string()).min(1).max(20).describe(
|
|
4313
|
+
"Section IDs to audit together. Every ID must resolve exactly; unknown IDs fail the whole call."
|
|
4314
|
+
),
|
|
4315
|
+
term: z2.string().optional().describe("Term code, e.g. 'FA2026'."),
|
|
4316
|
+
passing_minutes: z2.number().int().min(0).max(60).default(10).describe("Minimum acceptable gap between consecutive classes."),
|
|
4317
|
+
allow_term_fallback: z2.boolean().default(false)
|
|
4318
|
+
}).strict();
|
|
4319
|
+
var termsInputSchema = z2.object({
|
|
4320
|
+
limit: z2.number().int().min(1).max(20).default(6).describe("Most recent N terms.")
|
|
4321
|
+
}).strict();
|
|
4322
|
+
var historyInputSchema = z2.object({
|
|
4323
|
+
code: z2.string().describe("Course code without section, e.g. 'ECON 101', 'CSCI 151'."),
|
|
4324
|
+
schools: z2.array(z2.enum(SCHOOLS)).optional().describe(
|
|
4325
|
+
"Filter to campuses. CM is canonical for Claremont McKenna; CMC accepted as alias."
|
|
4326
|
+
),
|
|
4327
|
+
limit: z2.number().int().min(1).max(12).default(6),
|
|
4328
|
+
term: z2.string().optional().describe(
|
|
4329
|
+
"Term code anchoring instructor/seat enrichment, e.g. 'FA2026'. Defaults to current."
|
|
4330
|
+
),
|
|
4331
|
+
allow_term_fallback: z2.boolean().default(false)
|
|
4332
|
+
}).strict();
|
|
4333
|
+
var meetingBlockSchema = z2.object({
|
|
4334
|
+
days: z2.string().describe("Merged day letters for one meeting block."),
|
|
4335
|
+
start: z2.string().describe("'HH:MM'"),
|
|
4336
|
+
end: z2.string().describe("'HH:MM'"),
|
|
4337
|
+
location: z2.string().nullable()
|
|
4338
|
+
}).strict();
|
|
4339
|
+
var compactHitSchema = z2.object({
|
|
4340
|
+
id: z2.string(),
|
|
4341
|
+
term: z2.string(),
|
|
4342
|
+
school: z2.string(),
|
|
4343
|
+
code: z2.string(),
|
|
4344
|
+
section: z2.string(),
|
|
4345
|
+
title: z2.string(),
|
|
4346
|
+
instructors: z2.array(z2.string()),
|
|
4347
|
+
days: z2.string().describe('Merged across meetings; "" when TBA.'),
|
|
4348
|
+
start: z2.string().nullable(),
|
|
4349
|
+
end: z2.string().nullable(),
|
|
4350
|
+
meetings: z2.array(meetingBlockSchema).describe("Per-block truth; the summary above may combine blocks."),
|
|
4351
|
+
credits: z2.number(),
|
|
4352
|
+
potential_error: z2.boolean().describe("Source data flagged unreliable; verify before relying on it."),
|
|
4353
|
+
seats_open: z2.number(),
|
|
4354
|
+
seats_total: z2.number(),
|
|
4355
|
+
status: z2.string().describe("O=Open C=Closed R=Reopened U=unknown."),
|
|
4356
|
+
match: z2.object({ score: z2.number(), why: z2.array(z2.string()) }).strict(),
|
|
4357
|
+
fetched_at: z2.string(),
|
|
4358
|
+
stale: z2.boolean()
|
|
4359
|
+
}).strict();
|
|
4360
|
+
var searchOutputSchema = z2.object({
|
|
4361
|
+
term: z2.string(),
|
|
4362
|
+
total_count: z2.number(),
|
|
4363
|
+
has_more: z2.boolean(),
|
|
4364
|
+
next_offset: z2.number().nullable(),
|
|
4365
|
+
hits: z2.array(compactHitSchema),
|
|
4366
|
+
hint: z2.string().nullable(),
|
|
4367
|
+
suggestions: z2.array(z2.string()),
|
|
4368
|
+
snapshot_id: z2.string().nullable().describe("Compare across pages: a change means the snapshot rotated."),
|
|
4369
|
+
applied_filters: z2.record(z2.string(), z2.unknown()).describe("Filters as resolved, including the serving term."),
|
|
4370
|
+
seats_note: z2.string(),
|
|
4371
|
+
...ingestFields,
|
|
4372
|
+
fallback: fallbackSchema.optional(),
|
|
4373
|
+
warning: z2.string().optional()
|
|
4374
|
+
}).strict();
|
|
4375
|
+
var getOutputSchema = z2.object({
|
|
4376
|
+
classes: z2.array(sectionSchema),
|
|
4377
|
+
not_found: z2.array(z2.string()),
|
|
4378
|
+
ambiguous: z2.array(
|
|
4379
|
+
z2.object({ input: z2.string(), section_ids: z2.array(z2.string()) }).strict()
|
|
4380
|
+
).describe("Loose inputs matching several sections; ask the user which."),
|
|
4381
|
+
suggestions: z2.array(
|
|
4382
|
+
z2.object({ input: z2.string(), ids: z2.array(z2.string()) }).strict()
|
|
4383
|
+
),
|
|
4384
|
+
terms: z2.array(termProvenanceSchema).describe("One entry per snapshot used, in resolution order."),
|
|
4385
|
+
...ingestFields,
|
|
4386
|
+
fallback: fallbackSchema.optional(),
|
|
4387
|
+
warning: z2.string().optional()
|
|
4388
|
+
}).strict();
|
|
4389
|
+
var checkOutputSchema = z2.object({
|
|
4390
|
+
ok: z2.boolean().describe("No hard conflicts and no closed sections."),
|
|
4391
|
+
complete: z2.boolean().describe(
|
|
4392
|
+
"False when some inputs could not be fully audited (TBA times, flagged data). ok:true never implies a complete audit."
|
|
4393
|
+
),
|
|
4394
|
+
skipped_tba: z2.array(z2.string()).describe("Sections with unknown meeting times; not conflict-checked."),
|
|
4395
|
+
total_credits: z2.number(),
|
|
4396
|
+
class_count: z2.number(),
|
|
4397
|
+
conflicts: z2.array(
|
|
4398
|
+
z2.object({
|
|
4399
|
+
a: z2.string(),
|
|
4400
|
+
b: z2.string(),
|
|
4401
|
+
days: z2.array(z2.string()),
|
|
4402
|
+
overlap: z2.string(),
|
|
4403
|
+
overlap_minutes: z2.number(),
|
|
4404
|
+
severity: z2.enum(["hard", "tight"])
|
|
4405
|
+
}).strict()
|
|
4406
|
+
),
|
|
4407
|
+
travel_warnings: z2.array(
|
|
4408
|
+
z2.object({
|
|
4409
|
+
from: z2.string(),
|
|
4410
|
+
to: z2.string(),
|
|
4411
|
+
from_school: z2.string(),
|
|
4412
|
+
to_school: z2.string(),
|
|
4413
|
+
gap_minutes: z2.number(),
|
|
4414
|
+
message: z2.string()
|
|
4415
|
+
}).strict()
|
|
4416
|
+
),
|
|
4417
|
+
seat_problems: z2.array(
|
|
4418
|
+
z2.object({ id: z2.string(), reason: z2.string() }).strict()
|
|
4419
|
+
),
|
|
4420
|
+
warnings: z2.array(z2.string()),
|
|
4421
|
+
weekly_grid: z2.string(),
|
|
4422
|
+
checked: z2.array(z2.string()).describe("Section IDs that were audited."),
|
|
4423
|
+
terms: z2.array(termProvenanceSchema),
|
|
4424
|
+
...ingestFields,
|
|
4425
|
+
fallback: fallbackSchema.optional(),
|
|
4426
|
+
warning: z2.string().optional()
|
|
4427
|
+
}).strict();
|
|
4428
|
+
var termsOutputSchema = z2.object({
|
|
4429
|
+
current_term: z2.string(),
|
|
4430
|
+
terms: z2.array(
|
|
4431
|
+
z2.object({
|
|
4432
|
+
code: z2.string(),
|
|
4433
|
+
label: z2.string(),
|
|
4434
|
+
year: z2.number(),
|
|
4435
|
+
term: z2.string(),
|
|
4436
|
+
is_current: z2.boolean()
|
|
4437
|
+
}).strict()
|
|
4438
|
+
)
|
|
4439
|
+
}).strict();
|
|
4440
|
+
var historyOutputSchema = z2.object({
|
|
4441
|
+
code: z2.string(),
|
|
4442
|
+
offerings: z2.array(
|
|
4443
|
+
z2.object({
|
|
4444
|
+
term: z2.string(),
|
|
4445
|
+
instructors: z2.array(z2.string()),
|
|
4446
|
+
campus: z2.string(),
|
|
4447
|
+
seats: z2.object({ capacity: z2.number(), enrolled: z2.number() }).strict().nullable()
|
|
4448
|
+
}).strict()
|
|
4449
|
+
),
|
|
4450
|
+
rotation_note: z2.string().describe("Describes the window actually observed."),
|
|
4451
|
+
verdict: z2.string(),
|
|
4452
|
+
fetched_at: z2.string().describe("Freshness of the offering-history source."),
|
|
4453
|
+
stale: z2.boolean(),
|
|
4454
|
+
snapshot_term: z2.string(),
|
|
4455
|
+
enrichment: z2.object({
|
|
4456
|
+
term: z2.string(),
|
|
4457
|
+
fetched_at: z2.string(),
|
|
4458
|
+
stale: z2.boolean()
|
|
4459
|
+
}).strict().nullable().optional().describe("Freshness of the sections snapshot used for seats."),
|
|
4460
|
+
fallback: fallbackSchema.optional(),
|
|
4461
|
+
warning: z2.string().optional()
|
|
4462
|
+
}).strict();
|
|
4463
|
+
|
|
4464
|
+
// src/text.ts
|
|
4465
|
+
function provenanceLines(p) {
|
|
4466
|
+
const out = [];
|
|
4467
|
+
if (p.fallback) {
|
|
4468
|
+
out.push(
|
|
4469
|
+
`Note: term ${p.fallback.requested} is not published; showing ${p.fallback.served}.`
|
|
4470
|
+
);
|
|
4471
|
+
} else if (p.warning) {
|
|
4472
|
+
out.push(`Note: ${p.warning}`);
|
|
4473
|
+
}
|
|
4474
|
+
const stale = p.stale ? " (STALE)" : "";
|
|
4475
|
+
out.push(
|
|
4476
|
+
`Seats as of ${p.fetched_at}${stale} \u2014 verify in CX before enrolling.`
|
|
4477
|
+
);
|
|
4478
|
+
if (p.quarantined > 0) {
|
|
4479
|
+
out.push(
|
|
4480
|
+
`Coverage warning: ${p.quarantined} row(s) were dropped at ingest \u2014 results may be incomplete.`
|
|
4481
|
+
);
|
|
4482
|
+
}
|
|
4483
|
+
if (p.duplicate_ids > 0) {
|
|
4484
|
+
out.push(
|
|
4485
|
+
`Coverage note: ${p.duplicate_ids} row(s) collapsed onto an existing ID at ingest (same dept/number/school/section).`
|
|
4486
|
+
);
|
|
4487
|
+
}
|
|
4488
|
+
return out;
|
|
4489
|
+
}
|
|
4490
|
+
function moreLine(has_more, next_offset) {
|
|
4491
|
+
if (!has_more || next_offset === null) return null;
|
|
4492
|
+
return `More results: call again with offset ${next_offset}.`;
|
|
4493
|
+
}
|
|
4494
|
+
function searchText(p) {
|
|
4495
|
+
const lines = [];
|
|
4496
|
+
if (p.total_count === 0) {
|
|
4497
|
+
lines.push(`No matches in ${p.term}.`);
|
|
4498
|
+
lines.push(`Filters applied: ${JSON.stringify(p.applied_filters)}.`);
|
|
4499
|
+
} else {
|
|
4500
|
+
lines.push(
|
|
4501
|
+
`${p.total_count} match(es) in ${p.term} (showing ${p.hits.length}, as of ${p.fetched_at}).`
|
|
4502
|
+
);
|
|
4503
|
+
for (const h of p.hits) {
|
|
4504
|
+
const seats = `${h.seats_open}/${h.seats_total} open`;
|
|
4505
|
+
const credits = h.credits > 0 ? `, ${h.credits} cr` : "";
|
|
4506
|
+
const flagged = h.potential_error ? " [source data flagged]" : "";
|
|
4507
|
+
lines.push(
|
|
4508
|
+
`- ${h.id} \u2014 ${h.title} [${h.status}${credits}, ${seats}]${flagged}`
|
|
4509
|
+
);
|
|
4510
|
+
const meets = h.meetings.length > 0 ? h.meetings.map(
|
|
4511
|
+
(m) => `${m.days || "TBA"} ${m.start}-${m.end}${m.location ? ` @ ${m.location}` : ""}`
|
|
4512
|
+
).join("; ") : "TBA (no scheduled meeting time)";
|
|
4513
|
+
lines.push(` Meets: ${meets}`);
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
if (p.hint) lines.push(p.hint);
|
|
4517
|
+
const more = moreLine(p.has_more, p.next_offset);
|
|
4518
|
+
if (more) lines.push(more);
|
|
4519
|
+
for (const s of p.suggestions) lines.push(`Suggestion: ${s}`);
|
|
4520
|
+
if (p.snapshot_id)
|
|
4521
|
+
lines.push(
|
|
4522
|
+
`Snapshot: ${p.snapshot_id} (same id across pages = same data).`
|
|
4523
|
+
);
|
|
4524
|
+
lines.push(...provenanceLines(p));
|
|
4525
|
+
return lines.join("\n");
|
|
4526
|
+
}
|
|
4527
|
+
function getText(p) {
|
|
4528
|
+
const lines = [];
|
|
4529
|
+
for (const amb of p.ambiguous) {
|
|
4530
|
+
lines.push(
|
|
4531
|
+
`AMBIGUOUS: '${amb.input}' matches ${amb.section_ids.length} sections. Ask the user which, or pass a full ID: ${amb.section_ids.join(" | ")}`
|
|
4532
|
+
);
|
|
4533
|
+
}
|
|
4534
|
+
if (p.not_found.length > 0) {
|
|
4535
|
+
const sug = p.suggestions.filter((s) => p.not_found.includes(s.input)).map((s) => `${s.input} \u2192 ${s.ids.join(", ")}`).join("; ");
|
|
4536
|
+
lines.push(
|
|
4537
|
+
`NOT FOUND: ${p.not_found.join(", ")}.${sug ? ` Closest: ${sug}.` : ""} Search first, then pass IDs verbatim.`
|
|
4538
|
+
);
|
|
4539
|
+
}
|
|
4540
|
+
for (const c of p.classes) {
|
|
4541
|
+
const meets = c.meetings.length > 0 ? c.meetings.map(
|
|
4542
|
+
(m) => `${m.days.join("")} ${m.start12}\u2013${m.end12} @ ${m.locations.join("; ") || "TBA"}`
|
|
4543
|
+
).join("\n ") : "TBA";
|
|
4544
|
+
lines.push(
|
|
4545
|
+
`- ${c.id} \u2014 ${c.title} [${c.statusLabel}] ${c.credits} credit(s)`,
|
|
4546
|
+
` Meets: ${meets}`,
|
|
4547
|
+
` Seats: ${c.seats.available} open (${c.seats.filled}/${c.seats.total}; ${c.seats.permCount} instructor permits, not waitlist)`,
|
|
4548
|
+
` Instructors: ${c.instructors.join(", ") || "staff TBD"}`,
|
|
4549
|
+
` Areas: ${c.areas.join(", ") || "\u2014"} | Dates: ${c.startDate} \u2192 ${c.endDate}`,
|
|
4550
|
+
` ${c.description.split("\n")[0] ?? ""}`
|
|
4551
|
+
);
|
|
4552
|
+
if (c.potentialError) {
|
|
4553
|
+
lines.push(` Warning: source data for ${c.id} is flagged unreliable.`);
|
|
4554
|
+
}
|
|
4555
|
+
if (p.terms.length > 1) {
|
|
4556
|
+
const t = p.terms.find((x) => x.term === c.term);
|
|
4557
|
+
if (t?.stale) lines.push(` Note: ${c.term} snapshot is stale.`);
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
for (const t of p.terms) {
|
|
4561
|
+
lines.push(`${t.term}: as of ${t.fetched_at}${t.stale ? " (STALE)" : ""}`);
|
|
4562
|
+
}
|
|
4563
|
+
lines.push(...provenanceLines(p));
|
|
4564
|
+
return lines.join("\n");
|
|
4565
|
+
}
|
|
4566
|
+
function checkText(p) {
|
|
4567
|
+
const lines = [];
|
|
4568
|
+
const completeness = p.complete ? "audit complete" : `audit INCOMPLETE \u2014 ${p.skipped_tba.length} section(s) have unknown meeting times`;
|
|
4569
|
+
lines.push(
|
|
4570
|
+
p.ok ? `OK: no hard conflicts and no closed sections (${p.class_count} classes, ${p.total_credits} credits; ${completeness}).` : `NOT OK (${p.class_count} classes, ${p.total_credits} credits; ${completeness}).`
|
|
4571
|
+
);
|
|
4572
|
+
if (p.skipped_tba.length > 0) {
|
|
4573
|
+
lines.push(
|
|
4574
|
+
`Not checked (TBA times): ${p.skipped_tba.join(", ")}. TBA is absence of evidence, not evidence of a free slot.`
|
|
4575
|
+
);
|
|
4576
|
+
}
|
|
4577
|
+
if (p.conflicts.length > 0) {
|
|
4578
|
+
lines.push(
|
|
4579
|
+
`Conflicts: ${p.conflicts.map(
|
|
4580
|
+
(c) => `[${c.severity}] ${c.a} vs ${c.b} (${c.days.join("")} ${c.overlap})`
|
|
4581
|
+
).join("; ")}.`
|
|
4582
|
+
);
|
|
4583
|
+
} else {
|
|
4584
|
+
lines.push("No time conflicts among the sections with known times.");
|
|
4585
|
+
}
|
|
4586
|
+
for (const t of p.travel_warnings) lines.push(`Travel: ${t.message}`);
|
|
4587
|
+
if (p.seat_problems.length > 0) {
|
|
4588
|
+
lines.push(
|
|
4589
|
+
`Seats: ${p.seat_problems.map((s) => `${s.id} \u2014 ${s.reason}`).join("; ")}.`
|
|
4590
|
+
);
|
|
4591
|
+
}
|
|
4592
|
+
for (const w of p.warnings) lines.push(`Warning: ${w}`);
|
|
4593
|
+
lines.push(`Checked: ${p.checked.join(", ")}`);
|
|
4594
|
+
lines.push(...provenanceLines(p));
|
|
4595
|
+
return lines.join("\n");
|
|
4596
|
+
}
|
|
4597
|
+
function termsText(p) {
|
|
4598
|
+
return `Current: ${p.current_term}. Terms: ${p.terms.map((t) => `${t.code} (${t.label})`).join(", ")}.`;
|
|
4599
|
+
}
|
|
4600
|
+
function historyText(p) {
|
|
4601
|
+
const lines = [];
|
|
4602
|
+
lines.push(`${p.code}: ${p.verdict}`);
|
|
4603
|
+
lines.push(p.rotation_note);
|
|
4604
|
+
if (p.offerings.length === 0) {
|
|
4605
|
+
lines.push("No prior offerings found in the history source.");
|
|
4606
|
+
} else {
|
|
4607
|
+
for (const o of p.offerings) {
|
|
4608
|
+
const seats = o.seats ? `${o.seats.enrolled}/${o.seats.capacity} enrolled` : "seats unknown";
|
|
4609
|
+
lines.push(
|
|
4610
|
+
`- ${o.term} @ ${o.campus} \u2014 ${o.instructors.join(", ") || "staff TBD"} (${seats})`
|
|
4611
|
+
);
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
lines.push(`History as of ${p.fetched_at}${p.stale ? " (STALE)" : ""}.`);
|
|
4615
|
+
if (p.enrichment) {
|
|
4616
|
+
lines.push(
|
|
4617
|
+
`Seats enriched from ${p.enrichment.term} snapshot as of ${p.enrichment.fetched_at}${p.enrichment.stale ? " (STALE)" : ""}.`
|
|
4618
|
+
);
|
|
4619
|
+
}
|
|
4620
|
+
if (p.fallback) {
|
|
4621
|
+
lines.push(
|
|
4622
|
+
`Note: term ${p.fallback.requested} is not published; showing ${p.fallback.served}.`
|
|
4623
|
+
);
|
|
4624
|
+
} else if (p.warning) {
|
|
4625
|
+
lines.push(`Note: ${p.warning}`);
|
|
4626
|
+
}
|
|
4627
|
+
return lines.join("\n");
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
// src/server.ts
|
|
4631
|
+
var VERSION = packageVersion();
|
|
4632
|
+
var SEATS_NOTE = "Seats are a snapshot; verify in CX before enrolling.";
|
|
4633
|
+
var READONLY = { readOnlyHint: true, openWorldHint: true };
|
|
4634
|
+
var MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
4635
|
+
function packageVersion() {
|
|
4636
|
+
try {
|
|
4637
|
+
const pkg = JSON.parse(
|
|
4638
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
4639
|
+
);
|
|
4640
|
+
return pkg.version ?? "0.0.0";
|
|
4641
|
+
} catch {
|
|
4642
|
+
return "0.0.0";
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
var INSTRUCTIONS = `5C course search (Pomona, CM/Claremont McKenna, Harvey Mudd, Scripps, Pitzer).
|
|
4646
|
+
Workflow: list_terms if the term is vague -> search_classes (limit 10) -> get_classes to expand -> check_schedule to validate.
|
|
4647
|
+
Page with offset/next_offset/has_more (+total_count); don't widen queries. Seats are snapshots: always state fetched_at ("as of ..."). Never promise enrollment.
|
|
4648
|
+
Honesty rules: an unpublished term is an error, not a substitution, unless you pass allow_term_fallback. check_schedule ok:true does not mean complete:true \u2014 TBA sections are not checked. Report quarantined > 0 as incomplete coverage.`;
|
|
4649
|
+
function createMcpServer() {
|
|
4650
|
+
const server = new McpServer(
|
|
4651
|
+
{ name: "5c-index", version: VERSION },
|
|
4652
|
+
{ instructions: INSTRUCTIONS }
|
|
4653
|
+
);
|
|
4654
|
+
registerTools(server);
|
|
4655
|
+
return server;
|
|
4656
|
+
}
|
|
4657
|
+
function okPayload(payload) {
|
|
4658
|
+
return { structuredContent: payload };
|
|
4659
|
+
}
|
|
4660
|
+
function registerTools(server) {
|
|
4661
|
+
server.registerTool(
|
|
4662
|
+
"search_classes",
|
|
4663
|
+
{
|
|
4664
|
+
title: "Search 5C Classes",
|
|
4665
|
+
description: `Search current/upcoming 5C course offerings by keyword, department, campus, time, instructor, seats. USE for all discovery queries ('CS classes at Mudd Tuesday afternoons with open seats'). Do NOT use to validate a fixed schedule (use check_schedule) or for full details (use get_classes with returned IDs). Trust registrar status (O/C/R), not seat arithmetic: availability:"has_seat" means the registrar says registrable. TBA sections never match day/time filters. Unknown parameters are rejected \u2014 use availability (not open_only) and instructor singular (not instructors). Example: {"query":"microeconomics","schools":["PO","CM"],"term":"FA2026","availability":"has_seat","limit":10}.`,
|
|
4666
|
+
inputSchema: searchInputSchema,
|
|
4667
|
+
outputSchema: searchOutputSchema,
|
|
4668
|
+
annotations: { ...READONLY }
|
|
4669
|
+
},
|
|
4670
|
+
async (args) => {
|
|
4671
|
+
try {
|
|
4672
|
+
const credits = args.credits_min !== void 0 || args.credits_max !== void 0 ? `${args.credits_min ?? 0}-${args.credits_max ?? 999}` : void 0;
|
|
4673
|
+
const { result, normalized, meta, fallback, warning } = await searchClasses(args.query ?? "", {
|
|
4674
|
+
term: args.term,
|
|
4675
|
+
schools: args.schools,
|
|
4676
|
+
depts: args.departments,
|
|
4677
|
+
code: args.code,
|
|
4678
|
+
days: args.days?.join(""),
|
|
4679
|
+
after: args.starts_after,
|
|
4680
|
+
before: args.ends_before,
|
|
4681
|
+
instructor: args.instructor,
|
|
4682
|
+
areas: args.course_areas,
|
|
4683
|
+
open: args.availability === "has_seat" ? true : void 0,
|
|
4684
|
+
credits,
|
|
4685
|
+
sort: args.sort,
|
|
4686
|
+
explain: args.explain,
|
|
4687
|
+
excludeConflictsWith: args.exclude_conflicts_with,
|
|
4688
|
+
limit: args.limit,
|
|
4689
|
+
offset: args.offset,
|
|
4690
|
+
allowTermFallback: args.allow_term_fallback
|
|
4691
|
+
});
|
|
4692
|
+
const payload = {
|
|
4693
|
+
term: result.term,
|
|
4694
|
+
total_count: result.total,
|
|
4695
|
+
has_more: result.has_more,
|
|
4696
|
+
next_offset: result.next_offset,
|
|
4697
|
+
hits: result.hits,
|
|
4698
|
+
hint: result.hint,
|
|
4699
|
+
suggestions: result.suggestions,
|
|
4700
|
+
snapshot_id: result.snapshot_id,
|
|
4701
|
+
applied_filters: normalized,
|
|
4702
|
+
seats_note: SEATS_NOTE,
|
|
4703
|
+
fetched_at: meta.fetched_at,
|
|
4704
|
+
stale: meta.stale,
|
|
4705
|
+
quarantined: meta.quarantined,
|
|
4706
|
+
duplicate_ids: meta.duplicate_ids,
|
|
4707
|
+
...fallback ? { fallback } : {},
|
|
4708
|
+
...warning ? { warning } : {}
|
|
4709
|
+
};
|
|
4710
|
+
return {
|
|
4711
|
+
content: [{ type: "text", text: searchText(payload) }],
|
|
4712
|
+
...okPayload(payload)
|
|
4713
|
+
};
|
|
4714
|
+
} catch (err) {
|
|
4715
|
+
return toolError(err);
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
);
|
|
4719
|
+
server.registerTool(
|
|
4720
|
+
"get_classes",
|
|
4721
|
+
{
|
|
4722
|
+
title: "Get Class Details",
|
|
4723
|
+
description: 'Fetch full details for 1-20 class IDs from a prior search_classes call (description, seats, all meeting blocks, areas, dates). USE to expand hits before presenting or before check_schedule. Do NOT use for discovery \u2014 requires IDs. Unknown IDs come back in not_found with suggestions; loose IDs that match several sections come back in ambiguous (ask the user, never pick for them). Example: {"ids":["CSCI 051 PO-01 FA2026"]}.',
|
|
4724
|
+
inputSchema: getInputSchema,
|
|
4725
|
+
outputSchema: getOutputSchema,
|
|
4726
|
+
annotations: { ...READONLY }
|
|
4727
|
+
},
|
|
4728
|
+
async (args) => {
|
|
4729
|
+
try {
|
|
4730
|
+
const { result, meta, fallback, warning } = await getClasses(args.ids, {
|
|
4731
|
+
term: args.term,
|
|
4732
|
+
allowTermFallback: args.allow_term_fallback
|
|
4733
|
+
});
|
|
4734
|
+
const payload = {
|
|
4735
|
+
classes: result.classes,
|
|
4736
|
+
not_found: result.not_found,
|
|
4737
|
+
ambiguous: result.ambiguous,
|
|
4738
|
+
suggestions: result.suggestions,
|
|
4739
|
+
terms: result.terms,
|
|
4740
|
+
fetched_at: meta.fetched_at,
|
|
4741
|
+
stale: meta.stale,
|
|
4742
|
+
quarantined: meta.quarantined,
|
|
4743
|
+
duplicate_ids: meta.duplicate_ids,
|
|
4744
|
+
...fallback ? { fallback } : {},
|
|
4745
|
+
...warning ? { warning } : {}
|
|
4746
|
+
};
|
|
4747
|
+
const nothingResolved = payload.classes.length === 0;
|
|
4748
|
+
return {
|
|
4749
|
+
content: [{ type: "text", text: getText(payload) }],
|
|
4750
|
+
...okPayload(payload),
|
|
4751
|
+
...nothingResolved ? { isError: true } : {}
|
|
4752
|
+
};
|
|
4753
|
+
} catch (err) {
|
|
4754
|
+
return toolError(err);
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
);
|
|
4758
|
+
server.registerTool(
|
|
4759
|
+
"check_schedule",
|
|
4760
|
+
{
|
|
4761
|
+
title: "Check Schedule Conflicts",
|
|
4762
|
+
description: `Audit 1-20 class IDs for time conflicts, cross-campus travel risk, credit totals, and closed sections. USE whenever the user asks 'can I take A+B?', 'does this schedule work?'. Do NOT use for finding classes. The server does all time math \u2014 never compute overlaps yourself. Read complete/skipped_tba: TBA sections are not checked, so ok:true with complete:false is a partial answer. Half-semester sections only conflict on overlapping dates. Example: {"ids":["CSCI 051 PO-01 FA2026","MATH 031 PO-02 FA2026"],"term":"FA2026"}.`,
|
|
4763
|
+
inputSchema: checkInputSchema,
|
|
4764
|
+
outputSchema: checkOutputSchema,
|
|
4765
|
+
annotations: { ...READONLY }
|
|
4766
|
+
},
|
|
4767
|
+
async (args) => {
|
|
4768
|
+
try {
|
|
4769
|
+
const { result, resolved, meta, terms, fallback, warning } = await checkScheduleByIds(args.ids, {
|
|
4770
|
+
term: args.term,
|
|
4771
|
+
passingMinutes: args.passing_minutes,
|
|
4772
|
+
allowTermFallback: args.allow_term_fallback
|
|
4773
|
+
});
|
|
4774
|
+
const payload = {
|
|
4775
|
+
ok: result.ok,
|
|
4776
|
+
complete: result.complete,
|
|
4777
|
+
skipped_tba: result.skipped_tba,
|
|
4778
|
+
total_credits: result.total_credits,
|
|
4779
|
+
class_count: result.class_count,
|
|
4780
|
+
conflicts: result.conflicts,
|
|
4781
|
+
travel_warnings: result.travel_warnings,
|
|
4782
|
+
seat_problems: result.seat_problems,
|
|
4783
|
+
warnings: result.warnings,
|
|
4784
|
+
weekly_grid: result.weekly_grid,
|
|
4785
|
+
checked: resolved,
|
|
4786
|
+
terms,
|
|
4787
|
+
fetched_at: meta.fetched_at,
|
|
4788
|
+
stale: meta.stale,
|
|
4789
|
+
quarantined: meta.quarantined,
|
|
4790
|
+
duplicate_ids: meta.duplicate_ids,
|
|
4791
|
+
...fallback ? { fallback } : {},
|
|
4792
|
+
...warning ? { warning } : {}
|
|
4793
|
+
};
|
|
4794
|
+
return {
|
|
4795
|
+
content: [{ type: "text", text: checkText(payload) }],
|
|
4796
|
+
...okPayload(payload)
|
|
4797
|
+
};
|
|
4798
|
+
} catch (err) {
|
|
4799
|
+
return toolError(err);
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
);
|
|
4803
|
+
server.registerTool(
|
|
4804
|
+
"list_terms",
|
|
4805
|
+
{
|
|
4806
|
+
title: "List Academic Terms",
|
|
4807
|
+
description: "List available term codes with labels (e.g. FA2026 = Fall 2026). USE first when the user's term is ambiguous ('next semester', 'fall') or before search_classes with an explicit term. Do NOT call repeatedly \u2014 cache the result for the session. Example: {}.",
|
|
4808
|
+
inputSchema: termsInputSchema,
|
|
4809
|
+
outputSchema: termsOutputSchema,
|
|
4810
|
+
annotations: { ...READONLY }
|
|
4811
|
+
},
|
|
4812
|
+
async (args) => {
|
|
4813
|
+
try {
|
|
4814
|
+
const { current, available } = await listTerms({ limit: args.limit });
|
|
4815
|
+
const payload = {
|
|
4816
|
+
current_term: current,
|
|
4817
|
+
terms: available
|
|
4818
|
+
};
|
|
4819
|
+
return {
|
|
4820
|
+
content: [{ type: "text", text: termsText(payload) }],
|
|
4821
|
+
...okPayload(payload)
|
|
4822
|
+
};
|
|
4823
|
+
} catch (err) {
|
|
4824
|
+
return toolError(err);
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
);
|
|
4828
|
+
server.registerTool(
|
|
4829
|
+
"get_course_history",
|
|
4830
|
+
{
|
|
4831
|
+
title: "Get Course History",
|
|
4832
|
+
description: `Show past offerings of a course across terms (instructors, terms offered, rotation signal). USE for 'who usually teaches X?', 'how often is X offered?', 'is it likely in spring?'. Do NOT use for current availability (use search_classes). The rotation_note names the window actually observed \u2014 never claim a pattern the note does not support. Example: {"code":"ECON 101","schools":["PO"]}.`,
|
|
4833
|
+
inputSchema: historyInputSchema,
|
|
4834
|
+
outputSchema: historyOutputSchema,
|
|
4835
|
+
annotations: { ...READONLY }
|
|
4836
|
+
},
|
|
4837
|
+
async (args) => {
|
|
4838
|
+
try {
|
|
4839
|
+
const h = await getCourseHistory(args.code, {
|
|
4840
|
+
campuses: args.schools,
|
|
4841
|
+
limit: args.limit,
|
|
4842
|
+
term: args.term,
|
|
4843
|
+
allowTermFallback: args.allow_term_fallback
|
|
4844
|
+
});
|
|
4845
|
+
const payload = {
|
|
4846
|
+
code: h.code,
|
|
4847
|
+
offerings: h.offerings,
|
|
4848
|
+
rotation_note: h.rotation_note,
|
|
4849
|
+
verdict: h.verdict,
|
|
4850
|
+
fetched_at: h.fetched_at,
|
|
4851
|
+
stale: h.stale,
|
|
4852
|
+
snapshot_term: h.snapshot_term,
|
|
4853
|
+
...h.enrichment ? { enrichment: h.enrichment } : {},
|
|
4854
|
+
...h.fallback ? { fallback: h.fallback } : {},
|
|
4855
|
+
...h.warning ? { warning: h.warning } : {}
|
|
4856
|
+
};
|
|
4857
|
+
return {
|
|
4858
|
+
content: [{ type: "text", text: historyText(payload) }],
|
|
4859
|
+
...okPayload(payload)
|
|
4860
|
+
};
|
|
4861
|
+
} catch (err) {
|
|
4862
|
+
return toolError(err);
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
);
|
|
4866
|
+
}
|
|
4867
|
+
async function serveStdio() {
|
|
4868
|
+
await createMcpServer().connect(new StdioServerTransport());
|
|
4869
|
+
}
|
|
4870
|
+
async function serveHttp(host, port) {
|
|
4871
|
+
const httpServer = createServer(async (req, res) => {
|
|
4872
|
+
if (req.method !== "POST" || req.url?.split("?")[0] !== "/mcp") {
|
|
4873
|
+
res.writeHead(req.method === "POST" ? 404 : 405, {
|
|
4874
|
+
"Content-Type": "application/json"
|
|
4875
|
+
});
|
|
4876
|
+
res.end(JSON.stringify({ error: "Use POST /mcp" }));
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4879
|
+
try {
|
|
4880
|
+
let size = 0;
|
|
4881
|
+
const body = await new Promise((resolve, reject) => {
|
|
4882
|
+
const chunks = [];
|
|
4883
|
+
req.on("data", (c) => {
|
|
4884
|
+
size += c.length;
|
|
4885
|
+
if (size > MAX_BODY_BYTES) {
|
|
4886
|
+
reject(
|
|
4887
|
+
Object.assign(new Error("Request body too large"), {
|
|
4888
|
+
code: "E_TOO_LARGE"
|
|
4889
|
+
})
|
|
4890
|
+
);
|
|
4891
|
+
req.destroy();
|
|
4892
|
+
return;
|
|
4893
|
+
}
|
|
4894
|
+
chunks.push(c);
|
|
4895
|
+
});
|
|
4896
|
+
req.on("end", () => {
|
|
4897
|
+
try {
|
|
4898
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
4899
|
+
} catch (e) {
|
|
4900
|
+
reject(e);
|
|
4901
|
+
}
|
|
4902
|
+
});
|
|
4903
|
+
req.on("error", reject);
|
|
4904
|
+
});
|
|
4905
|
+
const transport = new StreamableHTTPServerTransport({
|
|
4906
|
+
sessionIdGenerator: void 0
|
|
4907
|
+
// stateless
|
|
4908
|
+
});
|
|
4909
|
+
await createMcpServer().connect(transport);
|
|
4910
|
+
await transport.handleRequest(req, res, body);
|
|
4911
|
+
} catch (err) {
|
|
4912
|
+
const tooLarge = err.code === "E_TOO_LARGE";
|
|
4913
|
+
console.error("MCP HTTP error:", err);
|
|
4914
|
+
if (!res.headersSent) {
|
|
4915
|
+
res.writeHead(tooLarge ? 413 : 500, {
|
|
4916
|
+
"Content-Type": "application/json"
|
|
4917
|
+
});
|
|
4918
|
+
res.end(
|
|
4919
|
+
JSON.stringify({
|
|
4920
|
+
error: tooLarge ? "Body too large" : "Internal error"
|
|
4921
|
+
})
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
});
|
|
4926
|
+
httpServer.listen(port, host, () => {
|
|
4927
|
+
const bound = httpServer.address();
|
|
4928
|
+
const actual = typeof bound === "object" && bound ? bound.port : port;
|
|
4929
|
+
console.error(
|
|
4930
|
+
`5c-mcp listening (stateless Streamable HTTP) on http://${host}:${actual}/mcp`
|
|
4931
|
+
);
|
|
4932
|
+
});
|
|
4933
|
+
}
|
|
4934
|
+
async function main() {
|
|
4935
|
+
const args = process.argv.slice(2);
|
|
4936
|
+
if (!args.includes("--http")) {
|
|
4937
|
+
await serveStdio();
|
|
4938
|
+
return;
|
|
4939
|
+
}
|
|
4940
|
+
const argValue = (flag) => {
|
|
4941
|
+
const i = args.indexOf(flag);
|
|
4942
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
4943
|
+
};
|
|
4944
|
+
const rawPort = argValue("--port");
|
|
4945
|
+
const port = rawPort !== void 0 ? Number.parseInt(rawPort, 10) : 3e3;
|
|
4946
|
+
const binding = Number.isFinite(port) ? port : 3e3;
|
|
4947
|
+
const host = argValue("--host") ?? "127.0.0.1";
|
|
4948
|
+
await serveHttp(host, binding);
|
|
4949
|
+
}
|
|
4950
|
+
var isDirectRun = (() => {
|
|
4951
|
+
const argv1 = process.argv[1];
|
|
4952
|
+
if (!argv1) return false;
|
|
4953
|
+
try {
|
|
4954
|
+
return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
|
|
4955
|
+
} catch {
|
|
4956
|
+
return import.meta.url === pathToFileURL(argv1).href;
|
|
4957
|
+
}
|
|
4958
|
+
})();
|
|
4959
|
+
if (isDirectRun) {
|
|
4960
|
+
main().catch((err) => {
|
|
4961
|
+
console.error("5c-mcp fatal:", err);
|
|
4962
|
+
process.exit(1);
|
|
4963
|
+
});
|
|
4964
|
+
}
|
|
4965
|
+
export {
|
|
4966
|
+
createMcpServer,
|
|
4967
|
+
isDirectRun
|
|
4968
|
+
};
|