From 8e0a165a07c1a6f3a7bd80eb6b44c4493f961025 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Wed, 27 May 2026 14:07:23 -0700 Subject: [PATCH 1/2] WEBDEV-8509: Migrate iaux-item-metadata into elements Moves @internetarchive/iaux-item-metadata models into src/models/item-metadata/. Field-parser imports rewritten to @src/parsers/* (the in-tree paths landed by WEBDEV-8507). Adds typescript-memoize as a runtime dependency. The addeddate test was using local-time setters to compare against a UTC-suffixed ISO timestamp; switched to setUTCHours/etc so the test isn't TZ-dependent under Vitest's browser provider. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 9 +- package.json | 5 +- src/models/item-metadata/file.test.ts | 61 ++ src/models/item-metadata/file.ts | 109 ++++ .../field-types/boolean.test.ts | 69 +++ .../metadata-fields/field-types/boolean.ts | 8 + .../metadata-fields/field-types/byte.test.ts | 21 + .../metadata-fields/field-types/byte.ts | 16 + .../metadata-fields/field-types/date.ts | 8 + .../field-types/duration.test.ts | 37 ++ .../metadata-fields/field-types/duration.ts | 17 + .../metadata-fields/field-types/list.test.ts | 62 ++ .../metadata-fields/field-types/list.ts | 42 ++ .../field-types/mediatype.test.ts | 21 + .../metadata-fields/field-types/mediatype.ts | 8 + .../field-types/number.test.ts | 69 +++ .../metadata-fields/field-types/number.ts | 8 + .../field-types/page-progression.test.ts | 21 + .../field-types/page-progression.ts | 14 + .../metadata-fields/field-types/string.ts | 8 + .../metadata-fields/metadata-field.test.ts | 119 ++++ .../metadata-fields/metadata-field.ts | 116 ++++ src/models/item-metadata/metadata.test.ts | 105 ++++ src/models/item-metadata/metadata.ts | 543 ++++++++++++++++++ src/models/item-metadata/review.test.ts | 59 ++ src/models/item-metadata/review.ts | 48 ++ .../item-metadata/speech-music-asr-entry.ts | 10 + src/models/item-metadata/task.ts | 13 + 28 files changed, 1623 insertions(+), 3 deletions(-) create mode 100644 src/models/item-metadata/file.test.ts create mode 100644 src/models/item-metadata/file.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/boolean.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/boolean.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/byte.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/byte.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/date.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/duration.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/duration.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/list.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/list.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/mediatype.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/mediatype.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/number.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/number.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/page-progression.test.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/page-progression.ts create mode 100644 src/models/item-metadata/metadata-fields/field-types/string.ts create mode 100644 src/models/item-metadata/metadata-fields/metadata-field.test.ts create mode 100644 src/models/item-metadata/metadata-fields/metadata-field.ts create mode 100644 src/models/item-metadata/metadata.test.ts create mode 100644 src/models/item-metadata/metadata.ts create mode 100644 src/models/item-metadata/review.test.ts create mode 100644 src/models/item-metadata/review.ts create mode 100644 src/models/item-metadata/speech-music-asr-entry.ts create mode 100644 src/models/item-metadata/task.ts diff --git a/package-lock.json b/package-lock.json index 87fc209..b5c0817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "typescript-memoize": "^1.1.1" }, "devDependencies": { "@open-wc/testing-helpers": "^3.0.1", @@ -7542,6 +7543,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-memoize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typescript-memoize/-/typescript-memoize-1.1.1.tgz", + "integrity": "sha512-GQ90TcKpIH4XxYTI2F98yEQYZgjNMOGPpOgdjIBhaLaWji5HPWlRnZ4AeA1hfBxtY7bCGDJsqDDHk/KaHOl5bA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 3006e61..a2a234c 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "ghpages:build": "wireit" }, "dependencies": { - "@lit/localize": "^0.12.2", "@internetarchive/ia-clearable-text-input": "^1.1.1", "@internetarchive/ia-dropdown": "^2.2.0", + "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "typescript-memoize": "^1.1.1" }, "devDependencies": { "@open-wc/testing-helpers": "^3.0.1", diff --git a/src/models/item-metadata/file.test.ts b/src/models/item-metadata/file.test.ts new file mode 100644 index 0000000..1e1e6e5 --- /dev/null +++ b/src/models/item-metadata/file.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; + +import { File } from './file'; + +describe('File', () => { + test('can be instantiated with an object', () => { + const file = new File({ name: 'foo.jpg' }); + expect(file.name).toBe('foo.jpg'); + }); + + test('properly instantiates modeled fields', () => { + const file = new File({ + name: 'foo.jpg', + size: '1234', + length: '1:23', + height: '1080', + width: '1920', + track: '1', + }); + expect(file.size).toBe(1234); + expect(file.length).toBe(83); + expect(file.height).toBe(1080); + expect(file.width).toBe(1920); + expect(file.track).toBe(1); + }); + + test('external_identifier can be a single value', () => { + const file = new File({ name: 'foo.jpg', external_identifier: 'bar' }); + expect(file.external_identifier).toBe('bar'); + }); + + test('external_identifier can be an array', () => { + const file = new File({ + name: 'foo.jpg', + external_identifier: ['foo', 'bar'], + }); + expect(file.external_identifier).toEqual(['foo', 'bar']); + }); + + test('handles falsy values properly', () => { + const file = new File({ + name: 'foo.jpg', + size: 0, + track: 0, + }); + expect(file.size).toBeDefined(); + expect(file.size).toBe(0); + expect(file.track).toBeDefined(); + expect(file.track).toBe(0); + }); + + test('parses mtime properly', () => { + const file = new File({ + name: 'foo.jpg', + mtime: '1639591034', + }); + expect(file.mtime).toBeDefined(); + expect(file.mtime instanceof Date).toBe(true); + expect(file.mtime?.getTime()).toBe(1639591034000); + }); +}); diff --git a/src/models/item-metadata/file.ts b/src/models/item-metadata/file.ts new file mode 100644 index 0000000..6aecbbd --- /dev/null +++ b/src/models/item-metadata/file.ts @@ -0,0 +1,109 @@ +import { Memoize } from 'typescript-memoize'; + +import { Byte, ByteParser } from '@src/parsers/field-types/byte'; +import { Duration, DurationParser } from '@src/parsers/field-types/duration'; +import { NumberParser } from '@src/parsers/field-types/number'; + +/** + * This represents an Internet Archive File + * + * @export + * @class File + */ +export class File { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly rawValue: Readonly>; + + get name(): string { + return this.rawValue.name; + } + + get source(): string { + return this.rawValue.source; + } + + get btih(): string { + return this.rawValue.btih; + } + + get md5(): string { + return this.rawValue.md5; + } + + get format(): string { + return this.rawValue.format; + } + + @Memoize() get mtime(): Date | undefined { + if (this.rawValue.mtime == null) { + return undefined; + } + const numberValue = NumberParser.shared.parseValue(this.rawValue.mtime); + if (numberValue) { + return new Date(numberValue * 1000); + } + } + + get crc32(): string { + return this.rawValue.crc32; + } + + get sha1(): string { + return this.rawValue.sha1; + } + + get original(): string | undefined { + return this.rawValue.original; + } + + @Memoize() get size(): Byte | undefined { + return this.rawValue.size != null + ? ByteParser.shared.parseValue(this.rawValue.size) + : undefined; + } + + get title(): string | undefined { + return this.rawValue.title; + } + + @Memoize() get length(): Duration | undefined { + return this.rawValue.length != null + ? DurationParser.shared.parseValue(this.rawValue.length) + : undefined; + } + + @Memoize() get height(): number | undefined { + return this.rawValue.height != null + ? NumberParser.shared.parseValue(this.rawValue.height) + : undefined; + } + + @Memoize() get width(): number | undefined { + return this.rawValue.width != null + ? NumberParser.shared.parseValue(this.rawValue.width) + : undefined; + } + + @Memoize() get track(): number | undefined { + return this.rawValue.track != null + ? NumberParser.shared.parseValue(this.rawValue.track) + : undefined; + } + + get external_identifier(): string | string[] | undefined { + return this.rawValue.external_identifier; + } + + get creator(): string | undefined { + return this.rawValue.creator; + } + + get album(): string | undefined { + return this.rawValue.album; + } + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(json: Record = {}) { + this.rawValue = json; + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/boolean.test.ts b/src/models/item-metadata/metadata-fields/field-types/boolean.test.ts new file mode 100644 index 0000000..80e64f4 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/boolean.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { BooleanField } from './boolean'; + +describe('Boolean Field', () => { + test('can parse true boolean value', () => { + const booleanField = new BooleanField(true); + + expect(booleanField.value).toBe(true); + expect(booleanField.values).toEqual([true]); + expect(booleanField.rawValue).toBe(true); + }); + + test('can parse false boolean value', () => { + const booleanField = new BooleanField(false); + + expect(booleanField.value).toBe(false); + expect(booleanField.values).toEqual([false]); + expect(booleanField.rawValue).toBe(false); + }); + + test('parses truthy values to true', () => { + const booleanField = new BooleanField('boop'); + + expect(booleanField.value).toBe(true); + expect(booleanField.values).toEqual([true]); + expect(booleanField.rawValue).toBe('boop'); + }); + + test('parses falsy values to false', () => { + const booleanField = new BooleanField(0); + + expect(booleanField.value).toBe(false); + expect(booleanField.values).toEqual([false]); + expect(booleanField.rawValue).toBe(0); + }); + + test('parses false string to false', () => { + const booleanField = new BooleanField('false'); + + expect(booleanField.value).toBe(false); + expect(booleanField.values).toEqual([false]); + expect(booleanField.rawValue).toBe('false'); + }); + + test('parses true string to true', () => { + const booleanField = new BooleanField('true'); + + expect(booleanField.value).toBe(true); + expect(booleanField.values).toEqual([true]); + expect(booleanField.rawValue).toBe('true'); + }); + + test('parses array of strings properly', () => { + const booleanField = new BooleanField(['true', 'false', 'true']); + + expect(booleanField.value).toBe(true); + expect(booleanField.values).toEqual([true, false, true]); + expect(booleanField.rawValue).toEqual(['true', 'false', 'true']); + }); + + test('parses array of booleans properly', () => { + const booleanField = new BooleanField([true, false, true]); + + expect(booleanField.value).toBe(true); + expect(booleanField.values).toEqual([true, false, true]); + expect(booleanField.rawValue).toEqual([true, false, true]); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/boolean.ts b/src/models/item-metadata/metadata-fields/field-types/boolean.ts new file mode 100644 index 0000000..4b31418 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/boolean.ts @@ -0,0 +1,8 @@ +import { BooleanParser } from '@src/parsers/field-types/boolean'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class BooleanField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(BooleanParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/byte.test.ts b/src/models/item-metadata/metadata-fields/field-types/byte.test.ts new file mode 100644 index 0000000..c7ac6c4 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/byte.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; + +import { ByteField } from './byte'; + +describe('ByteField Field', () => { + test('can parse a byte from a string', () => { + const field = new ByteField('123'); + + expect(field.value).toBe(123); + expect(field.values).toEqual([123]); + expect(field.rawValue).toBe('123'); + }); + + test('can parse a byte from a number', () => { + const field = new ByteField(123); + + expect(field.value).toBe(123); + expect(field.values).toEqual([123]); + expect(field.rawValue).toBe(123); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/byte.ts b/src/models/item-metadata/metadata-fields/field-types/byte.ts new file mode 100644 index 0000000..758c5c4 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/byte.ts @@ -0,0 +1,16 @@ +import { Byte, ByteParser } from '@src/parsers/field-types/byte'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +/** + * ByteField is a unit-specific number field that + * returns a value in bytes + * + * @export + * @class ByteField + * @extends {MetadataField} + */ +export class ByteField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(ByteParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/date.ts b/src/models/item-metadata/metadata-fields/field-types/date.ts new file mode 100644 index 0000000..fb14702 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/date.ts @@ -0,0 +1,8 @@ +import { DateParser } from '@src/parsers/field-types/date'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class DateField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(DateParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/duration.test.ts b/src/models/item-metadata/metadata-fields/field-types/duration.test.ts new file mode 100644 index 0000000..c2b7e02 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/duration.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; + +import { DurationField } from './duration'; + +describe('DurationField', () => { + test('can parse a seconds duration from a string', () => { + const field = new DurationField('123.5'); + + expect(field.value).toBe(123.5); + expect(field.values).toEqual([123.5]); + expect(field.rawValue).toBe('123.5'); + }); + + test('can parse a seconds duration from a number', () => { + const field = new DurationField(123.5); + + expect(field.value).toBe(123.5); + expect(field.values).toEqual([123.5]); + expect(field.rawValue).toBe(123.5); + }); + + test('can parse a hh:mm:ss duration', () => { + const field = new DurationField('1:23:45'); + + expect(field.value).toBe(5025); + expect(field.values).toEqual([5025]); + expect(field.rawValue).toBe('1:23:45'); + }); + + test('can parse a mm:ss duration', () => { + const field = new DurationField('1:23'); + + expect(field.value).toBe(83); + expect(field.values).toEqual([83]); + expect(field.rawValue).toBe('1:23'); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/duration.ts b/src/models/item-metadata/metadata-fields/field-types/duration.ts new file mode 100644 index 0000000..6f5410e --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/duration.ts @@ -0,0 +1,17 @@ +import { Duration, DurationParser } from '@src/parsers/field-types/duration'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +/** + * The DurationField parses different duration formats + * and returns a `Duration`, which is a number in seconds + * with decimals. + * + * @export + * @class DurationField + * @extends {MetadataField} + */ +export class DurationField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(DurationParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/list.test.ts b/src/models/item-metadata/metadata-fields/field-types/list.test.ts new file mode 100644 index 0000000..7c25238 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/list.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; + +import { NumberListField, StringListField } from './list'; + +describe('List Field', () => { + describe('String List Field', () => { + test('can parse individual values', () => { + const stringListField = new StringListField('foo'); + + expect(stringListField.value).toBe('foo'); + expect(stringListField.values).toEqual(['foo']); + expect(stringListField.rawValue).toBe('foo'); + }); + + test('can parse lists', () => { + const stringListField = new StringListField('foo, bar, baz'); + + expect(stringListField.value).toBe('foo'); + expect(stringListField.values).toEqual(['foo', 'bar', 'baz']); + expect(stringListField.rawValue).toBe('foo, bar, baz'); + }); + + test('can parse lists of lists', () => { + const stringListField = new StringListField([ + 'foo, bar, baz', + 'beep, boop, bop', + ]); + + expect(stringListField.value).toBe('foo'); + expect(stringListField.values).toEqual([ + 'foo', + 'bar', + 'baz', + 'beep', + 'boop', + 'bop', + ]); + expect(stringListField.rawValue).toEqual([ + 'foo, bar, baz', + 'beep, boop, bop', + ]); + }); + }); + + describe('NumberListField', () => { + test('can parse lists of numbers', () => { + const listField = new NumberListField('1, 2, 3'); + + expect(listField.value).toBe(1); + expect(listField.values).toEqual([1, 2, 3]); + expect(listField.rawValue).toBe('1, 2, 3'); + }); + + test('can parse lists of lists', () => { + const listField = new NumberListField(['1, 2, 3', '4, 5, 6']); + + expect(listField.value).toBe(1); + expect(listField.values).toEqual([1, 2, 3, 4, 5, 6]); + expect(listField.rawValue).toEqual(['1, 2, 3', '4, 5, 6']); + }); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/list.ts b/src/models/item-metadata/metadata-fields/field-types/list.ts new file mode 100644 index 0000000..e658635 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/list.ts @@ -0,0 +1,42 @@ +import type { FieldParserInterface } from '@src/parsers/field-parser-interface'; +import { ListParser } from '@src/parsers/field-types/list'; +import { NumberParser } from '@src/parsers/field-types/number'; +import { StringParser } from '@src/parsers/field-types/string'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +/** + * The ListField handles parsing of a list of values. + * + * Certain fields in the metadata, like `subject` typically have a + * comma or semicolon-separated list of values. The `ListField` + * parses the list values independently and aggregates them into + * the main `.values` array. + */ +export class ListField< + T, + FieldParserInterfaceType extends FieldParserInterface, +> extends MetadataField { + constructor(rawValue: MetadataRawValue, parser: FieldParserInterfaceType) { + super(parser, rawValue); + } +} + +/** + * The StringListField handles parsing of a list of strings. + */ +export class StringListField extends ListField> { + constructor(rawValue: MetadataRawValue) { + const parser = new ListParser(StringParser.shared); + super(rawValue, parser); + } +} + +/** + * The NumberListField handles parsing of a list of numbers. + */ +export class NumberListField extends ListField> { + constructor(rawValue: MetadataRawValue) { + const parser = new ListParser(NumberParser.shared); + super(rawValue, parser); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/mediatype.test.ts b/src/models/item-metadata/metadata-fields/field-types/mediatype.test.ts new file mode 100644 index 0000000..0e2105d --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/mediatype.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; + +import { MediaTypeField } from './mediatype'; + +describe('MediaTypeField Field', () => { + test('can parse a valid mediatype', () => { + const field = new MediaTypeField('movies'); + + expect(field.value).toBe('movies'); + expect(field.values).toEqual(['movies']); + expect(field.rawValue).toBe('movies'); + }); + + test('can parse an unrecognized mediatype string', () => { + const field = new MediaTypeField('blah'); + + expect(field.value).toBe('blah'); + expect(field.values).toEqual(['blah']); + expect(field.rawValue).toBe('blah'); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/mediatype.ts b/src/models/item-metadata/metadata-fields/field-types/mediatype.ts new file mode 100644 index 0000000..8872c20 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/mediatype.ts @@ -0,0 +1,8 @@ +import { MediaType, MediaTypeParser } from '@src/parsers/field-types/mediatype'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class MediaTypeField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(MediaTypeParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/number.test.ts b/src/models/item-metadata/metadata-fields/field-types/number.test.ts new file mode 100644 index 0000000..8930ad5 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/number.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { NumberField } from './number'; + +describe('Number Field', () => { + test('can parse number value', () => { + const field = new NumberField(1); + + expect(field.value).toBe(1); + expect(field.values).toEqual([1]); + expect(field.rawValue).toBe(1); + }); + + test('can parse string value', () => { + const field = new NumberField('1'); + + expect(field.value).toBe(1); + expect(field.values).toEqual([1]); + expect(field.rawValue).toBe('1'); + }); + + test('can parse decimal value string', () => { + const field = new NumberField('1.23'); + + expect(field.value).toBe(1.23); + expect(field.values).toEqual([1.23]); + expect(field.rawValue).toBe('1.23'); + }); + + test('can parse decimal value number', () => { + const field = new NumberField(1.23); + + expect(field.value).toBe(1.23); + expect(field.values).toEqual([1.23]); + expect(field.rawValue).toBe(1.23); + }); + + test('parses boolean false', () => { + const field = new NumberField(false); + + expect(field.value).toBeUndefined(); + expect(field.values).toEqual([]); + expect(field.rawValue).toBe(false); + }); + + test('parses non-numbers as undefined', () => { + const field = new NumberField('boop'); + + expect(field.value).toBeUndefined(); + expect(field.values).toEqual([]); + expect(field.rawValue).toBe('boop'); + }); + + test('parses array of strings properly', () => { + const field = new NumberField(['1', '2', '3']); + + expect(field.value).toBe(1); + expect(field.values).toEqual([1, 2, 3]); + expect(field.rawValue).toEqual(['1', '2', '3']); + }); + + test('parses array of numbers properly', () => { + const field = new NumberField([1, 2, 3]); + + expect(field.value).toBe(1); + expect(field.values).toEqual([1, 2, 3]); + expect(field.rawValue).toEqual([1, 2, 3]); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/number.ts b/src/models/item-metadata/metadata-fields/field-types/number.ts new file mode 100644 index 0000000..ac1d98c --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/number.ts @@ -0,0 +1,8 @@ +import { NumberParser } from '@src/parsers/field-types/number'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class NumberField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(NumberParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/page-progression.test.ts b/src/models/item-metadata/metadata-fields/field-types/page-progression.test.ts new file mode 100644 index 0000000..6c08b94 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/page-progression.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; + +import { PageProgressionField } from './page-progression'; + +describe('PageProgressionField', () => { + test('can parse a page progression', () => { + const field = new PageProgressionField('rl'); + + expect(field.value).toBe('rl'); + expect(field.values).toEqual(['rl']); + expect(field.rawValue).toBe('rl'); + }); + + test('accepts any value', () => { + const field = new PageProgressionField('blah'); + + expect(field.value).toBe('blah'); + expect(field.values).toEqual(['blah']); + expect(field.rawValue).toBe('blah'); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/field-types/page-progression.ts b/src/models/item-metadata/metadata-fields/field-types/page-progression.ts new file mode 100644 index 0000000..6c7267f --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/page-progression.ts @@ -0,0 +1,14 @@ +import { + PageProgression, + PageProgressionParser, +} from '@src/parsers/field-types/page-progression'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class PageProgressionField extends MetadataField< + PageProgression, + PageProgressionParser +> { + constructor(rawValue: MetadataRawValue) { + super(PageProgressionParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/field-types/string.ts b/src/models/item-metadata/metadata-fields/field-types/string.ts new file mode 100644 index 0000000..839420b --- /dev/null +++ b/src/models/item-metadata/metadata-fields/field-types/string.ts @@ -0,0 +1,8 @@ +import { StringParser } from '@src/parsers/field-types/string'; +import { MetadataField, MetadataRawValue } from '../metadata-field'; + +export class StringField extends MetadataField { + constructor(rawValue: MetadataRawValue) { + super(StringParser.shared, rawValue); + } +} diff --git a/src/models/item-metadata/metadata-fields/metadata-field.test.ts b/src/models/item-metadata/metadata-fields/metadata-field.test.ts new file mode 100644 index 0000000..b6306cb --- /dev/null +++ b/src/models/item-metadata/metadata-fields/metadata-field.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'vitest'; + +import type { FieldParserInterface } from '@src/parsers/field-parser-interface'; + +import { MetadataField } from './metadata-field'; + +describe('Metadata Field', () => { + test('can be properly instantiated with single value', () => { + class MockParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): string { + return rawValue; + } + } + + const parser = new MockParser(); + const metadataField = new MetadataField(parser, 'foo'); + + expect(metadataField.rawValue).toBe('foo'); + expect(metadataField.value).toBe('foo'); + expect(metadataField.values).toEqual(['foo']); + }); + + test('can be properly instantiated with array value', () => { + class MockParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): string { + return rawValue; + } + } + + const parser = new MockParser(); + const metadataField = new MetadataField(parser, ['foo', 'bar', 'baz']); + + expect(metadataField.rawValue).toEqual(['foo', 'bar', 'baz']); + expect(metadataField.value).toBe('foo'); + expect(metadataField.values).toEqual(['foo', 'bar', 'baz']); + }); + + test('properly casts values to expected parser type for single values', () => { + class MockFloatParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): number { + return parseFloat(rawValue); + } + } + + const parser = new MockFloatParser(); + const metadataField = new MetadataField(parser, '1.3'); + + expect(metadataField.rawValue).toBe('1.3'); + expect(metadataField.value).toBe(1.3); + expect(metadataField.values).toEqual([1.3]); + }); + + test('properly casts values to expected parser type for array values', () => { + class MockFloatParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): number { + return parseFloat(rawValue); + } + } + + const parser = new MockFloatParser(); + const metadataField = new MetadataField(parser, ['1.3', '2.4', '4.5']); + + expect(metadataField.rawValue).toEqual(['1.3', '2.4', '4.5']); + expect(metadataField.value).toBe(1.3); + expect(metadataField.values).toEqual([1.3, 2.4, 4.5]); + }); + + test('handles falsy `0` return values properly', () => { + class MockFloatParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): number { + return rawValue; + } + } + + const parser = new MockFloatParser(); + const metadataField = new MetadataField(parser, 0); + + expect(metadataField.rawValue).toBe(0); + expect(metadataField.value).toBe(0); + expect(metadataField.values).toEqual([0]); + }); + + test('handles falsy `false` return values properly', () => { + class MockFloatParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): number { + return rawValue; + } + } + + const parser = new MockFloatParser(); + const metadataField = new MetadataField(parser, false); + + expect(metadataField.rawValue).toBe(false); + expect(metadataField.value).toBe(false); + expect(metadataField.values).toEqual([false]); + }); + + test('handles falsy empty string return values properly', () => { + class MockFloatParser implements FieldParserInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseValue(rawValue: any): number { + return rawValue; + } + } + + const parser = new MockFloatParser(); + const metadataField = new MetadataField(parser, ''); + + expect(metadataField.rawValue).toBe(''); + expect(metadataField.value).toBe(''); + expect(metadataField.values).toEqual(['']); + }); +}); diff --git a/src/models/item-metadata/metadata-fields/metadata-field.ts b/src/models/item-metadata/metadata-fields/metadata-field.ts new file mode 100644 index 0000000..08082c7 --- /dev/null +++ b/src/models/item-metadata/metadata-fields/metadata-field.ts @@ -0,0 +1,116 @@ +import { Memoize } from 'typescript-memoize'; + +import type { FieldParserInterface } from '@src/parsers/field-parser-interface'; + +/** + * The MetadataRawValue is all of the possible raw types we can get for a field. + * + * This allows the parsers to know if they can handle the raw value or not and + * how to handle it if they can. + */ +export type MetadataRawValue = + | string + | string[] + | number + | number[] + | boolean + | boolean[]; + +export interface MetadataFieldInterface { + /** + * The raw value received from the API response + * + * @type {MetadataRawValue} + * @memberof MetadataField + */ + readonly rawValue: Readonly; + + /** + * The first value if there are multiple or the only value if there is one + * + * @readonly + * @type {(T | undefined)} + * @memberof MetadataField + */ + value?: T; + + /** + * The array of all values for the field. + * + * Many fields only contain a single value and + * can be accessed via the `.value` getter + * + * @type {T[]} + * @memberof MetadataField + */ + values: T[]; +} + +/** + * The MetadataField is responsible for three things: + * 1. Take in some raw data (strings, arrays, numbers, etc) + * 2. Normalize the input to an array of the input, + * ie. [string, string], [number, number], [Date, Date], etc + * 3. Cast the values to their expected `Type` + * + * This class gets instiated with a `Type` and a parser of that type. For instance, the + * `DateField` is a subclass of `MetadataField` with a `Type` of `Date` and a `DateParser`. + * + * When using a `DateField`, you can pass it a string date and it will cast it to a javascript Date, + * ie: + * + * ``` + * const dateField = new DateField('2020-02-13') + * dateField.value = Date(2020-02-13) // native javascript Date object + * dateField.values = [Date(2020-02-13)] // the normalized array of values + * dateField.rawValue = '2020-02-13' // the raw string that was passed in + * ``` + * + * @class MetadataField + * @template Type The type of metadata this is (string, number, Date, etc) + * @template FieldParserInterfaceType The parser for that type (StringParser, NumberParser, etc) + */ +export class MetadataField< + Type, + FieldParserInterfaceType extends FieldParserInterface, +> implements MetadataFieldInterface +{ + /** @inheritdoc */ + readonly rawValue: Readonly; + + /** @inheritdoc */ + @Memoize() get values(): Type[] { + const values = this.parseRawValue(); + return values; + } + + /** @inheritdoc */ + @Memoize() get value(): Type | undefined { + return this.values[0]; + } + + constructor(parser: FieldParserInterfaceType, rawValue: MetadataRawValue) { + this.parser = parser; + this.rawValue = rawValue; + } + + private parser: FieldParserInterfaceType; + + private parseRawValue(): Type[] { + const rawValues = Array.isArray(this.rawValue) + ? this.rawValue + : [this.rawValue]; + + const values: Type[] = []; + rawValues.forEach(value => { + const parsed = this.parser.parseValue(value); + if (Array.isArray(parsed)) { + values.push(...parsed); + } else if (parsed !== undefined) { + values.push(parsed); + } + }); + + return values; + } +} diff --git a/src/models/item-metadata/metadata.test.ts b/src/models/item-metadata/metadata.test.ts new file mode 100644 index 0000000..a817cee --- /dev/null +++ b/src/models/item-metadata/metadata.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'vitest'; + +import { Metadata } from './metadata'; + +describe('Metadata', () => { + test('properly instantiates metadata with no data', () => { + const metadata = new Metadata(); + expect(metadata.identifier).toBeUndefined(); + expect(metadata.collection).toBeUndefined(); + }); + + test('properly instantiates metadata with identifier', () => { + const json = { identifier: 'foo', collection: 'bar' }; + const metadata = new Metadata(json); + expect(metadata.identifier).toBe('foo'); + }); + + test('properly instantiates metadata with addeddate', () => { + const json = { identifier: 'foo', addeddate: '2021-05-20T13:37:15Z' }; + const metadata = new Metadata(json); + + const expected = new Date(); + expected.setUTCHours(13); + expected.setUTCMinutes(37); + expected.setUTCSeconds(15); + expected.setUTCMilliseconds(0); + expected.setUTCMonth(4); + expected.setUTCDate(20); + expected.setUTCFullYear(2021); + + expect(metadata.addeddate?.value?.getTime()).toBe(expected.getTime()); + }); + + test('properly instantiates metadata with audio_codec', () => { + const json = { identifier: 'foo', audio_codec: 'boop' }; + const metadata = new Metadata(json); + expect(metadata.audio_codec?.value).toBe('boop'); + }); + + test('properly instantiates metadata with audio_sample_rate', () => { + const json = { identifier: 'foo', audio_sample_rate: '123' }; + const metadata = new Metadata(json); + expect(metadata.audio_sample_rate?.value).toBe(123); + }); + + test('properly instantiates metadata with external-identifier', () => { + const json = { identifier: 'foo', 'external-identifier': ['abc', '123'] }; + const metadata = new Metadata(json); + expect(metadata.external_identifier?.values).toEqual(['abc', '123']); + }); + + test('returns undefined for fields that have not been provided', () => { + const json = { identifier: 'foo', collection: ['abc', '123'] }; + const metadata = new Metadata(json); + expect(metadata.runtime?.value).toBeUndefined(); + }); + + test('accepts fields that have not been modeled', () => { + const json = { identifier: 'foo', foo: ['abc', '123'] }; + const metadata = new Metadata(json); + expect(metadata.rawMetadata.foo).toEqual(['abc', '123']); + }); + + test('models the year as a NumberField, string value', () => { + const json = { identifier: 'foo', year: '1982' }; + const metadata = new Metadata(json); + expect(metadata.year?.value).toBe(1982); + }); + + test('models the year as a NumberField, number value', () => { + const json = { identifier: 'foo', year: 1982 }; + const metadata = new Metadata(json); + expect(metadata.year?.value).toBe(1982); + }); + + test('properly handles falsy number values', () => { + const json = { + identifier: 'foo', + year: 0, + duration: 0, + collection_size: 0, + }; + const metadata = new Metadata(json); + expect(metadata.year).toBeDefined(); + expect(metadata.year?.value).toBe(0); + expect(metadata.duration).toBeDefined(); + expect(metadata.duration?.value).toBe(0); + expect(metadata.collection_size).toBeDefined(); + expect(metadata.collection_size?.value).toBe(0); + }); + + test('properly handles falsy boolean values', () => { + const json = { identifier: 'foo', noindex: false }; + const metadata = new Metadata(json); + expect(metadata.noindex).toBeDefined(); + expect(metadata.noindex?.value).toBe(false); + }); + + test('properly handles falsy string values', () => { + const json = { identifier: 'foo', description: '' }; + const metadata = new Metadata(json); + expect(metadata.description).toBeDefined(); + expect(metadata.description?.value).toBe(''); + }); +}); diff --git a/src/models/item-metadata/metadata.ts b/src/models/item-metadata/metadata.ts new file mode 100644 index 0000000..7d7f34c --- /dev/null +++ b/src/models/item-metadata/metadata.ts @@ -0,0 +1,543 @@ +import { Memoize } from 'typescript-memoize'; +import { BooleanField } from './metadata-fields/field-types/boolean'; +import { DateField } from './metadata-fields/field-types/date'; +import { DurationField } from './metadata-fields/field-types/duration'; +import { NumberField } from './metadata-fields/field-types/number'; +import { StringField } from './metadata-fields/field-types/string'; +import { PageProgressionField } from './metadata-fields/field-types/page-progression'; +import { ByteField } from './metadata-fields/field-types/byte'; +import { MediaTypeField } from './metadata-fields/field-types/mediatype'; +import { StringListField } from './metadata-fields/field-types/list'; + +/** + * Metadata is an expansive model that describes an Item. + * + * The fields in here get casted to their respective field types. See `metadata-fields/field-type`. + * + * Add additional fields as needed. + * + * @export + * @class Metadata + */ +export class Metadata { + /** + * This is the raw metadata reponse; useful for inspecting the raw data returned from the server. + * + * @type { string: any } + * @memberof Metadata + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly rawMetadata: Readonly>; + + /** + * The item identifier. + * + * _Note_ This is a plain string instead of a `MetadataField` since it + * will only ever be a string and not an array. + * + * @type {string} + * @memberof Metadata + */ + get identifier(): string | undefined { + return this.rawMetadata.identifier; + } + + @Memoize() get addeddate(): DateField | undefined { + return this.rawMetadata.addeddate != null + ? new DateField(this.rawMetadata.addeddate) + : undefined; + } + + @Memoize() get audio_codec(): StringField | undefined { + return this.rawMetadata.audio_codec != null + ? new StringField(this.rawMetadata.audio_codec) + : undefined; + } + + @Memoize() get audio_sample_rate(): NumberField | undefined { + return this.rawMetadata.audio_sample_rate != null + ? new NumberField(this.rawMetadata.audio_sample_rate) + : undefined; + } + + @Memoize() get avg_rating(): NumberField | undefined { + return this.rawMetadata.avg_rating != null + ? new NumberField(this.rawMetadata.avg_rating) + : undefined; + } + + /** + * All of the collections that an Item is in, including + * all of the side-loaded collections from the ListAPI + * and SimpleListsAPI like `fav-*` + * + * @type {StringField} + * @memberof Metadata + */ + @Memoize() get collection(): StringField | undefined { + return this.rawMetadata.collection != null + ? new StringField(this.rawMetadata.collection) + : undefined; + } + + /** + * The "natural" collections for an item before augmentation + * by side-loaded collections like ListsAPI and SimpleLists + * + * The `collection` field above includes things like all of + * the `fav-*` collections, whereas this is only the collections + * that have been directly added in the hierarchy. + * + * @type {StringField} + * @memberof Metadata + */ + @Memoize() get collections_raw(): StringField | undefined { + return this.rawMetadata.collections_raw != null + ? new StringField(this.rawMetadata.collections_raw) + : undefined; + } + + /** + * The size of a collection in bytes + * + * @type {ByteField} + * @memberof Metadata + */ + @Memoize() get collection_size(): ByteField | undefined { + return this.rawMetadata.collection_size != null + ? new ByteField(this.rawMetadata.collection_size) + : undefined; + } + + @Memoize() get contact(): StringField | undefined { + return this.rawMetadata.contact != null + ? new StringField(this.rawMetadata.contact) + : undefined; + } + + @Memoize() get contributor(): StringField | undefined { + return this.rawMetadata.contributor != null + ? new StringField(this.rawMetadata.contributor) + : undefined; + } + + @Memoize() get coverage(): StringField | undefined { + return this.rawMetadata.coverage != null + ? new StringField(this.rawMetadata.coverage) + : undefined; + } + + @Memoize() get creator(): StringField | undefined { + return this.rawMetadata.creator != null + ? new StringField(this.rawMetadata.creator) + : undefined; + } + + @Memoize() get creator_alt_script(): StringField | undefined { + return this.rawMetadata['creator-alt-script'] != null + ? new StringField(this.rawMetadata['creator-alt-script']) + : undefined; + } + + @Memoize() get credits(): StringField | undefined { + return this.rawMetadata.credits != null + ? new StringField(this.rawMetadata.credits) + : undefined; + } + + @Memoize() get collection_layout(): StringField | undefined { + return this.rawMetadata.collection_layout != null + ? new StringField(this.rawMetadata.collection_layout) + : undefined; + } + + @Memoize() get date(): DateField | undefined { + return this.rawMetadata.date != null + ? new DateField(this.rawMetadata.date) + : undefined; + } + + @Memoize() get description(): StringField | undefined { + return this.rawMetadata.description != null + ? new StringField(this.rawMetadata.description) + : undefined; + } + + /** + * All time download count + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get downloads(): NumberField | undefined { + return this.rawMetadata.downloads != null + ? new NumberField(this.rawMetadata.downloads) + : undefined; + } + + /** + * The item duration in seconds + * + * @type {DurationField} + * @memberof Metadata + */ + @Memoize() get duration(): DurationField | undefined { + return this.rawMetadata.duration != null + ? new DurationField(this.rawMetadata.duration) + : undefined; + } + + @Memoize() get external_identifier(): StringField | undefined { + return this.rawMetadata['external-identifier'] != null + ? new StringField(this.rawMetadata['external-identifier']) + : undefined; + } + + @Memoize() get external_link(): StringField | undefined { + return this.rawMetadata['external-link'] != null + ? new StringField(this.rawMetadata['external-link']) + : undefined; + } + + /** + * The number of files in an item + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get files_count(): NumberField | undefined { + return this.rawMetadata.files_count != null + ? new NumberField(this.rawMetadata.files_count) + : undefined; + } + + @Memoize() get indexdate(): DateField | undefined { + return this.rawMetadata.indexdate != null + ? new DateField(this.rawMetadata.indexdate) + : undefined; + } + + @Memoize() get isbn(): StringField | undefined { + return this.rawMetadata.isbn != null + ? new StringField(this.rawMetadata.isbn) + : undefined; + } + + @Memoize() get issue(): StringField | undefined { + return this.rawMetadata.issue != null + ? new StringField(this.rawMetadata.issue) + : undefined; + } + + /** + * For collections, the number of items in the collection + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get item_count(): NumberField | undefined { + return this.rawMetadata.item_count != null + ? new NumberField(this.rawMetadata.item_count) + : undefined; + } + + /** + * The size of the item in bytes + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get item_size(): ByteField | undefined { + return this.rawMetadata.item_size != null + ? new ByteField(this.rawMetadata.item_size) + : undefined; + } + + @Memoize() get language(): StringField | undefined { + return this.rawMetadata.language != null + ? new StringField(this.rawMetadata.language) + : undefined; + } + + @Memoize() get length(): DurationField | undefined { + return this.rawMetadata.length != null + ? new DurationField(this.rawMetadata.length) + : undefined; + } + + @Memoize() get licenseurl(): StringField | undefined { + return this.rawMetadata.licenseurl != null + ? new StringField(this.rawMetadata.licenseurl) + : undefined; + } + + @Memoize() get lineage(): StringField | undefined { + return this.rawMetadata.lineage != null + ? new StringField(this.rawMetadata.lineage) + : undefined; + } + + /** + * The number of downloads in the last month + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get month(): NumberField | undefined { + return this.rawMetadata.month != null + ? new NumberField(this.rawMetadata.month) + : undefined; + } + + @Memoize() get mediatype(): MediaTypeField | undefined { + return this.rawMetadata.mediatype != null + ? new MediaTypeField(this.rawMetadata.mediatype) + : undefined; + } + + @Memoize() get noindex(): BooleanField | undefined { + return this.rawMetadata.noindex != null + ? new BooleanField(this.rawMetadata.noindex) + : undefined; + } + + @Memoize() get notes(): StringField | undefined { + return this.rawMetadata.notes != null + ? new StringField(this.rawMetadata.notes) + : undefined; + } + + /** + * The number of users that have favorited the item + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get num_favorites(): NumberField | undefined { + return this.rawMetadata.num_favorites != null + ? new NumberField(this.rawMetadata.num_favorites) + : undefined; + } + + @Memoize() get num_reviews(): NumberField | undefined { + return this.rawMetadata.num_reviews != null + ? new NumberField(this.rawMetadata.num_reviews) + : undefined; + } + + @Memoize() get openlibrary_edition(): StringField | undefined { + return this.rawMetadata.openlibrary_edition != null + ? new StringField(this.rawMetadata.openlibrary_edition) + : undefined; + } + + @Memoize() get openlibrary_work(): StringField | undefined { + return this.rawMetadata.openlibrary_work != null + ? new StringField(this.rawMetadata.openlibrary_work) + : undefined; + } + + @Memoize() get page_progression(): PageProgressionField | undefined { + return this.rawMetadata.page_progression != null + ? new PageProgressionField(this.rawMetadata.page_progression) + : undefined; + } + + @Memoize() get paginated(): BooleanField | undefined { + return this.rawMetadata.paginated != null + ? new BooleanField(this.rawMetadata.paginated) + : undefined; + } + + @Memoize() get partner(): StringField | undefined { + return this.rawMetadata.partner != null + ? new StringField(this.rawMetadata.partner) + : undefined; + } + + @Memoize() get post_text(): StringField | undefined { + return this.rawMetadata.post_text != null + ? new StringField(this.rawMetadata.post_text) + : undefined; + } + + @Memoize() get ppi(): NumberField | undefined { + return this.rawMetadata.ppi != null + ? new NumberField(this.rawMetadata.ppi) + : undefined; + } + + @Memoize() get publicdate(): DateField | undefined { + return this.rawMetadata.publicdate != null + ? new DateField(this.rawMetadata.publicdate) + : undefined; + } + + @Memoize() get publisher(): StringField | undefined { + return this.rawMetadata.publisher != null + ? new StringField(this.rawMetadata.publisher) + : undefined; + } + + @Memoize() get reviewdate(): DateField | undefined { + return this.rawMetadata.reviewdate != null + ? new DateField(this.rawMetadata.reviewdate) + : undefined; + } + + @Memoize() get rights(): StringField | undefined { + return this.rawMetadata.rights != null + ? new StringField(this.rawMetadata.rights) + : undefined; + } + + @Memoize() get rights_holder(): StringField | undefined { + const value = + this.rawMetadata['rights-holder'] ?? this.rawMetadata.rights_holder; + + return value != null ? new StringField(value) : undefined; + } + + @Memoize() get runtime(): DurationField | undefined { + return this.rawMetadata.runtime != null + ? new DurationField(this.rawMetadata.runtime) + : undefined; + } + + @Memoize() get scanner(): StringField | undefined { + return this.rawMetadata.scanner != null + ? new StringField(this.rawMetadata.scanner) + : undefined; + } + + @Memoize() get segments(): StringField | undefined { + return this.rawMetadata.segments != null + ? new StringField(this.rawMetadata.segments) + : undefined; + } + + @Memoize() get shotlist(): StringField | undefined { + return this.rawMetadata.shotlist != null + ? new StringField(this.rawMetadata.shotlist) + : undefined; + } + + @Memoize() get source(): StringField | undefined { + return this.rawMetadata.source != null + ? new StringField(this.rawMetadata.source) + : undefined; + } + + @Memoize() get sponsor(): StringField | undefined { + return this.rawMetadata.sponsor != null + ? new StringField(this.rawMetadata.sponsor) + : undefined; + } + + @Memoize() get start_localtime(): DateField | undefined { + return this.rawMetadata.start_localtime != null + ? new DateField(this.rawMetadata.start_localtime) + : undefined; + } + + @Memoize() get start_time(): DateField | undefined { + return this.rawMetadata.start_time != null + ? new DateField(this.rawMetadata.start_time) + : undefined; + } + + @Memoize() get stop_time(): DateField | undefined { + return this.rawMetadata.stop_time != null + ? new DateField(this.rawMetadata.stop_time) + : undefined; + } + + @Memoize() get subject(): StringListField | undefined { + return this.rawMetadata.subject != null + ? new StringListField(this.rawMetadata.subject) + : undefined; + } + + @Memoize() get taper(): StringField | undefined { + return this.rawMetadata.taper != null + ? new StringField(this.rawMetadata.taper) + : undefined; + } + + @Memoize() get title(): StringField | undefined { + return this.rawMetadata.title != null + ? new StringField(this.rawMetadata.title) + : undefined; + } + + @Memoize() get title_alt_script(): StringField | undefined { + return this.rawMetadata['title-alt-script'] != null + ? new StringField(this.rawMetadata['title-alt-script']) + : undefined; + } + + @Memoize() get transferer(): StringField | undefined { + return this.rawMetadata.transferer != null + ? new StringField(this.rawMetadata.transferer) + : undefined; + } + + @Memoize() get track(): NumberField | undefined { + return this.rawMetadata.track != null + ? new NumberField(this.rawMetadata.track) + : undefined; + } + + @Memoize() get type(): StringField | undefined { + return this.rawMetadata.type != null + ? new StringField(this.rawMetadata.type) + : undefined; + } + + @Memoize() get uploader(): StringField | undefined { + return this.rawMetadata.uploader != null + ? new StringField(this.rawMetadata.uploader) + : undefined; + } + + @Memoize() get utc_offset(): NumberField | undefined { + return this.rawMetadata.utc_offset != null + ? new NumberField(this.rawMetadata.utc_offset) + : undefined; + } + + @Memoize() get venue(): StringField | undefined { + return this.rawMetadata.venue != null + ? new StringField(this.rawMetadata.venue) + : undefined; + } + + @Memoize() get volume(): StringField | undefined { + return this.rawMetadata.volume != null + ? new StringField(this.rawMetadata.volume) + : undefined; + } + + /** + * The number of downloads in the last week + * + * @type {NumberField} + * @memberof Metadata + */ + @Memoize() get week(): NumberField | undefined { + return this.rawMetadata.week != null + ? new NumberField(this.rawMetadata.week) + : undefined; + } + + @Memoize() get year(): NumberField | undefined { + return this.rawMetadata.year != null + ? new NumberField(this.rawMetadata.year) + : undefined; + } + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(json: Record = {}) { + this.rawMetadata = json; + } +} diff --git a/src/models/item-metadata/review.test.ts b/src/models/item-metadata/review.test.ts new file mode 100644 index 0000000..b1322ae --- /dev/null +++ b/src/models/item-metadata/review.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; + +import { Review } from './review'; + +describe('Review', () => { + test('can be instantiated with a title', () => { + const review = new Review({ + reviewtitle: 'It was awesome!', + }); + expect(review.reviewtitle).toBe('It was awesome!'); + }); + + test('stars get converted to a number', () => { + const review = new Review({ + stars: '5', + }); + expect(review.stars).toBe(5); + }); + + test('reviewdate get converted to a date', () => { + const review = new Review({ + reviewdate: '2014-05-09 09:47:15', + }); + + const expected = new Date(); + expected.setHours(9); + expected.setMinutes(47); + expected.setSeconds(15); + expected.setMilliseconds(0); + expected.setMonth(4); + expected.setDate(9); + expected.setFullYear(2014); + + expect(review.reviewdate?.getTime()).toBe(expected.getTime()); + }); + + test('handles falsy values properly', () => { + const review = new Review({ + reviewtitle: 'yay', + reviewdate: '0', + stars: 0, + }); + + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2000); + + expect(review.reviewtitle).toBe('yay'); + expect(review.reviewdate).toBeDefined(); + expect(review.reviewdate?.getTime()).toBe(expected.getTime()); + expect(review.stars).toBeDefined(); + expect(review.stars).toBe(0); + }); +}); diff --git a/src/models/item-metadata/review.ts b/src/models/item-metadata/review.ts new file mode 100644 index 0000000..76e8e77 --- /dev/null +++ b/src/models/item-metadata/review.ts @@ -0,0 +1,48 @@ +import { Memoize } from 'typescript-memoize'; + +import { DateParser } from '@src/parsers/field-types/date'; +import { NumberParser } from '@src/parsers/field-types/number'; + +export class Review { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly rawValue: Readonly>; + + get reviewbody(): string | undefined { + return this.rawValue.reviewbody; + } + + get reviewtitle(): string | undefined { + return this.rawValue.reviewtitle; + } + + get reviewer(): string | undefined { + return this.rawValue.reviewer; + } + + get reviewer_itemname(): string | undefined { + return this.rawValue.reviewer_itemname; + } + + @Memoize() get reviewdate(): Date | undefined { + return this.rawValue.reviewdate != null + ? DateParser.shared.parseValue(this.rawValue.reviewdate) + : undefined; + } + + @Memoize() get createdate(): Date | undefined { + return this.rawValue.createdate != null + ? DateParser.shared.parseValue(this.rawValue.createdate) + : undefined; + } + + @Memoize() get stars(): number | undefined { + return this.rawValue.stars != null + ? NumberParser.shared.parseValue(this.rawValue.stars) + : undefined; + } + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(json: Record = {}) { + this.rawValue = json; + } +} diff --git a/src/models/item-metadata/speech-music-asr-entry.ts b/src/models/item-metadata/speech-music-asr-entry.ts new file mode 100644 index 0000000..63904c5 --- /dev/null +++ b/src/models/item-metadata/speech-music-asr-entry.ts @@ -0,0 +1,10 @@ +/** + * This is the format used for radio transcripts + */ +export type SpeechMusicASREntry = { + readonly end: number; + readonly id: number; + readonly is_music: boolean; + readonly start: number; + readonly text: string; +}; diff --git a/src/models/item-metadata/task.ts b/src/models/item-metadata/task.ts new file mode 100644 index 0000000..129e620 --- /dev/null +++ b/src/models/item-metadata/task.ts @@ -0,0 +1,13 @@ +export type TaskColor = 'red' | 'green' | 'blue' | 'purple' | 'brown'; + +export type TaskStatus = 'error' | 'queued' | 'running' | 'passed' | 'paused'; + +export type Task = { + task_id: number; + cmd: string; + priority: number; + wait_admin: number; + args: Record; + color: TaskColor; + status: TaskStatus; +}; From 44bf0923fc8aebb40deb12d95cb5c7a29391975f Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 12:59:41 -0700 Subject: [PATCH 2/2] WEBDEV-8509: Add item-metadata demo story Add an interactive `item-metadata-story` built on the shared `story-template`. Edit a raw archive.org metadata JSON record and watch the `Metadata` model lazily cast each field to its typed `MetadataField` (Dates, numbers, byte counts, normalized multi-value arrays). Broaden the demo `app-root.ts` glob to `../src/**/*-story.ts` so stories outside `elements`/`labs` are auto-discovered. Co-Authored-By: Claude Opus 4.8 --- demo/app-root.ts | 86 ++++--- .../item-metadata/item-metadata-story.ts | 226 ++++++++++++++++++ 2 files changed, 277 insertions(+), 35 deletions(-) create mode 100644 src/models/item-metadata/item-metadata-story.ts diff --git a/demo/app-root.ts b/demo/app-root.ts index 89f9ab1..eb66975 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -4,13 +4,12 @@ import { customElement } from 'lit/decorators.js'; // Lit's html`` tag cannot render variable tag names directly. import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -const storyModules = import.meta.glob( - ['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'], - { eager: true } -); +const storyModules = import.meta.glob(['../src/**/*-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" @@ -19,13 +18,15 @@ const storyEntries = Object.keys(storyModules) }) .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 +35,42 @@ 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 +78,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/src/models/item-metadata/item-metadata-story.ts b/src/models/item-metadata/item-metadata-story.ts new file mode 100644 index 0000000..ba2d87a --- /dev/null +++ b/src/models/item-metadata/item-metadata-story.ts @@ -0,0 +1,226 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import '@demo/story-template'; +import { Metadata } from './metadata'; + +/** + * Interactive demo for the `Metadata` model. Edit the raw metadata JSON (as it + * comes back from the archive.org metadata API) and watch the model cast each + * raw field to its typed `MetadataField` โ€” Dates, numbers, byte counts, and + * normalized multi-value arrays. + */ + +const SAMPLE_JSON = JSON.stringify( + { + identifier: 'goody', + title: 'The Goody Collection', + mediatype: 'texts', + date: '1936-05-01', + publicdate: '2008-04-15 10:32:18', + downloads: '12843', + duration: '1:02:03', + item_size: '1572864', + collection: ['goody', 'americana', 'opensource'], + description: 'A sample item used to demonstrate the Metadata model.', + }, + null, + 2, +); + +interface FieldRow { + label: string; + get: (m: Metadata) => unknown; +} + +// A representative slice of the model's typed getters. `identifier` is a plain +// string; the rest return `MetadataField`s exposing `.value` / `.values`. +const FIELDS: FieldRow[] = [ + { label: 'identifier', get: (m) => m.identifier }, + { label: 'title', get: (m) => m.title?.value }, + { label: 'mediatype', get: (m) => m.mediatype?.value }, + { label: 'date', get: (m) => m.date?.value }, + { label: 'publicdate', get: (m) => m.publicdate?.value }, + { label: 'downloads', get: (m) => m.downloads?.value }, + { label: 'duration', get: (m) => m.duration?.value }, + { label: 'item_size', get: (m) => m.item_size?.value }, + { label: 'collection', get: (m) => m.collection?.values }, + { label: 'description', get: (m) => m.description?.value }, +]; + +const EXAMPLE_USAGE = `const metadata = new Metadata(rawMetadataJson); + +metadata.identifier; // 'goody' (string) +metadata.title?.value; // 'The Goody Collection' (string) +metadata.date?.value; // Date โ€” parsed from '1936-05-01' +metadata.downloads?.value; // 12843 (number) +metadata.item_size?.value; // 1572864 (byte count) +metadata.collection?.values; // ['goody', 'americana', 'opensource']`; + +@customElement('item-metadata-story') +export class ItemMetadataStory extends LitElement { + @state() private rawJson = SAMPLE_JSON; + + render() { + const { metadata, parseError } = this.parse(); + return html` + +
+

+ The Metadata model wraps a raw archive.org metadata + record and lazily casts each field to a typed + MetadataField. Edit the JSON to see the parsed, + type-cast results update. +

+ +
+ + +
+ Parsed fields + ${parseError + ? html`

Invalid JSON: ${parseError}

` + : html` + + + + + + + + + + ${FIELDS.map((f) => { + const value = metadata ? f.get(metadata) : undefined; + return html` + + + + `; + })} + +
FieldParsed valueType
${f.label}${this.format(value)}${this.typeLabel(value)}
+ `} +
+
+
+
+ `; + } + + private parse(): { metadata?: Metadata; parseError?: string } { + try { + return { metadata: new Metadata(JSON.parse(this.rawJson)) }; + } catch (e) { + return { parseError: (e as Error).message }; + } + } + + private onInput(e: Event) { + this.rawJson = (e.target as HTMLTextAreaElement).value; + } + + private format(value: unknown): string { + if (value === undefined || value === null) return 'โ€”'; + 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 || value === null) return 'undefined'; + if (value instanceof Date) return 'Date'; + if (Array.isArray(value)) return 'array'; + return typeof value; + } + + static styles = css` + .intro { + margin-top: 0; + max-width: 40rem; + } + + .cols { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + + .json { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.75rem; + font-weight: 600; + color: #666; + flex: 1 1 18rem; + } + + textarea { + font-family: ui-monospace, monospace; + font-size: 0.8rem; + min-height: 16rem; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + resize: vertical; + } + + .parsed { + flex: 1 1 20rem; + } + + .parsed-label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: #666; + margin-bottom: 4px; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + } + + th { + text-align: left; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #888; + border-bottom: 1px solid #ccc; + padding: 4px 6px; + } + + td { + padding: 4px 6px; + border-bottom: 1px solid #eee; + vertical-align: top; + } + + td.type, + th:last-child { + color: #888; + font-size: 0.75rem; + white-space: nowrap; + } + + .error { + color: #a00; + font-size: 0.85rem; + } + `; +}