Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: lens specifications and loader for source lexicon transformation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+155 -1
+18
formats/tv.ionosphere/lenses/schedule-to-talk.lens.json
··· 1 + { 2 + "$type": "org.relationaltext.lens", 3 + "id": "community.lexicon.calendar.event.to.tv.ionosphere.talk.v1", 4 + "description": "Transform ATmosphereConf schedule events into ionosphere talk records", 5 + "source": "community.lexicon.calendar.event", 6 + "target": "tv.ionosphere.talk", 7 + "invertible": false, 8 + "rules": [ 9 + { "match": { "name": "name" }, "replace": { "name": "title" } }, 10 + { "match": { "name": "description" }, "replace": { "name": "description" } }, 11 + { "match": { "name": "startsAt" }, "replace": { "name": "startsAt" } }, 12 + { "match": { "name": "endsAt" }, "replace": { "name": "endsAt" } }, 13 + { "match": { "name": "additionalData.room" }, "replace": { "name": "room" } }, 14 + { "match": { "name": "additionalData.category" }, "replace": { "name": "category" } }, 15 + { "match": { "name": "additionalData.type" }, "replace": { "name": "talkType" } }, 16 + { "match": { "name": "additionalData.speakers" }, "replace": { "name": "speakers" } } 17 + ] 18 + }
+11
formats/tv.ionosphere/lenses/transcript-to-document.lens.json
··· 1 + { 2 + "$type": "org.relationaltext.lens", 3 + "id": "transcript.to.tv.ionosphere.document.v1", 4 + "description": "Map raw transcript timing data to ionosphere document timestamp facets", 5 + "source": "transcript.raw", 6 + "target": "tv.ionosphere.facet", 7 + "passthrough": "keep", 8 + "rules": [ 9 + { "match": { "name": "word-timing" }, "replace": { "name": "timestamp" } } 10 + ] 11 + }
+13
formats/tv.ionosphere/lenses/vod-to-talk.lens.json
··· 1 + { 2 + "$type": "org.relationaltext.lens", 3 + "id": "place.stream.video.to.tv.ionosphere.talk.v1", 4 + "description": "Map Streamplace VOD record fields to ionosphere talk video metadata", 5 + "source": "place.stream.video", 6 + "target": "tv.ionosphere.talk", 7 + "invertible": false, 8 + "rules": [ 9 + { "match": { "name": "title" }, "replace": { "name": "title" } }, 10 + { "match": { "name": "duration" }, "replace": { "name": "duration" } }, 11 + { "match": { "name": "creator" }, "replace": { "name": "streamCreator" } } 12 + ] 13 + }
+2 -1
formats/tv.ionosphere/package.json
··· 5 5 "main": "ts/index.ts", 6 6 "exports": { 7 7 ".": "./ts/index.ts", 8 - "./assemble": "./ts/assemble.ts" 8 + "./assemble": "./ts/assemble.ts", 9 + "./lenses": "./ts/lenses.ts" 9 10 }, 10 11 "dependencies": { 11 12 "relational-text": "^0.1.1"
+42
formats/tv.ionosphere/ts/lenses.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { applyLens, type LensSpec } from "./lenses.js"; 3 + 4 + describe("applyLens", () => { 5 + const lens: LensSpec = { 6 + $type: "org.relationaltext.lens", 7 + id: "test", 8 + description: "test lens", 9 + source: "source", 10 + target: "target", 11 + rules: [ 12 + { match: { name: "name" }, replace: { name: "title" } }, 13 + { match: { name: "additionalData.room" }, replace: { name: "room" } }, 14 + ], 15 + }; 16 + 17 + it("renames fields per rules", () => { 18 + const result = applyLens(lens, { name: "Hello" }); 19 + expect(result.title).toBe("Hello"); 20 + expect(result.name).toBeUndefined(); 21 + }); 22 + 23 + it("handles dotted paths", () => { 24 + const result = applyLens(lens, { 25 + name: "Test", 26 + additionalData: { room: "Room 1", type: "presentation" }, 27 + }); 28 + expect(result.title).toBe("Test"); 29 + expect(result.room).toBe("Room 1"); 30 + }); 31 + 32 + it("drops unmatched fields by default", () => { 33 + const result = applyLens(lens, { name: "Test", extra: "value" }); 34 + expect(result.extra).toBeUndefined(); 35 + }); 36 + 37 + it("keeps unmatched fields with passthrough=keep", () => { 38 + const keepLens = { ...lens, passthrough: "keep" as const }; 39 + const result = applyLens(keepLens, { name: "Test", extra: "value" }); 40 + expect(result.extra).toBe("value"); 41 + }); 42 + });
+69
formats/tv.ionosphere/ts/lenses.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import path from "node:path"; 3 + 4 + export interface LensSpec { 5 + $type: string; 6 + id: string; 7 + description: string; 8 + source: string; 9 + target: string; 10 + invertible?: boolean; 11 + passthrough?: "keep" | "drop"; 12 + rules: LensRule[]; 13 + } 14 + 15 + export interface LensRule { 16 + match: { name: string }; 17 + replace: { name: string }; 18 + } 19 + 20 + const LENS_DIR = path.resolve(import.meta.dirname, "../lenses"); 21 + 22 + export function loadLens(filename: string): LensSpec { 23 + const raw = readFileSync(path.join(LENS_DIR, filename), "utf-8"); 24 + return JSON.parse(raw); 25 + } 26 + 27 + /** 28 + * Apply a lens to transform a source record's fields to target field names. 29 + * Returns a new object with renamed keys per the lens rules. 30 + * Fields not matched by any rule are kept or dropped per `passthrough`. 31 + */ 32 + export function applyLens( 33 + lens: LensSpec, 34 + source: Record<string, any> 35 + ): Record<string, any> { 36 + const result: Record<string, any> = {}; 37 + const matched = new Set<string>(); 38 + 39 + for (const rule of lens.rules) { 40 + const sourceName = rule.match.name; 41 + // Support dotted paths (e.g., "additionalData.room") 42 + const value = getNestedValue(source, sourceName); 43 + if (value !== undefined) { 44 + result[rule.replace.name] = value; 45 + matched.add(sourceName.split(".")[0]); 46 + } 47 + } 48 + 49 + // Handle passthrough 50 + if (lens.passthrough === "keep") { 51 + for (const [key, value] of Object.entries(source)) { 52 + if (!matched.has(key) && !(key in result)) { 53 + result[key] = value; 54 + } 55 + } 56 + } 57 + 58 + return result; 59 + } 60 + 61 + function getNestedValue(obj: any, path: string): any { 62 + const parts = path.split("."); 63 + let current = obj; 64 + for (const part of parts) { 65 + if (current == null) return undefined; 66 + current = current[part]; 67 + } 68 + return current; 69 + }