From cade7104dbeb81d5a05c1d5611ea314e88249c95 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 21:47:25 +0800 Subject: [PATCH] feat(watchlist): yaml helpers + schema types --- packages/obsidian/src/watchlist/schema.ts | 40 +++++++++++++++++ packages/obsidian/src/watchlist/yaml.ts | 25 +++++++++++ tests/watchlist-yaml.test.ts | 53 +++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 packages/obsidian/src/watchlist/schema.ts create mode 100644 packages/obsidian/src/watchlist/yaml.ts create mode 100644 tests/watchlist-yaml.test.ts diff --git a/packages/obsidian/src/watchlist/schema.ts b/packages/obsidian/src/watchlist/schema.ts new file mode 100644 index 0000000..c832ef5 --- /dev/null +++ b/packages/obsidian/src/watchlist/schema.ts @@ -0,0 +1,40 @@ +export type WatchCategory = 'Movie' | 'Series' | 'Anime'; +export type WatchMediaType = 'Movie' | 'TV Series'; + +export interface WatchlistRecord { + title: string; + engName: string; + mediaType: WatchMediaType; + category: WatchCategory; + watchStatus: string; + rating: string; + ratingStars: string; + year: string; + runtime: number | null; + seasons: number | null; + episodes: number | null; + vod: string[]; + genre: string[]; + status: string; + language: string; + country: string; + director: string[]; + writer: string[]; + producer: string[]; + contentRating: string; + tmdbRating: number | null; + tmdbId: string; + imdbId: string; + releaseDate: string | null; + lastAirDate: string | null; + nextAirDate: string | null; + lastEpisode: string | null; + upcomingEpisode: string | null; + poster: string | null; + trailer: string; + homepage: string; + imdbPage: string; + notionUrl: string; + synopsis: string; + cast: string; +} diff --git a/packages/obsidian/src/watchlist/yaml.ts b/packages/obsidian/src/watchlist/yaml.ts new file mode 100644 index 0000000..6204f41 --- /dev/null +++ b/packages/obsidian/src/watchlist/yaml.ts @@ -0,0 +1,25 @@ +const NEEDS_QUOTE = /[:,#\[\]{}"&*!|>%@`]/; +const KEYWORDS = new Set(['null', 'true', 'false', 'yes', 'no']); + +export function yamlScalar(v: unknown): string { + if (v === null || v === undefined || v === '') return ''; + const s = String(v); + if (NEEDS_QUOTE.test(s) || s.trim() !== s || KEYWORDS.has(s.toLowerCase())) { + return '"' + s.replace(/"/g, '\\"') + '"'; + } + return s; +} + +export function yamlList(items: string[]): string { + const parts: string[] = []; + for (const raw of items) { + const g = String(raw).trim(); + if (!g) continue; + parts.push(/^[A-Za-z0-9 ]+$/.test(g) ? g : '"' + g.replace(/"/g, '\\"') + '"'); + } + return '[' + parts.join(', ') + ']'; +} + +export function quotedOrNull(v: string | null | undefined): string { + return v ? '"' + v + '"' : 'null'; +} diff --git a/tests/watchlist-yaml.test.ts b/tests/watchlist-yaml.test.ts new file mode 100644 index 0000000..8719a44 --- /dev/null +++ b/tests/watchlist-yaml.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { yamlScalar, yamlList, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +describe('yamlScalar', () => { + test('plain string passes through', () => { + expect(yamlScalar('English')).toBe('English'); + }); + test('empty/null/undefined → empty string', () => { + expect(yamlScalar('')).toBe(''); + expect(yamlScalar(null)).toBe(''); + expect(yamlScalar(undefined)).toBe(''); + }); + test('special chars → quoted', () => { + expect(yamlScalar('S2, E6: Glorious Purpose')).toBe('"S2, E6: Glorious Purpose"'); + expect(yamlScalar('Aaron Moorhead, Justin Benson')).toBe('"Aaron Moorhead, Justin Benson"'); + }); + test('comma alone does NOT quote (matches python regex)', () => { + // python regex [:#\[\]{}}&*!|>%@`] has no comma; "a, b" quotes via ":"? no — verify: comma not in class, so "Alter, Turtle" stays plain + expect(yamlScalar('Rachel Alter and Tommy Turtle')).toBe('Rachel Alter and Tommy Turtle'); + }); + test('yaml keywords → quoted', () => { + expect(yamlScalar('null')).toBe('"null"'); + expect(yamlScalar('No')).toBe('"No"'); + }); + test('embedded quotes escaped', () => { + expect(yamlScalar('He said "hi"')).toBe('"He said \\"hi\\""'); + }); +}); + +describe('yamlList', () => { + test('plain items unquoted, special quoted', () => { + expect(yamlList(['Drama', 'Sci-Fi & Fantasy'])).toBe('[Drama, "Sci-Fi & Fantasy"]'); + }); + test('empty list', () => { + expect(yamlList([])).toBe('[]'); + }); + test('blank items dropped', () => { + expect(yamlList(['', 'Drama', ' '])).toBe('[Drama]'); + }); + test('Disney+ quoted', () => { + expect(yamlList(['Disney+'])).toBe('["Disney+"]'); + }); +}); + +describe('quotedOrNull', () => { + test('value → quoted', () => { + expect(quotedOrNull('https://x.y/z')).toBe('"https://x.y/z"'); + }); + test('empty/null → null literal', () => { + expect(quotedOrNull('')).toBe('null'); + expect(quotedOrNull(null)).toBe('null'); + }); +});