diff --git a/README.md b/README.md index 00bd2d0a..486b6f04 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,12 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet | [RDF](https://feedsmith.dev/reference/feeds/rdf) | 0.9, 1.0 | ✅ | 📋 | | [JSON Feed](https://feedsmith.dev/reference/feeds/json) | 1.0, 1.1 | ✅ | ✅ | -### Other +### Related Formats | Format | Versions | Parse | Generate | |--------|----------|-------|----------| -| [OPML](https://feedsmith.dev/reference/other/opml) | 1.0, 2.0 | ✅ | ✅ | +| [OPML](https://feedsmith.dev/reference/opml) | 1.0, 2.0 | ✅ | ✅ | +| [Podcast Chapters](https://feedsmith.dev/reference/related/podcast-chapters) | 1.2 | ✅ | ✅ | ### Feed Namespaces diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3033cf6f..ecca7ff1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -145,8 +145,12 @@ export default defineConfig({ ], }, { - text: 'OPML', - link: '/reference/opml', + text: 'Related Formats', + collapsed: true, + items: [ + { text: 'OPML', link: '/reference/opml' }, + { text: 'Podcast Chapters', link: '/reference/related/podcast-chapters' }, + ], }, { text: 'TypeScript', diff --git a/docs/index.md b/docs/index.md index 296e741c..f0a809f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,11 +52,12 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet | [RDF](/reference/feeds/rdf) | 0.9, 1.0 | ✅ | 📋 | | [JSON Feed](/reference/feeds/json-feed) | 1.0, 1.1 | ✅ | ✅ | -### Other +### Related Formats | Format | Versions | Parse | Generate | |--------|----------|-------|----------| | [OPML](/reference/opml) | 1.0, 2.0 | ✅ | ✅ | +| [Podcast Chapters](/reference/related/podcast-chapters) | 1.2 | ✅ | ✅ | ### Feed Namespaces diff --git a/docs/reference/feeds/json-feed.md b/docs/reference/feeds/json-feed.md index bb885e5e..b6214d79 100644 --- a/docs/reference/feeds/json-feed.md +++ b/docs/reference/feeds/json-feed.md @@ -43,7 +43,7 @@ const jsonFeed = parseJsonFeed(jsonContent, { maxItems: 10 }) | Parameter | Type | Description | |-----------|------|-------------| -| `content` | `string` | The JSON Feed content to parse | +| `content` | `unknown` | The JSON Feed content to parse (string or object) | | `options` | `object` | Optional parsing settings | #### Options diff --git a/docs/reference/related/podcast-chapters.md b/docs/reference/related/podcast-chapters.md new file mode 100644 index 00000000..e833de12 --- /dev/null +++ b/docs/reference/related/podcast-chapters.md @@ -0,0 +1,126 @@ +--- +title: "Reference: Podcast Chapters" +--- + +# Podcast Chapters Reference + +Podcast Chapters is a JSON format for podcast chapter markers, part of the [Podcasting 2.0 specification](/reference/namespaces/podcast). It enables podcasters to embed chapter metadata including timestamps, titles, images, links, and location data. + + + + + + + + + + + + + + + + +
Version1.2
SpecificationPodcast Chapters Specification
MIME Typeapplication/json+chapters
+ +## Functions + +### `parseChapters()` + +Parses Podcast Chapters content and returns a typed chapters object. + +```typescript +import { parseChapters } from 'feedsmith' + +const chapters = parseChapters(jsonContent) +// Returns: object with all fields optional except required ones +``` + +#### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `content` | `unknown` | The Podcast Chapters JSON content to parse (string or object) | + +#### Returns +`object` - Parsed Podcast Chapters with all fields optional + +### `generateChapters()` + +Generates Podcast Chapters object from chapters data. The `version` field is automatically set to `1.2.0`. + +```typescript +import { generateChapters } from 'feedsmith' + +const chapters = generateChapters({ + chapters: [ + { startTime: 0, title: 'Introduction' }, + { startTime: 60, title: 'Main Content' }, + ], + title: 'Episode Chapters', +}) +// Returns: { version: '1.2.0', chapters: [...], title: 'Episode Chapters' } +``` + +#### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `data` | `object` | Podcast Chapters data to generate | +| `options` | `object` | Optional generation settings | + +#### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) | + +#### Returns +`object` - Generated Podcast Chapters + +### `detectChapters()` + +Detects if the provided content is a Podcast Chapters document. + +#### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `content` | `unknown` | The content to check (string or object) | + +#### Returns +`boolean` - `true` if content appears to be Podcast Chapters format + +#### Example +```typescript +import { detectChapters } from 'feedsmith' + +const isChapters = detectChapters(jsonContent) +``` + +## Types + +All Podcast Chapters types are available under the `Chapters` namespace: + +```typescript +import type { Chapters } from 'feedsmith' + +// Access any type from the definitions below +type Document = Chapters.Document +type Chapter = Chapters.Chapter +type Location = Chapters.Location +// … see type definitions below for all available types +``` + +See the [TypeScript guide](/reference/typescript) for usage examples. + +### Type Definitions + +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). + +<<< @/../src/related/chapters/common/types.ts#reference + +## Related + +- **[Podcast Namespace](/reference/namespaces/podcast)** - The Podcasting 2.0 namespace that references Podcast Chapters diff --git a/src/common/config.ts b/src/common/config.ts index 1a1858b5..14b762e0 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -129,6 +129,7 @@ export const locales = { invalidInputAtom: 'Invalid input Atom', invalidInputRss: 'Invalid input RSS', invalidInputJson: 'Invalid input JSON', + invalidInputChapters: 'Invalid input Podcast Chapters', } export const namespaceUris = { diff --git a/src/common/utils.test.ts b/src/common/utils.test.ts index 2841270f..13b63262 100644 --- a/src/common/utils.test.ts +++ b/src/common/utils.test.ts @@ -3,6 +3,7 @@ import { type XMLBuilder, XMLParser } from 'fast-xml-parser' import { namespacePrefixes, namespaceUris } from './config.js' import type { ParseUtilExact } from './types.js' import { + createCaseInsensitiveGetter, createNamespaceNormalizator, detectNamespaces, generateBoolean, @@ -1724,6 +1725,126 @@ describe('parseSingularOf', () => { }) }) +describe('createCaseInsensitiveGetter', () => { + it('should retrieve value using case-insensitive key lookup', () => { + const value = { + Title: 'Example Title', + AUTHOR: 'John Doe', + content: 'Some content here', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('title')).toBe('Example Title') + expect(get('author')).toBe('John Doe') + expect(get('CONTENT')).toBe('Some content here') + }) + + it('should preserve the original value types', () => { + const value = { + Number: 42, + Boolean: true, + Object: { key: 'value' }, + Array: [1, 2, 3], + Null: null, + } + const get = createCaseInsensitiveGetter(value) + + expect(get('number')).toBe(42) + expect(get('boolean')).toBe(true) + expect(get('object')).toEqual({ key: 'value' }) + expect(get('array')).toEqual([1, 2, 3]) + expect(get('null')).toBeNull() + }) + + it('should handle keys that differ only in case', () => { + const value = { + key: 'lowercase value', + KEY: 'uppercase value', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('key')).toBe('lowercase value') + expect(get('KEY')).toBe('uppercase value') + }) + + it('should handle non-string key lookups by coercing to string', () => { + const value = { + '123': 'numeric key', + true: 'boolean key', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('123')).toBe('numeric key') + expect(get('TRUE')).toBe('boolean key') + }) + + it('should handle special characters in keys', () => { + const value = { + 'Special-Key': 'with dash', + Special_Key: 'with underscore', + 'Special.Key': 'with dot', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('special-key')).toBe('with dash') + expect(get('SPECIAL_KEY')).toBe('with underscore') + expect(get('special.key')).toBe('with dot') + }) + + it('should handle Unicode characters correctly', () => { + const value = { + CaféItem: 'coffee', + RÉSUMÉ: 'document', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('caféitem')).toBe('coffee') + expect(get('résumé')).toBe('document') + }) + + it('should handle multiple lookups on the same getter', () => { + const value = { + First: 'first value', + Second: 'second value', + Third: 'third value', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('first')).toBe('first value') + expect(get('SECOND')).toBe('second value') + expect(get('THiRd')).toBe('third value') + }) + + it('should handle undefined values in the object', () => { + const value = { + DefinedKey: 'defined value', + UndefinedKey: undefined, + } + const get = createCaseInsensitiveGetter(value) + + expect(get('definedkey')).toBe('defined value') + expect(get('undefinedkey')).toBeUndefined() + // Make sure we can distinguish between non-existent keys and keys with undefined values. + expect('UndefinedKey' in value).toBe(true) + }) + + it('should return undefined for non-existent keys', () => { + const value = { + ExistingKey: 'value', + } + const get = createCaseInsensitiveGetter(value) + + expect(get('nonexistentkey')).toBeUndefined() + }) + + it('should handle empty objects', () => { + const value = {} + const get = createCaseInsensitiveGetter(value) + + expect(get('anykey')).toBeUndefined() + }) +}) + describe('parseArray', () => { it('should handle arrays', () => { const value1 = [] as Array diff --git a/src/common/utils.ts b/src/common/utils.ts index c2ad944b..ab1a2a53 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -331,6 +331,22 @@ export const parseSingularOf = (value: Unreliable, parse: ParseUtilExact): return parse(parseSingular(value)) } +export const createCaseInsensitiveGetter = (value: Record) => { + return (requestedKey: string) => { + if (requestedKey in value) { + return value[requestedKey] + } + + const lowerKey = requestedKey.toLowerCase() + + for (const key in value) { + if (key.toLowerCase() === lowerKey) { + return value[key] + } + } + } +} + export const parseCsvOf = ( value: Unreliable, parse: ParseUtilExact, diff --git a/src/feeds/json/detect/index.ts b/src/feeds/json/detect/index.ts index 63a65a1b..7ad0e055 100644 --- a/src/feeds/json/detect/index.ts +++ b/src/feeds/json/detect/index.ts @@ -1,5 +1,9 @@ -import { isNonEmptyString, isObject, parseJsonObject } from '../../../common/utils.js' -import { createCaseInsensitiveGetter } from '../parse/utils.js' +import { + createCaseInsensitiveGetter, + isNonEmptyString, + isObject, + parseJsonObject, +} from '../../../common/utils.js' export const detect = (value: unknown): value is object => { const json = parseJsonObject(value) diff --git a/src/feeds/json/parse/utils.test.ts b/src/feeds/json/parse/utils.test.ts index f6bca3df..381adcf8 100644 --- a/src/feeds/json/parse/utils.test.ts +++ b/src/feeds/json/parse/utils.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'bun:test' import { - createCaseInsensitiveGetter, parseAttachment, parseAuthor, parseFeed, @@ -9,126 +8,6 @@ import { retrieveAuthors, } from './utils.js' -describe('createCaseInsensitiveGetter', () => { - it('should retrieve value using case-insensitive key lookup', () => { - const value = { - Title: 'Example Title', - AUTHOR: 'John Doe', - content: 'Some content here', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('title')).toBe('Example Title') - expect(get('author')).toBe('John Doe') - expect(get('CONTENT')).toBe('Some content here') - }) - - it('should preserve the original value types', () => { - const value = { - Number: 42, - Boolean: true, - Object: { key: 'value' }, - Array: [1, 2, 3], - Null: null, - } - const get = createCaseInsensitiveGetter(value) - - expect(get('number')).toBe(42) - expect(get('boolean')).toBe(true) - expect(get('object')).toEqual({ key: 'value' }) - expect(get('array')).toEqual([1, 2, 3]) - expect(get('null')).toBeNull() - }) - - it('should handle keys that differ only in case', () => { - const value = { - key: 'lowercase value', - KEY: 'uppercase value', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('key')).toBe('lowercase value') - expect(get('KEY')).toBe('uppercase value') - }) - - it('should handle non-string key lookups by coercing to string', () => { - const value = { - '123': 'numeric key', - true: 'boolean key', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('123')).toBe('numeric key') - expect(get('TRUE')).toBe('boolean key') - }) - - it('should handle special characters in keys', () => { - const value = { - 'Special-Key': 'with dash', - Special_Key: 'with underscore', - 'Special.Key': 'with dot', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('special-key')).toBe('with dash') - expect(get('SPECIAL_KEY')).toBe('with underscore') - expect(get('special.key')).toBe('with dot') - }) - - it('should handle Unicode characters correctly', () => { - const value = { - CaféItem: 'coffee', - RÉSUMÉ: 'document', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('caféitem')).toBe('coffee') - expect(get('résumé')).toBe('document') - }) - - it('should handle multiple lookups on the same getter', () => { - const value = { - First: 'first value', - Second: 'second value', - Third: 'third value', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('first')).toBe('first value') - expect(get('SECOND')).toBe('second value') - expect(get('THiRd')).toBe('third value') - }) - - it('should handle undefined values in the object', () => { - const value = { - DefinedKey: 'defined value', - UndefinedKey: undefined, - } - const get = createCaseInsensitiveGetter(value) - - expect(get('definedkey')).toBe('defined value') - expect(get('undefinedkey')).toBeUndefined() - // Make sure we can distinguish between non-existent keys and keys with undefined values. - expect('UndefinedKey' in value).toBe(true) - }) - - it('should return undefined for non-existent keys', () => { - const value = { - ExistingKey: 'value', - } - const get = createCaseInsensitiveGetter(value) - - expect(get('nonexistentkey')).toBeUndefined() - }) - - it('should handle empty objects', () => { - const value = {} - const get = createCaseInsensitiveGetter(value) - - expect(get('anykey')).toBeUndefined() - }) -}) - describe('parseAuthor', () => { const expectedFull = { name: 'John', url: 'link', avatar: '123' } diff --git a/src/index.ts b/src/index.ts index b93b06f9..f889da64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,3 +52,7 @@ export type { YtNs } from './namespaces/yt/common/types.js' export type { Opml } from './opml/common/types.js' export { generate as generateOpml } from './opml/generate/index.js' export { parse as parseOpml } from './opml/parse/index.js' +export type { Chapters } from './related/chapters/common/types.js' +export { detect as detectChapters } from './related/chapters/detect/index.js' +export { generate as generateChapters } from './related/chapters/generate/index.js' +export { parse as parseChapters } from './related/chapters/parse/index.js' diff --git a/src/related/chapters/common/types.ts b/src/related/chapters/common/types.ts new file mode 100644 index 00000000..ef634446 --- /dev/null +++ b/src/related/chapters/common/types.ts @@ -0,0 +1,40 @@ +import type { Requirable, Strict } from '../../../common/types.js' + +// #region reference +export namespace Chapters { + export type Location = Strict< + { + name: Requirable // Required in spec. + geo: Requirable // Required in spec. + osm?: string + }, + TStrict + > + + export type Chapter = Strict< + { + startTime: Requirable // Required in spec. + title?: string + img?: string + url?: string + toc?: boolean + endTime?: number + location?: Location + }, + TStrict + > + + export type Document = Strict< + { + chapters: Requirable>> // Required in spec. + author?: string + title?: string + podcastName?: string + description?: string + fileName?: string + waypoints?: boolean + }, + TStrict + > +} +// #endregion reference diff --git a/src/related/chapters/detect/index.test.ts b/src/related/chapters/detect/index.test.ts new file mode 100644 index 00000000..28abfb3f --- /dev/null +++ b/src/related/chapters/detect/index.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'bun:test' +import { detect } from './index.js' + +describe('detect', () => { + it('should detect chapters with version string', () => { + const value = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with version 1.0.0', () => { + const value = { + version: '1.0.0', + chapters: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with version 1.1.0', () => { + const value = { + version: '1.1.0', + chapters: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters without version but with valid chapters array', () => { + const value = { + chapters: [{ startTime: 0, title: 'Introduction' }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters from JSON string', () => { + const value = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0 }], + }) + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters from JSON string with whitespace', () => { + const json = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0 }], + }) + const value = ` ${json} ` + + expect(detect(value)).toBe(true) + }) + + it('should not detect empty object', () => { + const value = {} + + expect(detect(value)).toBe(false) + }) + + it('should not detect object with empty chapters array', () => { + const value = { + chapters: [], + } + + expect(detect(value)).toBe(false) + }) + + it('should not detect chapters without startTime', () => { + const value = { + chapters: [{ title: 'Introduction' }], + } + + expect(detect(value)).toBe(false) + }) + + it('should not detect chapters with non-number startTime', () => { + const value = { + chapters: [{ startTime: '0' }], + } + + expect(detect(value)).toBe(false) + }) + + it('should not detect JSON Feed', () => { + const value = { + version: 'https://jsonfeed.org/version/1.1', + title: 'My Feed', + items: [], + } + + expect(detect(value)).toBe(false) + }) + + it('should not detect invalid JSON string', () => { + const value = 'not valid json' + + expect(detect(value)).toBe(false) + }) + + it('should not detect null', () => { + expect(detect(null)).toBe(false) + }) + + it('should not detect undefined', () => { + expect(detect(undefined)).toBe(false) + }) + + it('should not detect number', () => { + expect(detect(123)).toBe(false) + }) + + it('should not detect array', () => { + expect(detect([])).toBe(false) + }) + + it('should not detect empty string', () => { + expect(detect('')).toBe(false) + }) + + it('should return false for generic object', () => { + const genericObj = { + name: 'John', + age: 30, + city: 'New York', + } + + expect(detect(genericObj)).toBe(false) + }) + + it('should return false for object with only title', () => { + const value = { + title: 'Just a title', + } + + expect(detect(value)).toBe(false) + }) + + it('should return false for JSON string with non-chapters structure', () => { + const json = JSON.stringify({ + title: 'Just a title', + description: 'Some description', + }) + + expect(detect(json)).toBe(false) + }) + + it('should return false for object with wrong version format', () => { + const value = { + version: '2.0', + chapters: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(false) + }) +}) + +describe('case insensitive detection', () => { + it('should detect chapters with uppercase VERSION', () => { + const value = { + VERSION: '1.2.0', + chapters: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with uppercase CHAPTERS', () => { + const value = { + CHAPTERS: [{ startTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with uppercase STARTTIME', () => { + const value = { + chapters: [{ STARTTIME: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with mixed case properties', () => { + const value = { + Version: '1.2.0', + Chapters: [{ StartTime: 0 }], + } + + expect(detect(value)).toBe(true) + }) + + it('should detect chapters with all uppercase properties', () => { + const value = { + VERSION: '1.2.0', + CHAPTERS: [{ STARTTIME: 0, TITLE: 'Test' }], + } + + expect(detect(value)).toBe(true) + }) +}) diff --git a/src/related/chapters/detect/index.ts b/src/related/chapters/detect/index.ts new file mode 100644 index 00000000..fff3eb94 --- /dev/null +++ b/src/related/chapters/detect/index.ts @@ -0,0 +1,35 @@ +import { + createCaseInsensitiveGetter, + isNonEmptyString, + isObject, + parseJsonObject, +} from '../../../common/utils.js' + +export const detect = (value: unknown): value is object => { + const json = parseJsonObject(value) + + if (!isObject(json)) { + return false + } + + const get = createCaseInsensitiveGetter(json) + const version = get('version') + const chapters = get('chapters') + + // If version is present, it must start with '1.' + if (isNonEmptyString(version)) { + return version.startsWith('1.') + } + + // If no version, check for valid chapters structure + if (Array.isArray(chapters) && chapters.length > 0) { + const firstChapter = chapters[0] + + if (isObject(firstChapter)) { + const getChapter = createCaseInsensitiveGetter(firstChapter) + return typeof getChapter('startTime') === 'number' + } + } + + return false +} diff --git a/src/related/chapters/generate/index.test.ts b/src/related/chapters/generate/index.test.ts new file mode 100644 index 00000000..a440e36a --- /dev/null +++ b/src/related/chapters/generate/index.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'bun:test' +import { generate } from './index.js' + +describe('generate', () => { + it('should generate chapters document', () => { + const value = { + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + { + startTime: 60, + title: 'Main Content', + }, + ], + title: 'Episode Chapters', + } + const expected = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + { + startTime: 60, + title: 'Main Content', + }, + ], + title: 'Episode Chapters', + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate chapters with all properties', () => { + const value = { + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + img: 'https://example.com/eiffel.jpg', + url: 'https://example.com/chapter1', + toc: true, + endTime: 120, + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + author: 'John Doe', + title: 'Travel Podcast Chapters', + podcastName: 'World Travels', + description: 'Chapter markers for Paris episode', + fileName: 'paris-chapters.json', + waypoints: true, + } + const expected = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + img: 'https://example.com/eiffel.jpg', + url: 'https://example.com/chapter1', + toc: true, + endTime: 120, + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + author: 'John Doe', + title: 'Travel Podcast Chapters', + podcastName: 'World Travels', + description: 'Chapter markers for Paris episode', + fileName: 'paris-chapters.json', + waypoints: true, + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate minimal chapters document', () => { + const value = { + chapters: [{ startTime: 0 }], + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate chapters with floating point times', () => { + const value = { + chapters: [{ startTime: 10.5, endTime: 20.75 }], + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 10.5, endTime: 20.75 }], + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate document with empty object', () => { + const value = {} + const expected = { + version: '1.2.0', + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate document with missing chapters (lenient)', () => { + const value = { + title: 'No chapters', + } + const expected = { + version: '1.2.0', + title: 'No chapters', + } + + expect(generate(value)).toEqual(expected) + }) + + it('should generate document with empty chapters array (lenient)', () => { + const value = { + chapters: [], + } + const expected = { + version: '1.2.0', + } + + expect(generate(value)).toEqual(expected) + }) +}) + +describe('strict mode', () => { + it('should require chapters in strict mode', () => { + // @ts-expect-error: Testing missing required chapters field. + generate({ title: 'Test' }, { strict: true }) + }) + + it('should accept document with all required fields in strict mode', () => { + generate({ chapters: [{ startTime: 0 }] }, { strict: true }) + }) + + it('should require chapter startTime in strict mode', () => { + generate( + { + // @ts-expect-error: Testing missing required startTime field. + chapters: [{ title: 'Hello' }], + }, + { strict: true }, + ) + }) + + it('should require location name and geo in strict mode', () => { + generate( + { + chapters: [ + { + startTime: 0, + // @ts-expect-error: Testing missing required geo field. + location: { name: 'Test' }, + }, + ], + }, + { strict: true }, + ) + }) + + it('should accept location with all required fields in strict mode', () => { + generate( + { + chapters: [ + { + startTime: 0, + location: { name: 'Eiffel Tower', geo: 'geo:48.8584,2.2945' }, + }, + ], + }, + { strict: true }, + ) + }) + + it('should accept partial document in lenient mode', () => { + generate({ title: 'Test' }) + }) +}) + +describe('generate edge cases', () => { + it('should accept partial documents', () => { + const value = { + title: 'Test Chapters', + } + const expected = { + version: '1.2.0', + title: 'Test Chapters', + } + + expect(generate(value)).toEqual(expected) + }) + + it('should handle zero startTime', () => { + const value = { + chapters: [{ startTime: 0 }], + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + } + + expect(generate(value)).toEqual(expected) + }) + + it('should handle boolean waypoints', () => { + const value = { + chapters: [{ startTime: 0 }], + waypoints: true, + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + waypoints: true, + } + + expect(generate(value)).toEqual(expected) + }) +}) diff --git a/src/related/chapters/generate/index.ts b/src/related/chapters/generate/index.ts new file mode 100644 index 00000000..47d6df62 --- /dev/null +++ b/src/related/chapters/generate/index.ts @@ -0,0 +1,7 @@ +import type { GenerateMainJson } from '../../../common/types.js' +import type { Chapters } from '../common/types.js' +import { generateDocument } from './utils.js' + +export const generate: GenerateMainJson> = (value) => { + return generateDocument(value as Chapters.Document) +} diff --git a/src/related/chapters/generate/utils.test.ts b/src/related/chapters/generate/utils.test.ts new file mode 100644 index 00000000..652dcc45 --- /dev/null +++ b/src/related/chapters/generate/utils.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from 'bun:test' +import { generateChapter, generateDocument, generateLocation } from './utils.js' + +describe('generateLocation', () => { + it('should generate location with all properties', () => { + const value = { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + } + const expected = { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + } + + expect(generateLocation(value)).toEqual(expected) + }) + + it('should generate location with only required properties', () => { + const value = { + name: 'Central Park', + geo: 'geo:40.7829,-73.9654', + } + const expected = { + name: 'Central Park', + geo: 'geo:40.7829,-73.9654', + } + + expect(generateLocation(value)).toEqual(expected) + }) + + it('should handle empty strings', () => { + const value = { + name: 'Location', + geo: 'geo:0,0', + osm: '', + } + const expected = { + name: 'Location', + geo: 'geo:0,0', + } + + expect(generateLocation(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + name: ' ', + geo: ' ', + } + + expect(generateLocation(value)).toBeUndefined() + }) + + it('should handle empty object', () => { + expect(generateLocation({})).toBeUndefined() + }) + + it('should handle non-object inputs', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateLocation('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateLocation(123)).toBeUndefined() + expect(generateLocation(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateLocation(null)).toBeUndefined() + }) +}) + +describe('generateChapter', () => { + it('should generate chapter with all properties', () => { + const value = { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + location: { + name: 'Studio', + geo: 'geo:40.7128,-74.0060', + osm: 'R175905', + }, + } + const expected = { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + location: { + name: 'Studio', + geo: 'geo:40.7128,-74.0060', + osm: 'R175905', + }, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should generate chapter with only required startTime', () => { + const value = { + startTime: 120, + } + const expected = { + startTime: 120, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should generate chapter with toc set to false', () => { + const value = { + startTime: 0, + title: 'Hidden Chapter', + toc: false, + } + const expected = { + startTime: 0, + title: 'Hidden Chapter', + toc: false, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should handle floating point times', () => { + const value = { + startTime: 10.5, + endTime: 20.75, + } + const expected = { + startTime: 10.5, + endTime: 20.75, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should handle startTime of zero', () => { + const value = { + startTime: 0, + } + const expected = { + startTime: 0, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should handle empty strings', () => { + const value = { + startTime: 0, + title: '', + img: '', + url: '', + } + const expected = { + startTime: 0, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + startTime: 0, + title: ' ', + } + const expected = { + startTime: 0, + } + + expect(generateChapter(value)).toEqual(expected) + }) + + it('should handle empty object', () => { + expect(generateChapter({})).toBeUndefined() + }) + + it('should handle non-object inputs', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateChapter('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateChapter(123)).toBeUndefined() + expect(generateChapter(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateChapter(null)).toBeUndefined() + }) +}) + +describe('generateDocument', () => { + it('should generate document with all properties', () => { + const value = { + chapters: [ + { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + }, + { + startTime: 60, + title: 'Main Content', + toc: true, + }, + ], + author: 'John Doe', + title: 'Episode Chapters', + podcastName: 'My Podcast', + description: 'Chapter markers for episode 1', + fileName: 'episode1-chapters.json', + waypoints: false, + } + const expected = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + }, + { + startTime: 60, + title: 'Main Content', + toc: true, + }, + ], + author: 'John Doe', + title: 'Episode Chapters', + podcastName: 'My Podcast', + description: 'Chapter markers for episode 1', + fileName: 'episode1-chapters.json', + waypoints: false, + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should generate document with only chapters', () => { + const value = { + chapters: [{ startTime: 0 }, { startTime: 30 }], + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }, { startTime: 30 }], + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should generate document with chapters containing locations', () => { + const value = { + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + } + const expected = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should generate document with waypoints set to true', () => { + const value = { + chapters: [{ startTime: 0 }], + waypoints: true, + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + waypoints: true, + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should handle empty chapters array', () => { + const value = { + chapters: [], + } + const expected = { + version: '1.2.0', + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should handle empty strings', () => { + const value = { + chapters: [{ startTime: 0 }], + author: '', + title: '', + } + const expected = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + chapters: [], + author: ' ', + } + const expected = { + version: '1.2.0', + } + + expect(generateDocument(value)).toEqual(expected) + }) + + it('should handle empty object', () => { + const expected = { + version: '1.2.0', + } + + expect(generateDocument({})).toEqual(expected) + }) + + it('should handle non-object inputs', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateDocument('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateDocument(123)).toBeUndefined() + expect(generateDocument(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateDocument(null)).toBeUndefined() + }) +}) diff --git a/src/related/chapters/generate/utils.ts b/src/related/chapters/generate/utils.ts new file mode 100644 index 00000000..b70c7d63 --- /dev/null +++ b/src/related/chapters/generate/utils.ts @@ -0,0 +1,61 @@ +import type { GenerateUtil } from '../../../common/types.js' +import { + generateBoolean, + generateNumber, + generatePlainString, + isObject, + trimArray, + trimObject, +} from '../../../common/utils.js' +import type { Chapters } from '../common/types.js' + +export const generateLocation: GenerateUtil = (location) => { + if (!isObject(location)) { + return + } + + const value = { + name: generatePlainString(location.name), + geo: generatePlainString(location.geo), + osm: generatePlainString(location.osm), + } + + return trimObject(value) +} + +export const generateChapter: GenerateUtil = (chapter) => { + if (!isObject(chapter)) { + return + } + + const value = { + startTime: generateNumber(chapter.startTime), + title: generatePlainString(chapter.title), + img: generatePlainString(chapter.img), + url: generatePlainString(chapter.url), + toc: generateBoolean(chapter.toc), + endTime: generateNumber(chapter.endTime), + location: generateLocation(chapter.location), + } + + return trimObject(value) +} + +export const generateDocument: GenerateUtil = (document) => { + if (!isObject(document)) { + return + } + + const value = { + version: '1.2.0', + chapters: trimArray(document.chapters, generateChapter), + author: generatePlainString(document.author), + title: generatePlainString(document.title), + podcastName: generatePlainString(document.podcastName), + description: generatePlainString(document.description), + fileName: generatePlainString(document.fileName), + waypoints: generateBoolean(document.waypoints), + } + + return trimObject(value) +} diff --git a/src/related/chapters/parse/index.test.ts b/src/related/chapters/parse/index.test.ts new file mode 100644 index 00000000..5b232f80 --- /dev/null +++ b/src/related/chapters/parse/index.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'bun:test' +import { locales } from '../../../common/config.js' +import { parse } from './index.js' + +describe('parse', () => { + it('should parse valid JSON chapters string', () => { + const value = JSON.stringify({ + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + { + startTime: 60, + title: 'Main Content', + }, + ], + title: 'Episode Chapters', + }) + const expected = { + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + { + startTime: 60, + title: 'Main Content', + }, + ], + title: 'Episode Chapters', + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse already-parsed object', () => { + const value = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + ], + title: 'Episode Chapters', + } + const expected = { + chapters: [ + { + startTime: 0, + title: 'Introduction', + }, + ], + title: 'Episode Chapters', + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse chapters with all properties', () => { + const value = JSON.stringify({ + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + img: 'https://example.com/eiffel.jpg', + url: 'https://example.com/chapter1', + toc: true, + endTime: 120, + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + author: 'John Doe', + title: 'Travel Podcast Chapters', + podcastName: 'World Travels', + description: 'Chapter markers for Paris episode', + fileName: 'paris-chapters.json', + waypoints: true, + }) + const expected = { + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + img: 'https://example.com/eiffel.jpg', + url: 'https://example.com/chapter1', + toc: true, + endTime: 120, + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + author: 'John Doe', + title: 'Travel Podcast Chapters', + podcastName: 'World Travels', + description: 'Chapter markers for Paris episode', + fileName: 'paris-chapters.json', + waypoints: true, + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse minimal valid chapters document', () => { + const value = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0 }], + }) + const expected = { + chapters: [{ startTime: 0 }], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse chapters from string with leading whitespace', () => { + const json = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0, title: 'Test' }], + }) + const value = ` ${json}` + const expected = { + chapters: [{ startTime: 0, title: 'Test' }], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse chapters from string with trailing whitespace', () => { + const json = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0, title: 'Test' }], + }) + const value = `${json} ` + const expected = { + chapters: [{ startTime: 0, title: 'Test' }], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should parse chapters from string with whitespace on both ends', () => { + const json = JSON.stringify({ + version: '1.2.0', + chapters: [{ startTime: 0, title: 'Test' }], + }) + const value = ` ${json} ` + const expected = { + chapters: [{ startTime: 0, title: 'Test' }], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should handle malformed JSON string', () => { + const value = '{"version":"1.2.0","chapters":[{"startTime":0' + + expect(() => parse(value)).toThrowError(locales.invalidInputChapters) + }) + + it('should parse document with missing chapters (lenient)', () => { + const value = JSON.stringify({ + version: '1.2.0', + title: 'No chapters', + }) + const expected = { + title: 'No chapters', + } + + expect(parse(value)).toEqual(expected) + }) + + it('should throw error for empty object', () => { + expect(() => parse({})).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for empty chapters array', () => { + expect(() => parse({ chapters: [] })).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for invalid string', () => { + expect(() => parse('not valid json')).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for null', () => { + expect(() => parse(null)).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for undefined', () => { + expect(() => parse(undefined)).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for array', () => { + expect(() => parse([])).toThrowError(locales.invalidInputChapters) + }) + + it('should throw error for number', () => { + expect(() => parse(123)).toThrowError(locales.invalidInputChapters) + }) + + it('should handle case insensitive fields', () => { + const value = { + VERSION: '1.2.0', + CHAPTERS: [{ STARTTIME: 0, TITLE: 'Introduction' }], + AUTHOR: 'John Doe', + } + const expected = { + chapters: [{ startTime: 0, title: 'Introduction' }], + author: 'John Doe', + } + + expect(parse(value)).toEqual(expected) + }) +}) diff --git a/src/related/chapters/parse/index.ts b/src/related/chapters/parse/index.ts new file mode 100644 index 00000000..bf962a1c --- /dev/null +++ b/src/related/chapters/parse/index.ts @@ -0,0 +1,21 @@ +import { locales } from '../../../common/config.js' +import { parseJsonObject } from '../../../common/utils.js' +import { detectChapters } from '../../../index.js' +import type { Chapters } from '../common/types.js' +import { parseDocument } from './utils.js' + +export const parse = (value: unknown): Chapters.Document => { + const json = parseJsonObject(value) + + if (!detectChapters(json)) { + throw new Error(locales.invalidInputChapters) + } + + const parsed = parseDocument(json) + + if (!parsed) { + throw new Error(locales.invalidInputChapters) + } + + return parsed +} diff --git a/src/related/chapters/parse/utils.test.ts b/src/related/chapters/parse/utils.test.ts new file mode 100644 index 00000000..271957c6 --- /dev/null +++ b/src/related/chapters/parse/utils.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it } from 'bun:test' +import { parseChapter, parseDocument, parseLocation } from './utils.js' + +describe('parseLocation', () => { + it('should parse location with all properties', () => { + const value = { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + } + const expected = { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + } + + expect(parseLocation(value)).toEqual(expected) + }) + + it('should parse location with only required properties', () => { + const value = { + name: 'Central Park', + geo: 'geo:40.7829,-73.9654', + } + const expected = { + name: 'Central Park', + geo: 'geo:40.7829,-73.9654', + } + + expect(parseLocation(value)).toEqual(expected) + }) + + it('should handle empty strings', () => { + const value = { + name: 'Location', + geo: 'geo:0,0', + osm: '', + } + const expected = { + name: 'Location', + geo: 'geo:0,0', + } + + expect(parseLocation(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + name: ' ', + geo: ' ', + } + + expect(parseLocation(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseLocation(value)).toBeUndefined() + }) + + it('should return undefined for non-object inputs', () => { + expect(parseLocation(null)).toBeUndefined() + expect(parseLocation(undefined)).toBeUndefined() + expect(parseLocation('string')).toBeUndefined() + expect(parseLocation(123)).toBeUndefined() + }) +}) + +describe('parseChapter', () => { + it('should parse chapter with all properties', () => { + const value = { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + location: { + name: 'Studio', + geo: 'geo:40.7128,-74.0060', + osm: 'R175905', + }, + } + const expected = { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + location: { + name: 'Studio', + geo: 'geo:40.7128,-74.0060', + osm: 'R175905', + }, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should parse chapter with only required startTime', () => { + const value = { + startTime: 120, + } + const expected = { + startTime: 120, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should parse chapter with toc set to false', () => { + const value = { + startTime: 0, + title: 'Hidden Chapter', + toc: false, + } + const expected = { + startTime: 0, + title: 'Hidden Chapter', + toc: false, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should handle startTime as string number', () => { + const value = { + startTime: '90.5', + } + const expected = { + startTime: 90.5, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should handle floating point times', () => { + const value = { + startTime: 10.5, + endTime: 20.75, + } + const expected = { + startTime: 10.5, + endTime: 20.75, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should handle empty strings', () => { + const value = { + startTime: 0, + title: '', + img: '', + url: '', + } + const expected = { + startTime: 0, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + startTime: 0, + title: ' ', + } + const expected = { + startTime: 0, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseChapter(value)).toBeUndefined() + }) + + it('should return undefined for non-object inputs', () => { + expect(parseChapter(null)).toBeUndefined() + expect(parseChapter(undefined)).toBeUndefined() + expect(parseChapter('string')).toBeUndefined() + expect(parseChapter(123)).toBeUndefined() + }) +}) + +describe('parseDocument', () => { + const expectedFull = { + chapters: [ + { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + }, + { + startTime: 60, + title: 'Main Content', + toc: true, + }, + ], + author: 'John Doe', + title: 'Episode Chapters', + podcastName: 'My Podcast', + description: 'Chapter markers for episode 1', + fileName: 'episode1-chapters.json', + waypoints: false, + } + + it('should parse document with all properties', () => { + const value = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + }, + { + startTime: 60, + title: 'Main Content', + toc: true, + }, + ], + author: 'John Doe', + title: 'Episode Chapters', + podcastName: 'My Podcast', + description: 'Chapter markers for episode 1', + fileName: 'episode1-chapters.json', + waypoints: false, + } + + expect(parseDocument(value)).toEqual(expectedFull) + }) + + it('should parse document with only chapters', () => { + const value = { + version: '1.2.0', + chapters: [{ startTime: 0 }, { startTime: 30 }], + } + const expected = { + chapters: [{ startTime: 0 }, { startTime: 30 }], + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should parse document with chapters containing locations', () => { + const value = { + version: '1.2.0', + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + } + const expected = { + chapters: [ + { + startTime: 0, + title: 'At the Eiffel Tower', + location: { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + }, + }, + ], + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should parse document with waypoints set to true', () => { + const value = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + waypoints: true, + } + const expected = { + chapters: [{ startTime: 0 }], + waypoints: true, + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should handle coercible values', () => { + const value = { + version: 1.2, + chapters: [{ startTime: '0' }], + } + const expected = { + chapters: [{ startTime: 0 }], + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should handle empty chapters array', () => { + const value = { + version: '1.2.0', + chapters: [], + } + + expect(parseDocument(value)).toBeUndefined() + }) + + it('should handle empty strings', () => { + const value = { + version: '1.2.0', + chapters: [{ startTime: 0 }], + author: '', + title: '', + } + const expected = { + chapters: [{ startTime: 0 }], + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should handle whitespace-only strings', () => { + const value = { + version: ' ', + chapters: [], + } + + expect(parseDocument(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseDocument(value)).toBeUndefined() + }) + + it('should return undefined for non-object inputs', () => { + expect(parseDocument(null)).toBeUndefined() + expect(parseDocument(undefined)).toBeUndefined() + expect(parseDocument('string')).toBeUndefined() + expect(parseDocument(123)).toBeUndefined() + }) +}) + +describe('case insensitive parsing', () => { + it('should parse document with uppercase property names', () => { + const value = { + VERSION: '1.2.0', + CHAPTERS: [{ STARTTIME: 0, TITLE: 'Introduction' }], + AUTHOR: 'John Doe', + TITLE: 'Episode Chapters', + } + const expected = { + chapters: [{ startTime: 0, title: 'Introduction' }], + author: 'John Doe', + title: 'Episode Chapters', + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should parse document with mixed case property names', () => { + const value = { + Version: '1.2.0', + Chapters: [{ StartTime: 0, Title: 'Introduction' }], + Author: 'John Doe', + PodcastName: 'My Podcast', + } + const expected = { + chapters: [{ startTime: 0, title: 'Introduction' }], + author: 'John Doe', + podcastName: 'My Podcast', + } + + expect(parseDocument(value)).toEqual(expected) + }) + + it('should parse chapter with uppercase property names', () => { + const value = { + STARTTIME: 0, + TITLE: 'Introduction', + IMG: 'https://example.com/intro.jpg', + URL: 'https://example.com/intro', + TOC: true, + ENDTIME: 60, + } + const expected = { + startTime: 0, + title: 'Introduction', + img: 'https://example.com/intro.jpg', + url: 'https://example.com/intro', + toc: true, + endTime: 60, + } + + expect(parseChapter(value)).toEqual(expected) + }) + + it('should parse location with uppercase property names', () => { + const value = { + NAME: 'Eiffel Tower', + GEO: 'geo:48.8584,2.2945', + OSM: 'W5013364', + } + const expected = { + name: 'Eiffel Tower', + geo: 'geo:48.8584,2.2945', + osm: 'W5013364', + } + + expect(parseLocation(value)).toEqual(expected) + }) + + it('should prefer exact case match over case-insensitive match', () => { + const value = { + startTime: 0, + STARTTIME: 999, + title: 'Correct', + TITLE: 'Wrong', + } + const expected = { + startTime: 0, + title: 'Correct', + } + + expect(parseChapter(value)).toEqual(expected) + }) +}) diff --git a/src/related/chapters/parse/utils.ts b/src/related/chapters/parse/utils.ts new file mode 100644 index 00000000..e2d2705e --- /dev/null +++ b/src/related/chapters/parse/utils.ts @@ -0,0 +1,80 @@ +import type { ParseUtilPartial } from '../../../common/types.js' +import { + isObject, + parseArrayOf, + parseBoolean, + parseNumber, + parseSingularOf, + parseString, + trimObject, +} from '../../../common/utils.js' +import type { Chapters } from '../common/types.js' + +const createCaseInsensitiveGetter = (value: Record) => { + return (requestedKey: string) => { + if (requestedKey in value) { + return value[requestedKey] + } + + const lowerKey = requestedKey.toLowerCase() + + for (const key in value) { + if (key.toLowerCase() === lowerKey) { + return value[key] + } + } + } +} + +export const parseLocation: ParseUtilPartial = (value) => { + if (!isObject(value)) { + return + } + + const get = createCaseInsensitiveGetter(value) + const location = { + name: parseSingularOf(get('name'), parseString), + geo: parseSingularOf(get('geo'), parseString), + osm: parseSingularOf(get('osm'), parseString), + } + + return trimObject(location) +} + +export const parseChapter: ParseUtilPartial = (value) => { + if (!isObject(value)) { + return + } + + const get = createCaseInsensitiveGetter(value) + const chapter = { + startTime: parseSingularOf(get('startTime'), parseNumber), + title: parseSingularOf(get('title'), parseString), + img: parseSingularOf(get('img'), parseString), + url: parseSingularOf(get('url'), parseString), + toc: parseSingularOf(get('toc'), parseBoolean), + endTime: parseSingularOf(get('endTime'), parseNumber), + location: parseSingularOf(get('location'), parseLocation), + } + + return trimObject(chapter) +} + +export const parseDocument: ParseUtilPartial = (value) => { + if (!isObject(value)) { + return + } + + const get = createCaseInsensitiveGetter(value) + const document = { + chapters: parseArrayOf(get('chapters'), parseChapter), + author: parseSingularOf(get('author'), parseString), + title: parseSingularOf(get('title'), parseString), + podcastName: parseSingularOf(get('podcastName'), parseString), + description: parseSingularOf(get('description'), parseString), + fileName: parseSingularOf(get('fileName'), parseString), + waypoints: parseSingularOf(get('waypoints'), parseBoolean), + } + + return trimObject(document) +}