diff --git a/demo/app-root.ts b/demo/app-root.ts index 89f9ab1..a27ae5f 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -5,27 +5,43 @@ import { customElement } from 'lit/decorators.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; const storyModules = import.meta.glob( - ['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'], - { eager: true } + [ + '../src/elements/**/*-story.ts', + '../src/labs/**/*-story.ts', + '../src/parsers/**/*-story.ts', + ], + { eager: true }, ); const storyEntries = Object.keys(storyModules) - .map(path => { + .map((path) => { const labs = path.includes('/src/labs/'); const parts = path.split('/'); const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts" const tag = filename.replace(/-story\.ts$/, ''); - return { tag, storyTag: `${tag}-story`, id: `elem-${tag}`, labs }; + // Stories under src/elements or src/labs are custom elements; others + // (e.g. parsers) are plain modules and shouldn't be shown as ``. + const component = + path.includes('/src/elements/') || path.includes('/src/labs/'); + return { + tag, + storyTag: `${tag}-story`, + id: `elem-${tag}`, + labs, + component, + }; }) .sort((a, b) => a.tag.localeCompare(b.tag)); -const productionEntries = storyEntries.filter(e => !e.labs); -const labsEntries = storyEntries.filter(e => e.labs); +const productionEntries = storyEntries.filter((e) => !e.labs); +const labsEntries = storyEntries.filter((e) => e.labs); const ALL_ENTRIES = [...productionEntries, ...labsEntries]; @customElement('app-root') export class AppRoot extends LitElement { - createRenderRoot() { return this; } + createRenderRoot() { + return this; + } private _observer?: IntersectionObserver; private _abortController = new AbortController(); @@ -34,33 +50,43 @@ export class AppRoot extends LitElement { return html`

Internet Archive Elements

Production-Ready Elements

- ${productionEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${productionEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}

Labs Elements

- ${labsEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${labsEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}
`; } firstUpdated() { - const allIds = ALL_ENTRIES.map(e => e.id); + const allIds = ALL_ENTRIES.map((e) => e.id); const links = Object.fromEntries( - allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + allIds.map((id) => [ + id, + this.querySelector(`#ia-sidebar a[href="#${id}"]`), + ]), ); const visible = new Set(); @@ -68,31 +94,37 @@ export class AppRoot extends LitElement { // Only anchors in the top 30% of the viewport count as "active". // The first (topmost) visible anchor wins. this._observer = new IntersectionObserver( - entries => { + (entries) => { for (const entry of entries) { if (entry.isIntersecting) visible.add(entry.target.id); else visible.delete(entry.target.id); } - const activeId = allIds.find(id => visible.has(id)) ?? allIds[0]; - allIds.forEach(id => links[id]?.classList.toggle('active', id === activeId)); + const activeId = allIds.find((id) => visible.has(id)) ?? allIds[0]; + allIds.forEach((id) => + links[id]?.classList.toggle('active', id === activeId), + ); }, { rootMargin: '0px 0px -70% 0px' }, ); - allIds.forEach(id => { + allIds.forEach((id) => { const el = document.getElementById(id); if (el) this._observer!.observe(el); }); - allIds.forEach(id => { - links[id]?.addEventListener('click', (e: Event) => { - e.preventDefault(); - const el = document.getElementById(id); - if (el) { - const top = el.getBoundingClientRect().top + window.scrollY; - window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); - } - }, { signal: this._abortController.signal }); + allIds.forEach((id) => { + links[id]?.addEventListener( + 'click', + (e: Event) => { + e.preventDefault(); + const el = document.getElementById(id); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + } + }, + { signal: this._abortController.signal }, + ); }); } diff --git a/demo/story-template.ts b/demo/story-template.ts index d0871cc..d7611f5 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -28,6 +28,14 @@ export class StoryTemplate extends LitElement { @property({ type: String }) customExampleUsage?: string; + /* Overrides the derived import snippet โ€” for non-component modules + * (parsers, services, models) whose import path isn't `/`. */ + @property({ type: String }) customImport?: string; + + /* Plain-text heading for non-component modules. When set, it's shown as-is + * instead of as an ``. */ + @property({ type: String }) heading?: string; + /* Optional stringified properties to always include in the example usage */ @property({ type: String }) defaultUsageProps?: string; @@ -62,7 +70,9 @@ export class StoryTemplate extends LitElement { return html`

- <${this.elementTag}> + ${this.heading + ? this.heading + : html`<${this.elementTag}>`} ${when( this.labs, () => @@ -214,6 +224,7 @@ export class StoryTemplate extends LitElement { } private get importCode(): string { + if (this.customImport) return this.customImport; if (this.elementClassName) { return `import '${this.modulePath}';\nimport { ${this.elementClassName} } from '${this.modulePath}';`; } else { diff --git a/src/parsers/field-parser-interface.ts b/src/parsers/field-parser-interface.ts new file mode 100644 index 0000000..64815d3 --- /dev/null +++ b/src/parsers/field-parser-interface.ts @@ -0,0 +1,10 @@ +export type FieldParserRawValue = string | number | boolean; + +export interface FieldParserInterface { + /** + * Parse the raw value and return a value of type T or undefined if unparseable + * + * @param rawValue T | undefined + */ + parseValue(rawValue: FieldParserRawValue): T | undefined; +} diff --git a/src/parsers/field-parsers-story.ts b/src/parsers/field-parsers-story.ts new file mode 100644 index 0000000..8ffc8f9 --- /dev/null +++ b/src/parsers/field-parsers-story.ts @@ -0,0 +1,189 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import '@demo/story-template'; + +import type { FieldParserInterface } from './field-parser-interface'; +import { BooleanParser } from './field-types/boolean'; +import { ByteParser } from './field-types/byte'; +import { DateParser } from './field-types/date'; +import { DurationParser } from './field-types/duration'; +import { ListParser } from './field-types/list'; +import { MediaTypeParser } from './field-types/mediatype'; +import { NumberParser } from './field-types/number'; +import { PageProgressionParser } from './field-types/page-progression'; +import { StringParser } from './field-types/string'; + +interface ParserOption { + label: string; + parser: FieldParserInterface; + example: string; +} + +const PARSERS: ParserOption[] = [ + { label: 'number', parser: NumberParser.shared, example: '1234.5' }, + { label: 'boolean', parser: BooleanParser.shared, example: 'true' }, + { label: 'byte', parser: ByteParser.shared, example: '1572864' }, + { label: 'date', parser: DateParser.shared, example: '2021-11-18' }, + { label: 'duration', parser: DurationParser.shared, example: '1:02:03' }, + { + label: 'list (of numbers)', + parser: new ListParser(NumberParser.shared), + example: '1; 2; 3', + }, + { label: 'mediatype', parser: MediaTypeParser.shared, example: 'texts' }, + { + label: 'page-progression', + parser: PageProgressionParser.shared, + example: 'rl', + }, + { label: 'string', parser: StringParser.shared, example: 'hello' }, +]; + +const IMPORT_EXAMPLE = `import { NumberParser } from '@internetarchive/elements/parsers/field-types/number';`; + +const USAGE_EXAMPLE = `const result = NumberParser.shared.parseValue('1234.5'); +// result === 1234.5`; + +/** + * Demo story for the field-type parsers. Renders inside the shared + * chrome with an interactive playground in the demo slot: + * pick a parser, type a raw value, and see the parsed output and runtime type. + */ +@customElement('field-parsers-story') +export class FieldParsersStory extends LitElement { + @state() private selectedIndex = 0; + + @state() private rawValue = PARSERS[0].example; + + private get selected(): ParserOption { + return PARSERS[this.selectedIndex]; + } + + render() { + const result = this.selected.parser.parseValue(this.rawValue); + return html` + +
+
+ + + +
+
+ Result โ†’ + ${this.format(result)} + ${this.typeLabel(result)} +
+
+
+ `; + } + + private onParserChange(e: Event) { + this.selectedIndex = Number((e.target as HTMLSelectElement).value); + this.rawValue = this.selected.example; + } + + private onInput(e: Event) { + this.rawValue = (e.target as HTMLInputElement).value; + } + + private useExample() { + this.rawValue = this.selected.example; + } + + private format(value: unknown): string { + if (value === undefined) return 'undefined (unparseable)'; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return JSON.stringify(value); + return String(value); + } + + private typeLabel(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value instanceof Date) return 'Date'; + if (Array.isArray(value)) return 'array'; + return typeof value; + } + + static styles = css` + .playground { + font-family: system-ui, sans-serif; + } + + .controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-bottom: 1rem; + } + + label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.85rem; + font-weight: 600; + } + + select, + input { + padding: 4px 6px; + font-size: 0.9rem; + } + + .result { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + } + + .result.unparseable code { + color: #a00; + } + + .arrow { + color: #666; + } + + .result code { + font-size: 1rem; + } + + .type { + margin-left: auto; + font-size: 0.75rem; + color: #666; + } + `; +} diff --git a/src/parsers/field-types/boolean.test.ts b/src/parsers/field-types/boolean.test.ts new file mode 100644 index 0000000..fd8d53e --- /dev/null +++ b/src/parsers/field-types/boolean.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { BooleanParser } from './boolean'; + +describe('BooleanParser', () => { + test('can parse string number truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('1'); + expect(response).toBe(true); + }); + + test('can parse string number falsy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('0'); + expect(response).toBe(false); + }); + + test('can parse words truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('true'); + expect(response).toBe(true); + }); + + test('can parse words falsy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('false'); + expect(response).toBe(false); + }); + + test('can parse date truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue(Date()); + expect(response).toBe(true); + }); +}); diff --git a/src/parsers/field-types/boolean.ts b/src/parsers/field-types/boolean.ts new file mode 100644 index 0000000..aeea104 --- /dev/null +++ b/src/parsers/field-types/boolean.ts @@ -0,0 +1,21 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class BooleanParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new BooleanParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): boolean { + if ( + typeof rawValue === 'string' && + (rawValue === 'false' || rawValue === '0') + ) { + return false; + } + return Boolean(rawValue); + } +} diff --git a/src/parsers/field-types/byte.test.ts b/src/parsers/field-types/byte.test.ts new file mode 100644 index 0000000..5cb1203 --- /dev/null +++ b/src/parsers/field-types/byte.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { ByteParser } from './byte'; + +describe('ByteParser', () => { + test('can parse int strings', () => { + const parser = new ByteParser(); + const response = parser.parseValue('3'); + expect(response).toBe(3); + }); + + test('can parse float strings', () => { + const parser = new ByteParser(); + const response = parser.parseValue('3.14'); + expect(response).toBe(3.14); + }); + + test('returns undefined if the number is not a number', () => { + const parser = new ByteParser(); + const response = parser.parseValue('qab'); + expect(response).toBeUndefined(); + }); + + test('returns the number if a number is passed', () => { + const parser = new ByteParser(); + const response = parser.parseValue(5.67); + expect(response).toBe(5.67); + }); + + test('returns undefined if a boolean is passed in', () => { + const parser = new ByteParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/byte.ts b/src/parsers/field-types/byte.ts new file mode 100644 index 0000000..c5dece8 --- /dev/null +++ b/src/parsers/field-types/byte.ts @@ -0,0 +1,30 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; +import { NumberParser } from './number'; + +/** + * A Byte is a unit-specific `number`, in bytes. + */ +export type Byte = number; + +/** + * The ByteParser is a unit-specific NumberParser + * that returns a value in bytes + * + * @export + * @class ByteParser + * @implements {FieldParserInterface} + */ +export class ByteParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new ByteParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Byte | undefined { + const parser = NumberParser.shared; + return parser.parseValue(rawValue); + } +} diff --git a/src/parsers/field-types/date.test.ts b/src/parsers/field-types/date.test.ts new file mode 100644 index 0000000..5d53091 --- /dev/null +++ b/src/parsers/field-types/date.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; + +import { DateParser } from './date'; + +describe('DateParser', () => { + test('can parse date-only strings', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20'); + const response2 = parser.parseValue('06/20/2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + }); + + test('can parse date-time strings', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20 3:46:23'); + const response2 = parser.parseValue('2020-06-20 03:46:23'); + const expected = new Date(); + expected.setHours(3); + expected.setMinutes(46); + expected.setSeconds(23); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + }); + + test('can parse date-time strings different string test', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-09-20 05:12:38'); + const expected = new Date(); + expected.setHours(5); + expected.setMinutes(12); + expected.setSeconds(38); + expected.setMilliseconds(0); + expected.setMonth(8); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse ISO8601 strings without time zones', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20T13:37:15'); + const expected = new Date(); + expected.setHours(13); + expected.setMinutes(37); + expected.setSeconds(15); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse ISO8601 strings with explicit time zones', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20T13:37:15Z'); + const response2 = parser.parseValue('2020-06-20T13:37:15-00:00'); + const response3 = parser.parseValue('2020-06-20T13:37:15+00:00'); + const expected = new Date(); + expected.setUTCHours(13); + expected.setUTCMinutes(37); + expected.setUTCSeconds(15); + expected.setUTCMilliseconds(0); + expected.setUTCMonth(5); + expected.setUTCDate(20); + expected.setUTCFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + expect(response3?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "c.a. yyyy" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('c.a. 2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "ca yyyy" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('ca 2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "[yyyy]" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('[2020]'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('returns undefined if it is a bad date', () => { + const parser = new DateParser(); + const response = parser.parseValue('absjkdvfnskj'); + expect(response).toBeUndefined(); + }); + + test('returns undefined if passed a non-string value', () => { + const parser = new DateParser(); + const response = parser.parseValue(123); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/date.ts b/src/parsers/field-types/date.ts new file mode 100644 index 0000000..0dd9da4 --- /dev/null +++ b/src/parsers/field-types/date.ts @@ -0,0 +1,57 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class DateParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new DateParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Date | undefined { + // try different date parsing + return this.parseJSDate(rawValue) || this.parseBracketDate(rawValue); + } + + // handles "[yyyy]" format + private parseBracketDate(rawValue: FieldParserRawValue): Date | undefined { + if (typeof rawValue !== 'string') return undefined; + const yearMatch = rawValue.match(/\[([0-9]{4})\]/); + if (!yearMatch || yearMatch.length < 2) { + return undefined; + } + return this.parseJSDate(yearMatch[1]); + } + + private parseJSDate(rawValue: FieldParserRawValue): Date | undefined { + if (typeof rawValue !== 'string') return undefined; + let parsedValue = rawValue; + + // fix for Safari not supporting `yyyy-mm-dd HH:MM:SS` format, insert a `T` into the space + if ( + parsedValue.match( + /^[0-9]{4}-[0-9]{2}-[0-9]{2}\s{1}[0-9]{2}:[0-9]{2}:[0-9]{2}$/, + ) + ) { + parsedValue = parsedValue.replace(' ', 'T'); + } + + const parsed = Date.parse(parsedValue); + if (Number.isNaN(parsed)) { + return undefined; + } + let date = new Date(parsedValue); + // The `Date(string)` constructor parses some strings as UTC and some in the local timezone. + // This attempts to detect cases that get parsed as UTC but should be parsed as local. + // Note that this does _not_ include cases with an explicit time zone specified, which + // should generally be parsed as-is and not converted to local time. + const isUTCTimeZoneInferred = + parsedValue.match(/^[0-9]{4}$/) || // just the year, ie `2020` + parsedValue.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/); // YYYY-MM-DD format + if (isUTCTimeZoneInferred) { + date = new Date(date.getTime() + date.getTimezoneOffset() * 1000 * 60); + } + return date; + } +} diff --git a/src/parsers/field-types/duration.test.ts b/src/parsers/field-types/duration.test.ts new file mode 100644 index 0000000..42c59a0 --- /dev/null +++ b/src/parsers/field-types/duration.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; + +import { DurationParser } from './duration'; + +describe('DurationParser', () => { + test('can parse mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('45:23'); + const expected = 23 + 45 * 60; + expect(response).toBe(expected); + }); + + test('can parse hh:mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('3:45:23'); + const expected = 23 + 45 * 60 + 3 * 60 * 60; + expect(response).toBe(expected); + }); + + test('returns undefined for a non-number component in hh:mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('3:AB:23'); + expect(response).toBeUndefined(); + }); + + test('can parse decimal format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('345.23'); + expect(response).toBe(345.23); + }); + + test('returns undefined for non-numeric numbers', () => { + const parser = new DurationParser(); + const response = parser.parseValue('abc.de'); + expect(response).toBeUndefined(); + }); + + test('returns the number if passed a number', () => { + const parser = new DurationParser(); + const response = parser.parseValue(345.23); + expect(response).toBe(345.23); + }); + + test('returns undefined if passed a boolean', () => { + const parser = new DurationParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/duration.ts b/src/parsers/field-types/duration.ts new file mode 100644 index 0000000..8e5565a --- /dev/null +++ b/src/parsers/field-types/duration.ts @@ -0,0 +1,76 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +/** + * Duration is a number in seconds + */ +export type Duration = number; + +/** + * Parses duration format to a `Duration` (number of seconds with decimal) + * + * Can parse hh:mm:ss.ms, hh:mm:ss, mm:ss, mm:ss.ms, and s.ms formats + */ +export class DurationParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new DurationParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Duration | undefined { + if (typeof rawValue === 'number') return rawValue; + if (typeof rawValue === 'boolean') return undefined; + + const componentArray: string[] = rawValue.split(':'); + let seconds: number | undefined; + // if there are no colons in the string, we can assume it's in sss.ms format so just parse it + if (componentArray.length === 1) { + seconds = this.parseNumberFormat(componentArray[0]); + } else { + seconds = this.parseColonSeparatedFormat(componentArray); + } + + return seconds; + } + + /** + * Parse sss.ms format + * + * @param rawValue + * @returns + */ + private parseNumberFormat(rawValue: string): number | undefined { + let seconds: number | undefined = parseFloat(rawValue); + if (Number.isNaN(seconds)) seconds = undefined; + return seconds; + } + + /** + * Parse hh:mm:ss.ms format + * + * @param componentArray + * @returns + */ + private parseColonSeparatedFormat( + componentArray: string[], + ): number | undefined { + // if any of the hh:mm:ss components are NaN, just return undefined + let hasNaNComponent = false; + const parsedValue = componentArray + .map((element: string, index: number) => { + const componentValue: number = parseFloat(element); + if (Number.isNaN(componentValue)) { + hasNaNComponent = true; + return 0; + } + const exponent: number = componentArray.length - 1 - index; + const multiplier: number = 60 ** exponent; + return componentValue * Math.floor(multiplier); + }) + .reduce((a, b) => a + b, 0); + + return hasNaNComponent ? undefined : parsedValue; + } +} diff --git a/src/parsers/field-types/list.test.ts b/src/parsers/field-types/list.test.ts new file mode 100644 index 0000000..01fb0ef --- /dev/null +++ b/src/parsers/field-types/list.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest'; + +import { BooleanParser } from './boolean'; +import { ListParser } from './list'; +import { NumberParser } from './number'; +import { StringParser } from './string'; + +describe('ListParser', () => { + test('can parse a list of strings with commas', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo, bar, baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('can parse a list of strings with semicolons', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo; bar; baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('returns a single value if no list', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo bar baz'); + expect(response).toEqual(['foo bar baz']); + }); + + test('trims whitespace in list items', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue(' foo , bar , baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('can parse a list of numbers with commas', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('1, 2, 3'); + expect(response).toEqual([1, 2, 3]); + }); + + test('can parse a list of numbers with a 0 in it for falsy protection', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('0, 1, 2, 3'); + expect(response).toEqual([0, 1, 2, 3]); + }); + + test('does not include non-numbers in result if numbers are intended', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('abc, 2, 3'); + expect(response).toEqual([2, 3]); + }); + + test('can parse a list of booleans with commas', () => { + const booleanParser = new BooleanParser(); + const parser = new ListParser(booleanParser); + const response = parser.parseValue('true, false, true'); + expect(response).toEqual([true, false, true]); + }); + + test('can parse a list of strings with custom separator', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser, { separators: ['-'] }); + const response = parser.parseValue('boop - bop - beep'); + expect(response).toEqual(['boop', 'bop', 'beep']); + }); + + test('can parse a list of strings with the second custom separator', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser, { separators: ['-', '|'] }); + const response = parser.parseValue('boop | bop | beep'); + expect(response).toEqual(['boop', 'bop', 'beep']); + }); + + test('defaults to semicolons before commas since commas are common in some terms', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('10,000 Maniacs; Boop, Beep, Boop'); + expect(response).toEqual(['10,000 Maniacs', 'Boop, Beep, Boop']); + }); +}); diff --git a/src/parsers/field-types/list.ts b/src/parsers/field-types/list.ts new file mode 100644 index 0000000..95950a1 --- /dev/null +++ b/src/parsers/field-types/list.ts @@ -0,0 +1,45 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class ListParser implements FieldParserInterface { + private parser: FieldParserInterface; + + private separators = [';', ',']; + + constructor( + parser: FieldParserInterface, + options?: { + separators?: string[]; + }, + ) { + this.parser = parser; + if (options && options.separators) { + this.separators = options.separators; + } + } + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): T[] { + const stringifiedValue = String(rawValue); + let results: string[] = []; + + for (const separator of this.separators) { + results = stringifiedValue.split(separator); + if (results.length > 1) break; + } + + return this.parseListValues(results); + } + + private parseListValues(rawValues: string[]): T[] { + const trimmed = rawValues.map((s) => s.trim()); + const parsed = trimmed.map((rawValue) => this.parser.parseValue(rawValue)); + const result: T[] = []; + parsed.forEach((p) => { + if (p !== undefined) result.push(p); + }); + return result; + } +} diff --git a/src/parsers/field-types/mediatype.test.ts b/src/parsers/field-types/mediatype.test.ts new file mode 100644 index 0000000..3ebfc12 --- /dev/null +++ b/src/parsers/field-types/mediatype.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import { MediaTypeParser } from './mediatype'; + +describe('MediaTypeParser', () => { + test('can parse mediatypes', () => { + const parser = new MediaTypeParser(); + expect(parser.parseValue('account')).toBe('account'); + expect(parser.parseValue('audio')).toBe('audio'); + expect(parser.parseValue('collection')).toBe('collection'); + expect(parser.parseValue('data')).toBe('data'); + expect(parser.parseValue('etree')).toBe('etree'); + expect(parser.parseValue('image')).toBe('image'); + expect(parser.parseValue('movies')).toBe('movies'); + expect(parser.parseValue('software')).toBe('software'); + expect(parser.parseValue('texts')).toBe('texts'); + expect(parser.parseValue('web')).toBe('web'); + }); + + test('returns undefined for number values', () => { + const parser = new MediaTypeParser(); + const response = parser.parseValue(15); + expect(response).toBeUndefined(); + }); + + test('returns undefined for boolean values', () => { + const parser = new MediaTypeParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/mediatype.ts b/src/parsers/field-types/mediatype.ts new file mode 100644 index 0000000..c5d8df9 --- /dev/null +++ b/src/parsers/field-types/mediatype.ts @@ -0,0 +1,29 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export type MediaType = + | 'account' + | 'audio' + | 'collection' + | 'data' + | 'etree' + | 'image' + | 'movies' + | 'search' + | 'software' + | 'texts' + | 'web'; + +export class MediaTypeParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new MediaTypeParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): MediaType | undefined { + if (typeof rawValue !== 'string') return undefined; + return rawValue as MediaType; + } +} diff --git a/src/parsers/field-types/number.test.ts b/src/parsers/field-types/number.test.ts new file mode 100644 index 0000000..fb1c4c5 --- /dev/null +++ b/src/parsers/field-types/number.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { NumberParser } from './number'; + +describe('NumberParser', () => { + test('can parse int strings', () => { + const parser = new NumberParser(); + const response = parser.parseValue('3'); + expect(response).toBe(3); + }); + + test('can parse float strings', () => { + const parser = new NumberParser(); + const response = parser.parseValue('3.14'); + expect(response).toBe(3.14); + }); + + test('returns undefined if the number is not a number', () => { + const parser = new NumberParser(); + const response = parser.parseValue('qab'); + expect(response).toBeUndefined(); + }); + + test('returns the number if a number is passed', () => { + const parser = new NumberParser(); + const response = parser.parseValue(5.67); + expect(response).toBe(5.67); + }); + + test('returns undefined if a boolean is passed in', () => { + const parser = new NumberParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/number.ts b/src/parsers/field-types/number.ts new file mode 100644 index 0000000..6f32ab6 --- /dev/null +++ b/src/parsers/field-types/number.ts @@ -0,0 +1,22 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class NumberParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new NumberParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): number | undefined { + if (typeof rawValue === 'number') return rawValue; + if (typeof rawValue === 'boolean') return undefined; + + const value = parseFloat(rawValue); + if (Number.isNaN(value)) { + return undefined; + } + return value; + } +} diff --git a/src/parsers/field-types/page-progression.test.ts b/src/parsers/field-types/page-progression.test.ts new file mode 100644 index 0000000..d7eb4c9 --- /dev/null +++ b/src/parsers/field-types/page-progression.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +import { PageProgressionParser } from './page-progression'; + +describe('PageProgressionParser', () => { + test('can parse page progression', () => { + const parser = new PageProgressionParser(); + expect(parser.parseValue('rl')).toBe('rl'); + expect(parser.parseValue('lr')).toBe('lr'); + }); + + test('returns undefined for number values', () => { + const parser = new PageProgressionParser(); + const response = parser.parseValue(15); + expect(response).toBeUndefined(); + }); + + test('returns undefined for boolean values', () => { + const parser = new PageProgressionParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/page-progression.ts b/src/parsers/field-types/page-progression.ts new file mode 100644 index 0000000..c9aa4da --- /dev/null +++ b/src/parsers/field-types/page-progression.ts @@ -0,0 +1,20 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export type PageProgression = 'rl' | 'lr'; + +export class PageProgressionParser + implements FieldParserInterface +{ + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new PageProgressionParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): PageProgression | undefined { + if (typeof rawValue !== 'string') return undefined; + return rawValue as PageProgression; + } +} diff --git a/src/parsers/field-types/string.test.ts b/src/parsers/field-types/string.test.ts new file mode 100644 index 0000000..b9d5db1 --- /dev/null +++ b/src/parsers/field-types/string.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'vitest'; + +import { StringParser } from './string'; + +describe('StringParser', () => { + test('can parse strings', () => { + const parser = new StringParser(); + const response = parser.parseValue('3'); + expect(response).toBe('3'); + }); +}); diff --git a/src/parsers/field-types/string.ts b/src/parsers/field-types/string.ts new file mode 100644 index 0000000..efcfd70 --- /dev/null +++ b/src/parsers/field-types/string.ts @@ -0,0 +1,15 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class StringParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new StringParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): string { + return String(rawValue); + } +}