ezmedicationinput 0.1.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/dist/parser.js ADDED
@@ -0,0 +1,870 @@
1
+ import { DAY_OF_WEEK_TOKENS, DEFAULT_ROUTE_SYNONYMS, DEFAULT_UNIT_SYNONYMS, EVENT_TIMING_TOKENS, MEAL_KEYWORDS, ROUTE_TEXT, TIMING_ABBREVIATIONS, WORD_FREQUENCIES } from "./maps";
2
+ import { inferUnitFromContext } from "./context";
3
+ import { checkDiscouraged } from "./safety";
4
+ import { EventTiming, FhirPeriodUnit, RouteCode } from "./types";
5
+ const BODY_SITE_HINTS = new Set([
6
+ "left",
7
+ "right",
8
+ "bilateral",
9
+ "arm",
10
+ "arms",
11
+ "leg",
12
+ "legs",
13
+ "thigh",
14
+ "thighs",
15
+ "shoulder",
16
+ "shoulders",
17
+ "hand",
18
+ "hands",
19
+ "foot",
20
+ "feet",
21
+ "eye",
22
+ "eyes",
23
+ "ear",
24
+ "ears",
25
+ "nostril",
26
+ "nostrils",
27
+ "abdomen",
28
+ "belly",
29
+ "cheek",
30
+ "cheeks",
31
+ "upper",
32
+ "lower",
33
+ "forearm",
34
+ "back"
35
+ ]);
36
+ const COMBO_EVENT_TIMINGS = {
37
+ "early morning": EventTiming["Early Morning"],
38
+ "late morning": EventTiming["Late Morning"],
39
+ "early afternoon": EventTiming["Early Afternoon"],
40
+ "late afternoon": EventTiming["Late Afternoon"],
41
+ "early evening": EventTiming["Early Evening"],
42
+ "late evening": EventTiming["Late Evening"],
43
+ "after sleep": EventTiming["After Sleep"],
44
+ "upon waking": EventTiming.Wake
45
+ };
46
+ // Tracking explicit breakfast/lunch/dinner markers lets the meal-expansion
47
+ // logic bail early when the clinician already specified precise events.
48
+ const SPECIFIC_MEAL_TIMINGS = new Set([
49
+ EventTiming["Before Breakfast"],
50
+ EventTiming["Before Lunch"],
51
+ EventTiming["Before Dinner"],
52
+ EventTiming["After Breakfast"],
53
+ EventTiming["After Lunch"],
54
+ EventTiming["After Dinner"],
55
+ EventTiming.Breakfast,
56
+ EventTiming.Lunch,
57
+ EventTiming.Dinner
58
+ ]);
59
+ // Ocular shorthand tokens commonly used in ophthalmic sigs.
60
+ const EYE_SITE_TOKENS = {
61
+ od: { site: "right eye", route: RouteCode["Ophthalmic route"] },
62
+ re: { site: "right eye", route: RouteCode["Ophthalmic route"] },
63
+ os: { site: "left eye", route: RouteCode["Ophthalmic route"] },
64
+ le: { site: "left eye", route: RouteCode["Ophthalmic route"] },
65
+ ou: { site: "both eyes", route: RouteCode["Ophthalmic route"] },
66
+ be: { site: "both eyes", route: RouteCode["Ophthalmic route"] },
67
+ vod: {
68
+ site: "right eye",
69
+ route: RouteCode["Intravitreal route (qualifier value)"]
70
+ },
71
+ vos: {
72
+ site: "left eye",
73
+ route: RouteCode["Intravitreal route (qualifier value)"]
74
+ },
75
+ ivtod: {
76
+ site: "right eye",
77
+ route: RouteCode["Intravitreal route (qualifier value)"]
78
+ },
79
+ ivtre: {
80
+ site: "right eye",
81
+ route: RouteCode["Intravitreal route (qualifier value)"]
82
+ },
83
+ ivtos: {
84
+ site: "left eye",
85
+ route: RouteCode["Intravitreal route (qualifier value)"]
86
+ },
87
+ ivtle: {
88
+ site: "left eye",
89
+ route: RouteCode["Intravitreal route (qualifier value)"]
90
+ },
91
+ ivtou: {
92
+ site: "both eyes",
93
+ route: RouteCode["Intravitreal route (qualifier value)"]
94
+ },
95
+ ivtbe: {
96
+ site: "both eyes",
97
+ route: RouteCode["Intravitreal route (qualifier value)"]
98
+ }
99
+ };
100
+ export function tokenize(input) {
101
+ const separators = /[(),]/g;
102
+ let normalized = input.trim().replace(separators, " ");
103
+ normalized = normalized.replace(/(\d+)\s*\/\s*(\d+)/g, (match, num, den) => {
104
+ const numerator = parseFloat(num);
105
+ const denominator = parseFloat(den);
106
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) {
107
+ return match;
108
+ }
109
+ const value = numerator / denominator;
110
+ return value.toString();
111
+ });
112
+ normalized = normalized.replace(/(\d+(?:\.\d+)?[x*])([A-Za-z]+)/g, "$1 $2");
113
+ normalized = normalized.replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)/g, "$1-$2");
114
+ normalized = normalized.replace(/(\d+(?:\.\d+)?)(tab|tabs|tablet|tablets|cap|caps|capsule|capsules|mg|mcg|ml|g|drops|drop|puff|puffs|spray|sprays|patch|patches)/gi, "$1 $2");
115
+ normalized = normalized.replace(/[\\/]/g, " ");
116
+ const rawTokens = normalized
117
+ .split(/\s+/)
118
+ .map((t) => t.trim())
119
+ .filter((t) => t.length > 0 && t !== "." && t !== "-");
120
+ const tokens = [];
121
+ for (let i = 0; i < rawTokens.length; i++) {
122
+ const raw = rawTokens[i];
123
+ const parts = splitToken(raw);
124
+ for (const part of parts) {
125
+ if (!part)
126
+ continue;
127
+ tokens.push({ original: part, lower: part.toLowerCase(), index: tokens.length });
128
+ }
129
+ }
130
+ return tokens;
131
+ }
132
+ function splitToken(token) {
133
+ if (/^[0-9]+(?:\.[0-9]+)?$/.test(token)) {
134
+ return [token];
135
+ }
136
+ if (/^[A-Za-z]+$/.test(token)) {
137
+ return [token];
138
+ }
139
+ const qRange = token.match(/^q([0-9]+(?:\.[0-9]+)?)-([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)$/i);
140
+ if (qRange) {
141
+ const [, low, high, unit] = qRange;
142
+ return [token.charAt(0), `${low}-${high}`, unit];
143
+ }
144
+ const match = token.match(/^([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)$/);
145
+ if (match) {
146
+ const [, num, unit] = match;
147
+ if (!/^x\d+/i.test(unit) && !/^q\d+/i.test(unit)) {
148
+ return [num, unit];
149
+ }
150
+ }
151
+ return [token];
152
+ }
153
+ function mark(consumed, token) {
154
+ consumed.add(token.index);
155
+ }
156
+ function addWhen(target, code) {
157
+ if (!target.includes(code)) {
158
+ target.push(code);
159
+ }
160
+ }
161
+ // Removing is slightly more work than adding because a clinician might repeat
162
+ // the same token; trimming them all keeps downstream assertions tidy.
163
+ function removeWhen(target, code) {
164
+ let index = target.indexOf(code);
165
+ while (index !== -1) {
166
+ target.splice(index, 1);
167
+ index = target.indexOf(code);
168
+ }
169
+ }
170
+ // Translate the requested expansion context into the appropriate sequence of
171
+ // EventTiming values (e.g., AC -> ACM/ACD/ACV) for the detected frequency.
172
+ function computeMealExpansions(base, frequency, pairPreference) {
173
+ if (frequency < 1 || frequency > 4) {
174
+ return undefined;
175
+ }
176
+ const bedtime = EventTiming["Before Sleep"];
177
+ const beforePair = pairPreference === "breakfast+lunch"
178
+ ? [EventTiming["Before Breakfast"], EventTiming["Before Lunch"]]
179
+ : [EventTiming["Before Breakfast"], EventTiming["Before Dinner"]];
180
+ const afterPair = pairPreference === "breakfast+lunch"
181
+ ? [EventTiming["After Breakfast"], EventTiming["After Lunch"]]
182
+ : [EventTiming["After Breakfast"], EventTiming["After Dinner"]];
183
+ const withPair = pairPreference === "breakfast+lunch"
184
+ ? [EventTiming.Breakfast, EventTiming.Lunch]
185
+ : [EventTiming.Breakfast, EventTiming.Dinner];
186
+ if (base === "before") {
187
+ if (frequency === 1)
188
+ return [EventTiming["Before Breakfast"]];
189
+ if (frequency === 2)
190
+ return beforePair;
191
+ if (frequency === 3) {
192
+ return [
193
+ EventTiming["Before Breakfast"],
194
+ EventTiming["Before Lunch"],
195
+ EventTiming["Before Dinner"]
196
+ ];
197
+ }
198
+ return [
199
+ EventTiming["Before Breakfast"],
200
+ EventTiming["Before Lunch"],
201
+ EventTiming["Before Dinner"],
202
+ bedtime
203
+ ];
204
+ }
205
+ if (base === "after") {
206
+ if (frequency === 1)
207
+ return [EventTiming["After Breakfast"]];
208
+ if (frequency === 2)
209
+ return afterPair;
210
+ if (frequency === 3) {
211
+ return [
212
+ EventTiming["After Breakfast"],
213
+ EventTiming["After Lunch"],
214
+ EventTiming["After Dinner"]
215
+ ];
216
+ }
217
+ return [
218
+ EventTiming["After Breakfast"],
219
+ EventTiming["After Lunch"],
220
+ EventTiming["After Dinner"],
221
+ bedtime
222
+ ];
223
+ }
224
+ // base === "with"
225
+ if (frequency === 1)
226
+ return [EventTiming.Breakfast];
227
+ if (frequency === 2)
228
+ return withPair;
229
+ if (frequency === 3) {
230
+ return [EventTiming.Breakfast, EventTiming.Lunch, EventTiming.Dinner];
231
+ }
232
+ return [EventTiming.Breakfast, EventTiming.Lunch, EventTiming.Dinner, bedtime];
233
+ }
234
+ // Optionally replace generic meal tokens with concrete breakfast/lunch/dinner
235
+ // EventTiming codes when the cadence makes the intent obvious.
236
+ function expandMealTimings(internal, options) {
237
+ if (!options?.smartMealExpansion) {
238
+ return;
239
+ }
240
+ if (!internal.when.length) {
241
+ return;
242
+ }
243
+ if (internal.when.some((code) => SPECIFIC_MEAL_TIMINGS.has(code))) {
244
+ return;
245
+ }
246
+ const frequency = internal.frequency;
247
+ if (!frequency || frequency < 1 || frequency > 4) {
248
+ return;
249
+ }
250
+ if (internal.period !== undefined &&
251
+ internal.periodUnit !== undefined &&
252
+ (internal.periodUnit !== FhirPeriodUnit.Day || internal.period !== 1)) {
253
+ return;
254
+ }
255
+ if (internal.period !== undefined &&
256
+ internal.periodUnit === undefined &&
257
+ internal.period !== 1) {
258
+ return;
259
+ }
260
+ if (internal.periodUnit && internal.periodUnit !== FhirPeriodUnit.Day) {
261
+ return;
262
+ }
263
+ if (internal.frequencyMax !== undefined || internal.periodMax !== undefined) {
264
+ return;
265
+ }
266
+ const pairPreference = options.twoPerDayPair ?? "breakfast+dinner";
267
+ const replacements = [];
268
+ if (internal.when.includes(EventTiming["Before Meal"])) {
269
+ const specifics = computeMealExpansions("before", frequency, pairPreference);
270
+ if (specifics) {
271
+ replacements.push({ general: EventTiming["Before Meal"], specifics });
272
+ }
273
+ }
274
+ if (internal.when.includes(EventTiming["After Meal"])) {
275
+ const specifics = computeMealExpansions("after", frequency, pairPreference);
276
+ if (specifics) {
277
+ replacements.push({ general: EventTiming["After Meal"], specifics });
278
+ }
279
+ }
280
+ if (internal.when.includes(EventTiming.Meal)) {
281
+ const specifics = computeMealExpansions("with", frequency, pairPreference);
282
+ if (specifics) {
283
+ replacements.push({ general: EventTiming.Meal, specifics });
284
+ }
285
+ }
286
+ for (const { general, specifics } of replacements) {
287
+ removeWhen(internal.when, general);
288
+ for (const specific of specifics) {
289
+ addWhen(internal.when, specific);
290
+ }
291
+ }
292
+ }
293
+ function setRoute(internal, code, text) {
294
+ internal.routeCode = code;
295
+ internal.routeText = text ?? ROUTE_TEXT[code];
296
+ }
297
+ /**
298
+ * Convert hour-based values into minutes when fractional quantities appear so
299
+ * the resulting FHIR repeat payloads avoid unwieldy decimals.
300
+ */
301
+ function normalizePeriodValue(value, unit) {
302
+ if (unit === FhirPeriodUnit.Hour && (!Number.isInteger(value) || value < 1)) {
303
+ return { value: Math.round(value * 60 * 1000) / 1000, unit: FhirPeriodUnit.Minute };
304
+ }
305
+ return { value, unit };
306
+ }
307
+ /**
308
+ * Ensure ranges expressed in hours remain consistent when fractional values
309
+ * demand conversion into minutes.
310
+ */
311
+ function normalizePeriodRange(low, high, unit) {
312
+ if (unit === FhirPeriodUnit.Hour && (!Number.isInteger(low) || !Number.isInteger(high) || low < 1 || high < 1)) {
313
+ return {
314
+ low: Math.round(low * 60 * 1000) / 1000,
315
+ high: Math.round(high * 60 * 1000) / 1000,
316
+ unit: FhirPeriodUnit.Minute
317
+ };
318
+ }
319
+ return { low, high, unit };
320
+ }
321
+ function periodUnitSuffix(unit) {
322
+ switch (unit) {
323
+ case FhirPeriodUnit.Minute:
324
+ return "min";
325
+ case FhirPeriodUnit.Hour:
326
+ return "h";
327
+ case FhirPeriodUnit.Day:
328
+ return "d";
329
+ case FhirPeriodUnit.Week:
330
+ return "wk";
331
+ case FhirPeriodUnit.Month:
332
+ return "mo";
333
+ case FhirPeriodUnit.Year:
334
+ return "a";
335
+ default:
336
+ return undefined;
337
+ }
338
+ }
339
+ function maybeAssignTimingCode(internal, value, unit) {
340
+ const suffix = periodUnitSuffix(unit);
341
+ if (!suffix) {
342
+ return;
343
+ }
344
+ const key = `q${value}${suffix}`;
345
+ const descriptor = TIMING_ABBREVIATIONS[key];
346
+ if (descriptor?.code && !internal.timingCode) {
347
+ internal.timingCode = descriptor.code;
348
+ }
349
+ }
350
+ /**
351
+ * Apply the chosen period/unit pair and infer helpful timing codes when the
352
+ * period clearly represents common cadences (daily/weekly/monthly).
353
+ */
354
+ function applyPeriod(internal, period, unit) {
355
+ const normalized = normalizePeriodValue(period, unit);
356
+ internal.period = normalized.value;
357
+ internal.periodUnit = normalized.unit;
358
+ maybeAssignTimingCode(internal, normalized.value, normalized.unit);
359
+ if (normalized.unit === FhirPeriodUnit.Day && normalized.value === 1) {
360
+ internal.frequency = internal.frequency ?? 1;
361
+ }
362
+ if (normalized.unit === FhirPeriodUnit.Week && normalized.value === 1) {
363
+ internal.timingCode = internal.timingCode ?? "WK";
364
+ }
365
+ if (normalized.unit === FhirPeriodUnit.Month && normalized.value === 1) {
366
+ internal.timingCode = internal.timingCode ?? "MO";
367
+ }
368
+ }
369
+ /**
370
+ * Parse compact q-interval tokens like q30min, q0.5h, or q1w, optionally using
371
+ * the following token as the unit if the compact token only carries the value.
372
+ */
373
+ function tryParseCompactQ(internal, tokens, index) {
374
+ const token = tokens[index];
375
+ const lower = token.lower;
376
+ const compact = lower.match(/^q([0-9]+(?:\.[0-9]+)?)([a-z]+)$/);
377
+ if (compact) {
378
+ const value = parseFloat(compact[1]);
379
+ const unitCode = mapIntervalUnit(compact[2]);
380
+ if (Number.isFinite(value) && unitCode) {
381
+ applyPeriod(internal, value, unitCode);
382
+ mark(internal.consumed, token);
383
+ return true;
384
+ }
385
+ }
386
+ const valueOnly = lower.match(/^q([0-9]+(?:\.[0-9]+)?)$/);
387
+ if (valueOnly) {
388
+ const unitToken = tokens[index + 1];
389
+ if (!unitToken || internal.consumed.has(unitToken.index)) {
390
+ return false;
391
+ }
392
+ const unitCode = mapIntervalUnit(unitToken.lower);
393
+ if (!unitCode) {
394
+ return false;
395
+ }
396
+ const value = parseFloat(valueOnly[1]);
397
+ if (!Number.isFinite(value)) {
398
+ return false;
399
+ }
400
+ applyPeriod(internal, value, unitCode);
401
+ mark(internal.consumed, token);
402
+ mark(internal.consumed, unitToken);
403
+ return true;
404
+ }
405
+ return false;
406
+ }
407
+ function applyFrequencyDescriptor(internal, token, descriptor, options) {
408
+ if (descriptor.discouraged) {
409
+ const check = checkDiscouraged(token.original, options);
410
+ if (check.warning) {
411
+ internal.warnings.push(check.warning);
412
+ }
413
+ }
414
+ if (descriptor.code) {
415
+ internal.timingCode = descriptor.code;
416
+ }
417
+ if (descriptor.frequency !== undefined) {
418
+ internal.frequency = descriptor.frequency;
419
+ }
420
+ if (descriptor.frequencyMax !== undefined) {
421
+ internal.frequencyMax = descriptor.frequencyMax;
422
+ }
423
+ if (descriptor.period !== undefined) {
424
+ internal.period = descriptor.period;
425
+ }
426
+ if (descriptor.periodMax !== undefined) {
427
+ internal.periodMax = descriptor.periodMax;
428
+ }
429
+ if (descriptor.periodUnit) {
430
+ internal.periodUnit = descriptor.periodUnit;
431
+ }
432
+ if (descriptor.when) {
433
+ for (const w of descriptor.when) {
434
+ addWhen(internal.when, w);
435
+ }
436
+ }
437
+ mark(internal.consumed, token);
438
+ }
439
+ function applyWhenToken(internal, token, code) {
440
+ addWhen(internal.when, code);
441
+ mark(internal.consumed, token);
442
+ }
443
+ function parseMealContext(internal, tokens, index, code) {
444
+ const token = tokens[index];
445
+ const next = tokens[index + 1];
446
+ if (!next || internal.consumed.has(next.index)) {
447
+ applyWhenToken(internal, token, code);
448
+ return;
449
+ }
450
+ const meal = MEAL_KEYWORDS[next.lower];
451
+ if (meal) {
452
+ const whenCode = code === EventTiming["After Meal"]
453
+ ? meal.pc
454
+ : code === EventTiming["Before Meal"]
455
+ ? meal.ac
456
+ : code;
457
+ applyWhenToken(internal, token, whenCode);
458
+ mark(internal.consumed, next);
459
+ return;
460
+ }
461
+ applyWhenToken(internal, token, code);
462
+ }
463
+ function parseSeparatedQ(internal, tokens, index, options) {
464
+ const token = tokens[index];
465
+ const next = tokens[index + 1];
466
+ if (!next || internal.consumed.has(next.index)) {
467
+ return false;
468
+ }
469
+ const after = tokens[index + 2];
470
+ const lowerNext = next.lower;
471
+ const range = parseNumericRange(lowerNext);
472
+ if (range) {
473
+ const unitToken = after;
474
+ if (!unitToken) {
475
+ return false;
476
+ }
477
+ const unitCode = mapIntervalUnit(unitToken.lower);
478
+ if (!unitCode) {
479
+ return false;
480
+ }
481
+ const normalized = normalizePeriodRange(range.low, range.high, unitCode);
482
+ internal.period = normalized.low;
483
+ internal.periodMax = normalized.high;
484
+ internal.periodUnit = normalized.unit;
485
+ mark(internal.consumed, token);
486
+ mark(internal.consumed, next);
487
+ mark(internal.consumed, unitToken);
488
+ return true;
489
+ }
490
+ const isNumber = /^[0-9]+(?:\.[0-9]+)?$/.test(lowerNext);
491
+ if (!isNumber) {
492
+ const unitCode = mapIntervalUnit(lowerNext);
493
+ if (unitCode) {
494
+ mark(internal.consumed, token);
495
+ mark(internal.consumed, next);
496
+ applyPeriod(internal, 1, unitCode);
497
+ return true;
498
+ }
499
+ return false;
500
+ }
501
+ const unitToken = after;
502
+ if (!unitToken) {
503
+ return false;
504
+ }
505
+ const unitCode = mapIntervalUnit(unitToken.lower);
506
+ if (!unitCode) {
507
+ return false;
508
+ }
509
+ const value = parseFloat(next.original);
510
+ applyPeriod(internal, value, unitCode);
511
+ mark(internal.consumed, token);
512
+ mark(internal.consumed, next);
513
+ mark(internal.consumed, unitToken);
514
+ return true;
515
+ }
516
+ function mapIntervalUnit(token) {
517
+ if (token === "min" ||
518
+ token === "mins" ||
519
+ token === "minute" ||
520
+ token === "minutes" ||
521
+ token === "m") {
522
+ return FhirPeriodUnit.Minute;
523
+ }
524
+ if (token === "h" || token === "hr" || token === "hrs" || token === "hour" || token === "hours") {
525
+ return FhirPeriodUnit.Hour;
526
+ }
527
+ if (token === "d" || token === "day" || token === "days") {
528
+ return FhirPeriodUnit.Day;
529
+ }
530
+ if (token === "wk" || token === "w" || token === "week" || token === "weeks") {
531
+ return FhirPeriodUnit.Week;
532
+ }
533
+ if (token === "mo" || token === "month" || token === "months") {
534
+ return FhirPeriodUnit.Month;
535
+ }
536
+ return undefined;
537
+ }
538
+ function parseNumericRange(token) {
539
+ const rangeMatch = token.match(/^([0-9]+(?:\.[0-9]+)?)-([0-9]+(?:\.[0-9]+)?)$/);
540
+ if (!rangeMatch) {
541
+ return undefined;
542
+ }
543
+ const low = parseFloat(rangeMatch[1]);
544
+ const high = parseFloat(rangeMatch[2]);
545
+ if (!Number.isFinite(low) || !Number.isFinite(high)) {
546
+ return undefined;
547
+ }
548
+ return { low, high };
549
+ }
550
+ export function parseInternal(input, options) {
551
+ const tokens = tokenize(input);
552
+ const internal = {
553
+ input,
554
+ tokens,
555
+ consumed: new Set(),
556
+ dayOfWeek: [],
557
+ when: [],
558
+ warnings: []
559
+ };
560
+ const context = options?.context ?? undefined;
561
+ const customRouteMap = options?.routeMap
562
+ ? new Map(Object.entries(options.routeMap).map(([key, value]) => [
563
+ key.toLowerCase(),
564
+ value
565
+ ]))
566
+ : undefined;
567
+ if (tokens.length === 0) {
568
+ return internal;
569
+ }
570
+ // PRN detection
571
+ let prnReasonStart;
572
+ for (let i = 0; i < tokens.length; i++) {
573
+ const token = tokens[i];
574
+ if (token.lower === "prn") {
575
+ internal.asNeeded = true;
576
+ mark(internal.consumed, token);
577
+ prnReasonStart = i + 1;
578
+ break;
579
+ }
580
+ if (token.lower === "as" && tokens[i + 1]?.lower === "needed") {
581
+ internal.asNeeded = true;
582
+ mark(internal.consumed, token);
583
+ mark(internal.consumed, tokens[i + 1]);
584
+ let reasonIndex = i + 2;
585
+ if (tokens[reasonIndex]?.lower === "for") {
586
+ mark(internal.consumed, tokens[reasonIndex]);
587
+ reasonIndex += 1;
588
+ }
589
+ prnReasonStart = reasonIndex;
590
+ break;
591
+ }
592
+ }
593
+ // Multiplicative tokens like 1x3
594
+ for (const token of tokens) {
595
+ if (internal.consumed.has(token.index))
596
+ continue;
597
+ const match = token.lower.match(/^([0-9]+(?:\.[0-9]+)?)[x*]([0-9]+(?:\.[0-9]+)?)$/);
598
+ if (match) {
599
+ const dose = parseFloat(match[1]);
600
+ const freq = parseFloat(match[2]);
601
+ if (internal.dose === undefined) {
602
+ internal.dose = dose;
603
+ }
604
+ internal.frequency = freq;
605
+ internal.period = 1;
606
+ internal.periodUnit = FhirPeriodUnit.Day;
607
+ mark(internal.consumed, token);
608
+ }
609
+ }
610
+ // Process tokens sequentially
611
+ const tryRouteSynonym = (startIndex) => {
612
+ const maxSpan = Math.min(5, tokens.length - startIndex);
613
+ for (let span = maxSpan; span >= 1; span--) {
614
+ const slice = tokens.slice(startIndex, startIndex + span);
615
+ if (slice.some((part) => internal.consumed.has(part.index))) {
616
+ continue;
617
+ }
618
+ const phrase = slice.map((part) => part.lower).join(" ");
619
+ const customCode = customRouteMap?.get(phrase);
620
+ const synonym = customCode
621
+ ? { code: customCode, text: ROUTE_TEXT[customCode] }
622
+ : DEFAULT_ROUTE_SYNONYMS[phrase];
623
+ if (synonym) {
624
+ setRoute(internal, synonym.code, synonym.text);
625
+ for (const part of slice) {
626
+ mark(internal.consumed, part);
627
+ }
628
+ return true;
629
+ }
630
+ }
631
+ return false;
632
+ };
633
+ for (let i = 0; i < tokens.length; i++) {
634
+ const token = tokens[i];
635
+ if (internal.consumed.has(token.index)) {
636
+ continue;
637
+ }
638
+ if (token.lower === "bld" || token.lower === "b-l-d") {
639
+ const check = checkDiscouraged(token.original, options);
640
+ if (check.warning) {
641
+ internal.warnings.push(check.warning);
642
+ }
643
+ applyWhenToken(internal, token, EventTiming.Meal);
644
+ continue;
645
+ }
646
+ if (token.lower === "q") {
647
+ if (parseSeparatedQ(internal, tokens, i, options)) {
648
+ continue;
649
+ }
650
+ }
651
+ // Frequency abbreviation map
652
+ const freqDescriptor = TIMING_ABBREVIATIONS[token.lower];
653
+ if (freqDescriptor) {
654
+ applyFrequencyDescriptor(internal, token, freqDescriptor, options);
655
+ continue;
656
+ }
657
+ if (tryParseCompactQ(internal, tokens, i)) {
658
+ continue;
659
+ }
660
+ // Event timing tokens
661
+ if (token.lower === "pc" || token.lower === "ac") {
662
+ parseMealContext(internal, tokens, i, token.lower === "pc"
663
+ ? EventTiming["After Meal"]
664
+ : EventTiming["Before Meal"]);
665
+ continue;
666
+ }
667
+ const nextToken = tokens[i + 1];
668
+ if (nextToken && !internal.consumed.has(nextToken.index)) {
669
+ const combo = `${token.lower} ${nextToken.lower}`;
670
+ const comboWhen = COMBO_EVENT_TIMINGS[combo] ?? EVENT_TIMING_TOKENS[combo];
671
+ if (comboWhen) {
672
+ applyWhenToken(internal, token, comboWhen);
673
+ mark(internal.consumed, nextToken);
674
+ continue;
675
+ }
676
+ }
677
+ const customWhen = options?.whenMap?.[token.lower];
678
+ if (customWhen) {
679
+ applyWhenToken(internal, token, customWhen);
680
+ continue;
681
+ }
682
+ const whenCode = EVENT_TIMING_TOKENS[token.lower];
683
+ if (whenCode) {
684
+ applyWhenToken(internal, token, whenCode);
685
+ continue;
686
+ }
687
+ // Day of week
688
+ const day = DAY_OF_WEEK_TOKENS[token.lower];
689
+ if (day) {
690
+ if (!internal.dayOfWeek.includes(day)) {
691
+ internal.dayOfWeek.push(day);
692
+ }
693
+ mark(internal.consumed, token);
694
+ continue;
695
+ }
696
+ // Units following numbers handled later
697
+ if (tryRouteSynonym(i)) {
698
+ continue;
699
+ }
700
+ const eyeSite = EYE_SITE_TOKENS[token.lower];
701
+ if (eyeSite) {
702
+ internal.siteText = eyeSite.site;
703
+ if (eyeSite.route && !internal.routeCode) {
704
+ setRoute(internal, eyeSite.route);
705
+ }
706
+ mark(internal.consumed, token);
707
+ continue;
708
+ }
709
+ // Numeric dose
710
+ const rangeValue = parseNumericRange(token.lower);
711
+ if (rangeValue) {
712
+ if (!internal.doseRange) {
713
+ internal.doseRange = rangeValue;
714
+ }
715
+ mark(internal.consumed, token);
716
+ const unitToken = tokens[i + 1];
717
+ if (unitToken && !internal.consumed.has(unitToken.index)) {
718
+ const unit = normalizeUnit(unitToken.lower, options);
719
+ if (unit) {
720
+ internal.unit = unit;
721
+ mark(internal.consumed, unitToken);
722
+ }
723
+ }
724
+ continue;
725
+ }
726
+ if (/^[0-9]+(?:\.[0-9]+)?$/.test(token.lower)) {
727
+ const value = parseFloat(token.original);
728
+ if (internal.dose === undefined) {
729
+ internal.dose = value;
730
+ }
731
+ mark(internal.consumed, token);
732
+ const unitToken = tokens[i + 1];
733
+ if (unitToken && !internal.consumed.has(unitToken.index)) {
734
+ const unit = normalizeUnit(unitToken.lower, options);
735
+ if (unit) {
736
+ internal.unit = unit;
737
+ mark(internal.consumed, unitToken);
738
+ }
739
+ }
740
+ continue;
741
+ }
742
+ // Patterns like 1x or 2x
743
+ const timesMatch = token.lower.match(/^([0-9]+(?:\.[0-9]+)?)[x*]$/);
744
+ if (timesMatch) {
745
+ const val = parseFloat(timesMatch[1]);
746
+ if (internal.dose === undefined) {
747
+ internal.dose = val;
748
+ }
749
+ mark(internal.consumed, token);
750
+ continue;
751
+ }
752
+ // Words for frequency
753
+ const wordFreq = WORD_FREQUENCIES[token.lower];
754
+ if (wordFreq) {
755
+ internal.frequency = wordFreq.frequency;
756
+ internal.period = 1;
757
+ internal.periodUnit = wordFreq.periodUnit;
758
+ mark(internal.consumed, token);
759
+ continue;
760
+ }
761
+ // Skip generic connectors
762
+ if (token.lower === "per" || token.lower === "a" || token.lower === "every") {
763
+ mark(internal.consumed, token);
764
+ continue;
765
+ }
766
+ }
767
+ // Units from trailing tokens if still undefined
768
+ if (internal.unit === undefined) {
769
+ for (const token of tokens) {
770
+ if (internal.consumed.has(token.index))
771
+ continue;
772
+ const unit = normalizeUnit(token.lower, options);
773
+ if (unit) {
774
+ internal.unit = unit;
775
+ mark(internal.consumed, token);
776
+ break;
777
+ }
778
+ }
779
+ }
780
+ if (internal.unit === undefined) {
781
+ internal.unit = inferUnitFromContext(context);
782
+ }
783
+ // Frequency defaults when timing code implies it
784
+ if (internal.frequency === undefined &&
785
+ internal.period === undefined &&
786
+ internal.timingCode) {
787
+ const descriptor = TIMING_ABBREVIATIONS[internal.timingCode.toLowerCase()];
788
+ if (descriptor) {
789
+ if (descriptor.frequency !== undefined) {
790
+ internal.frequency = descriptor.frequency;
791
+ }
792
+ if (descriptor.period !== undefined) {
793
+ internal.period = descriptor.period;
794
+ }
795
+ if (descriptor.periodUnit) {
796
+ internal.periodUnit = descriptor.periodUnit;
797
+ }
798
+ if (descriptor.when) {
799
+ for (const w of descriptor.when) {
800
+ addWhen(internal.when, w);
801
+ }
802
+ }
803
+ }
804
+ }
805
+ if (!internal.timingCode &&
806
+ internal.frequency !== undefined &&
807
+ internal.periodUnit === FhirPeriodUnit.Day &&
808
+ (internal.period === undefined || internal.period === 1)) {
809
+ if (internal.frequency === 2) {
810
+ internal.timingCode = "BID";
811
+ }
812
+ else if (internal.frequency === 3) {
813
+ internal.timingCode = "TID";
814
+ }
815
+ else if (internal.frequency === 4) {
816
+ internal.timingCode = "QID";
817
+ }
818
+ }
819
+ // Expand generic meal markers into specific EventTiming codes when asked to.
820
+ expandMealTimings(internal, options);
821
+ // Determine site text from leftover tokens (excluding PRN reason tokens)
822
+ const leftoverTokens = tokens.filter((t) => !internal.consumed.has(t.index));
823
+ if (leftoverTokens.length > 0) {
824
+ const siteCandidates = leftoverTokens.filter((t) => BODY_SITE_HINTS.has(t.lower));
825
+ if (siteCandidates.length > 0) {
826
+ const indices = new Set(siteCandidates.map((t) => t.index));
827
+ const words = [];
828
+ for (const token of leftoverTokens) {
829
+ if (indices.has(token.index) || BODY_SITE_HINTS.has(token.lower)) {
830
+ words.push(token.original);
831
+ mark(internal.consumed, token);
832
+ }
833
+ }
834
+ if (words.length > 0) {
835
+ internal.siteText = words.join(" ");
836
+ }
837
+ }
838
+ }
839
+ // PRN reason text
840
+ if (internal.asNeeded && prnReasonStart !== undefined) {
841
+ const reasonTokens = [];
842
+ for (let i = prnReasonStart; i < tokens.length; i++) {
843
+ const token = tokens[i];
844
+ if (internal.consumed.has(token.index)) {
845
+ continue;
846
+ }
847
+ reasonTokens.push(token.original);
848
+ mark(internal.consumed, token);
849
+ }
850
+ if (reasonTokens.length > 0) {
851
+ internal.asNeededReason = reasonTokens.join(" ");
852
+ }
853
+ }
854
+ if (internal.routeCode === RouteCode["Intravitreal route (qualifier value)"] &&
855
+ (!internal.siteText || !/eye/i.test(internal.siteText))) {
856
+ internal.warnings.push("Intravitreal administrations require an eye site (e.g., OD/OS/OU).");
857
+ }
858
+ return internal;
859
+ }
860
+ function normalizeUnit(token, options) {
861
+ const override = options?.unitMap?.[token];
862
+ if (override) {
863
+ return override;
864
+ }
865
+ const defaultUnit = DEFAULT_UNIT_SYNONYMS[token];
866
+ if (defaultUnit) {
867
+ return defaultUnit;
868
+ }
869
+ return undefined;
870
+ }