webapps/packages/layers/content/app/utils/content-canonical.ts
Dominik Milacher 0dc24c4db7
Some checks failed
Build and deploy updated apps / Build & deploy (push) Failing after 50s
Extend ux layer and overhaul panoramablick-saalbach.at
2025-11-21 21:17:52 +01:00

417 lines
10 KiB
TypeScript

export type Primitive = string | number | boolean | null
export type JsonObject = { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
export type JsonValue = Primitive | JsonObject | JsonArray
export type CanonicalValue = CanonicalPrimitive | CanonicalObject | CanonicalArray
// We do not use Map or Set as properties, they cant be serialized to JSON properly
// Instead we use objects and arrays under the hood, and optimize speed/memory for runtime, not build time
// TODO: In fromCanonicalJson the .x are not set (not critical actually)
export class CanonicalBase {
protected readonly x: number = 0
private readonly t?: string[]
constructor(tags: string[]) {
const clean = cleanTags(tags)
this.t = clean.length ? clean : undefined
}
get tags(): readonly string[] {
return this.t ?? []
}
get delete(): boolean {
return this.t?.includes('delete') ?? false
}
get replace(): boolean {
return this.t?.includes('replace') ?? false
}
static fromCanonicalJson(json: JsonObject): CanonicalBase {
const base = Object.create(CanonicalBase.prototype)
base.t = json.t
return base as CanonicalBase
}
mergeInto(other: CanonicalBase): CanonicalBase {
throw new Error('Not implemented')
}
dropTags(except: string[]): CanonicalBase {
throw new Error('Not implemented')
}
reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any {
throw new Error('Not implemented')
}
}
export class CanonicalPrimitive extends CanonicalBase {
protected override readonly x: number = 1
private v: Primitive | string[]
private e?: boolean
get value(): Readonly<Primitive | string[]> {
return this.v
}
get expand(): boolean {
return this.e ?? false
}
constructor(tags: string[], value: Primitive | string[], expand?: boolean) {
super(tags)
this.v = value
this.e = expand
}
static fromInputJson(outerTags: string[], primitive: Primitive): CanonicalPrimitive {
let v: Primitive | string[]
let e: boolean | undefined = undefined
if (typeof primitive === 'string') {
const segments = primitive.split(/\$\{(.*?)\}/)
if (segments.length > 1) {
v = segments
e = true
} else {
v = primitive
}
} else {
v = primitive
}
return new CanonicalPrimitive(outerTags, v, e)
}
static override fromCanonicalJson(json: JsonObject): CanonicalPrimitive {
const primitive = super.fromCanonicalJson(json) as CanonicalPrimitive
Object.setPrototypeOf(primitive, CanonicalPrimitive.prototype)
primitive.v = json.v as Primitive | string[]
primitive.e = json.e as boolean | undefined
return primitive
}
override mergeInto(other: CanonicalPrimitive): CanonicalPrimitive {
return this
}
override dropTags(except: string[]): CanonicalPrimitive {
return new CanonicalPrimitive(
this.tags.filter((t) => except.includes(t)),
this.v,
this.e
)
}
override reduce(options: {
predicate: (tags?: string[]) => boolean
expand?: boolean
}): CanonicalPrimitive | Primitive | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
if (this.expand) {
options.expand = true
return this
}
return this.v as Primitive
}
resolve(resolver: (key: string) => JsonValue): string {
return (this.v as string[]).map((p, i) => (i % 2 ? resolver(p) : p)).join('')
}
}
export class CanonicalObject extends CanonicalBase {
protected override readonly x: number = 2
private n?: Record<string, CanonicalValue>
private a?: CanonicalValue[]
constructor(tags: string[], named: Record<string, CanonicalValue>, anonymous: CanonicalValue[]) {
super(tags)
if (Object.keys(named).length) {
this.n = named
}
if (anonymous.length) {
this.a = anonymous
}
}
get named(): Readonly<Record<string, CanonicalValue>> {
return this.n ?? {}
}
get anonymous(): readonly CanonicalValue[] {
return this.a ?? []
}
static fromInputJson(outerTags: string[], json: JsonObject): CanonicalObject {
let n: Record<string, CanonicalValue> = {}
let a: CanonicalValue[] = []
for (const key in json) {
if (key === '$tags') continue
let [name, ...tags] = key.split('$')
tags = cleanTags(tags)
if (name) {
n[name] = fromInputJson(tags, json[key] as JsonValue)
} else if (tags.length) {
a.push(fromInputJson(tags, json[key] as JsonValue))
} else {
console.error(`No name and no tags found in key '${key}'`)
}
}
return new CanonicalObject([...outerTags, ...getInnerTags(json)], n, a)
}
static override fromCanonicalJson(json: JsonObject): CanonicalObject {
const object = super.fromCanonicalJson(json) as CanonicalObject
Object.setPrototypeOf(object, CanonicalObject.prototype)
if (json.n) {
object.n = {}
for (const [k, v] of Object.entries(json.n)) {
object.n[k] = fromCanonicalJson(v)
}
}
if (json.a) {
object.a = (json.a as JsonValue[]).map((i) => fromCanonicalJson(i))
}
return object
}
override mergeInto(other: CanonicalObject): CanonicalObject {
const n: Record<string, CanonicalValue> = {}
for (const key of new Set([...Object.keys(this.named), ...Object.keys(other.named)])) {
let merged: CanonicalValue | undefined = undefined
if (key in this.named && key in other.named) {
merged = mergeInto(this.named[key]!, other.named[key]!)
} else {
merged = key in this.named ? this.named[key] : other.named[key]
}
if (merged) {
n[key] = merged
}
}
return new CanonicalObject([...this.tags], n, [...this.anonymous])
}
override dropTags(except: string[]): CanonicalObject {
const n = Object.fromEntries(
Object.entries(this.named).map(([k, v]) => [k, v.dropTags(except)])
)
const a = this.anonymous.map((a) => a.dropTags(except))
return new CanonicalObject(
this.tags.filter((t) => except.includes(t)),
n,
a
)
}
override reduce(options: {
predicate: (tags?: string[]) => boolean
expand?: boolean
}): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
const n = Object.fromEntries(
Object.entries(this.named)
.map(([k, v]) => [k, v.reduce(options)])
.filter(([k, v]) => v !== undefined)
)
if (Object.keys(n).length) {
return n
}
const a = this.anonymous.map((a) => a.reduce(options)).filter((a) => a !== undefined)
if (a.length) {
if (a.length > 1) {
console.error(`Multiple anonymous object properties: ${JSON.stringify(a)}`)
}
return a[0]
}
return undefined
}
}
export class CanonicalArray extends CanonicalBase {
protected override readonly x: number = 3
private i?: CanonicalValue[]
constructor(tags: string[], items: CanonicalValue[]) {
super(tags)
if (items.length) {
this.i = items
}
}
get items(): readonly CanonicalValue[] {
return this.i ?? []
}
static fromInputJson(outerTags: string[], json: JsonArray): CanonicalArray {
const tags = [...outerTags]
json = json.filter((item) => {
if (
typeof item === 'object' &&
item !== null &&
Object.keys(item).length === 1 &&
'$tags' in item
) {
tags.push(...getInnerTags(item))
return false
}
return true
})
return new CanonicalArray(
tags,
json.map((i) => fromInputJson([], i))
)
}
static override fromCanonicalJson(json: JsonObject): CanonicalArray {
const array = super.fromCanonicalJson(json) as CanonicalArray
Object.setPrototypeOf(array, CanonicalArray.prototype)
if (json.i) {
array.i = (json.i as JsonValue[]).map((i) => fromCanonicalJson(i))
}
return array
}
override mergeInto(other: CanonicalArray): CanonicalArray {
console.error('Merging arrays not yet implemented')
return this // TODO
}
override dropTags(except: string[]): CanonicalArray {
const items = (this.i ?? []).map((i) => i.dropTags(except))
return new CanonicalArray(
this.tags.filter((t) => except.includes(t)),
items
)
}
override reduce(options: {
predicate: (tags?: string[]) => boolean
expand?: boolean
}): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
return this.items.map((i) => i.reduce(options)).filter((i) => i !== undefined)
}
}
export function fromInputJson(outerTags: string[], json: JsonValue): CanonicalValue {
if (Array.isArray(json)) {
return CanonicalArray.fromInputJson(outerTags, json)
} else if (typeof json === 'object' && json !== null) {
return CanonicalObject.fromInputJson(outerTags, json)
}
return CanonicalPrimitive.fromInputJson(outerTags, json)
}
export function fromCanonicalJson(json: JsonValue): CanonicalValue {
if (typeof json === 'object' && json !== null) {
json = json as JsonObject
if (json.x === 1) {
return CanonicalPrimitive.fromCanonicalJson(json)
} else if (json.x == 2) {
return CanonicalObject.fromCanonicalJson(json)
}
return CanonicalArray.fromCanonicalJson(json)
}
throw new Error(`Not a valid canonical object: ${json}`)
}
function mergeInto(self: CanonicalValue, other: CanonicalValue): CanonicalValue | undefined {
if (self instanceof CanonicalBase) {
if (self.delete) {
return undefined
}
if (self.replace) {
return self
}
}
if (
(self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive) ||
(self instanceof CanonicalObject && other instanceof CanonicalObject) ||
(self instanceof CanonicalArray && other instanceof CanonicalArray)
) {
return self.mergeInto(other)
}
return self
}
export function merge(items: CanonicalValue[]): CanonicalValue | undefined {
if (!items.length) {
return undefined
} else if (items.length === 1) {
return items[0]
}
return items.reduceRight((acc, cur) => mergeInto(cur, acc))
}
function getInnerTags(json: JsonObject): string[] {
const tags = json['$tags']
if (typeof tags === 'string') {
return cleanTags(tags.split(' '))
}
return []
}
function cleanTags(tags: string[]): string[] {
return [...new Set(tags)]
.map((t) => t.trim())
.filter((t) => !!t)
.sort()
}