mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
446
ui/src/app/app-config/config-cursor.ts
Normal file
446
ui/src/app/app-config/config-cursor.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
ValueSpec, ConfigSpec, UniqueBy, ValueSpecOf, ValueType
|
||||
} from './config-types'
|
||||
import * as pointer from 'json-pointer'
|
||||
import * as handlebars from 'handlebars'
|
||||
import { Annotations, getDefaultObject, getDefaultUnion, listInnerSpec, mapConfigSpec, Range } from './config-utilities'
|
||||
|
||||
export class ConfigCursor<T extends ValueType> {
|
||||
private cachedSpec?: ValueSpecOf<T>
|
||||
|
||||
constructor (
|
||||
private readonly rootSpec: ConfigSpec,
|
||||
private readonly rootOldConfig: object,
|
||||
private readonly rootMappedConfig: object = null,
|
||||
private readonly rootConfig: object = null,
|
||||
private readonly ptr: string = '',
|
||||
) {
|
||||
if (!this.rootOldConfig) {
|
||||
this.rootOldConfig = getDefaultObject(this.rootSpec)
|
||||
}
|
||||
if (!this.rootMappedConfig) {
|
||||
this.rootMappedConfig = JSON.parse(JSON.stringify(this.rootOldConfig))
|
||||
mapConfigSpec(this.rootSpec, this.rootMappedConfig)
|
||||
}
|
||||
if (!this.rootConfig) {
|
||||
this.rootConfig = JSON.parse(JSON.stringify(this.rootMappedConfig))
|
||||
}
|
||||
}
|
||||
|
||||
seek<S extends ValueType> (ptr: string): ConfigCursor<S> {
|
||||
return new ConfigCursor(
|
||||
this.rootSpec,
|
||||
this.rootOldConfig,
|
||||
this.rootMappedConfig,
|
||||
this.rootConfig,
|
||||
pointer.compile(
|
||||
pointer.parse(this.ptr)
|
||||
.concat(pointer.parse(ptr)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
seekNext<S extends ValueType> (key: string | number): ConfigCursor<S> {
|
||||
return this.seek(pointer.compile([`${key}`]))
|
||||
}
|
||||
|
||||
unseek<S extends ValueType> (levels?: number): ConfigCursor<S> {
|
||||
let ptr: string
|
||||
if (levels === undefined) {
|
||||
ptr = ''
|
||||
} else {
|
||||
// TODO, delete or make use of, it isn't being used so far
|
||||
// This is not being used so far
|
||||
let ptr_arr = pointer.parse(this.ptr)
|
||||
for (let i = 0; i < levels; i++) {
|
||||
ptr_arr.pop()
|
||||
}
|
||||
ptr = pointer.compile(ptr_arr)
|
||||
}
|
||||
return new ConfigCursor(
|
||||
this.rootSpec,
|
||||
this.rootOldConfig,
|
||||
this.rootMappedConfig,
|
||||
this.rootConfig,
|
||||
ptr,
|
||||
)
|
||||
}
|
||||
|
||||
key (): string {
|
||||
return pointer.parse(this.ptr).pop()
|
||||
}
|
||||
|
||||
oldConfig (): any {
|
||||
if (pointer.has(this.rootOldConfig, this.ptr)) {
|
||||
return pointer.get(this.rootOldConfig, this.ptr)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
mappedConfig (): any {
|
||||
if (pointer.has(this.rootMappedConfig, this.ptr)) {
|
||||
return pointer.get(this.rootMappedConfig, this.ptr)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
toString (): string {
|
||||
const spec: ValueSpec = this.spec()
|
||||
const config = this.config()
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
return config
|
||||
case 'number':
|
||||
return `${config}${spec.units ? ' ' + spec.units : ''}`
|
||||
case 'object':
|
||||
return spec.displayAs ? handlebars.compile(spec.displayAs)(config) : ''
|
||||
case 'union':
|
||||
return spec.displayAs ? handlebars.compile(spec.displayAs)(config) : config[spec.tag.id]
|
||||
case 'pointer':
|
||||
return 'System Defined'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// if (config : T) then (spec : ValueSpecOf<T>)
|
||||
config (): any {
|
||||
if (pointer.has(this.rootConfig, this.ptr)) {
|
||||
return pointer.get(this.rootConfig, this.ptr)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// if (config : T) then (spec : ValueSpecOf<T>)
|
||||
spec (): ValueSpecOf<T> {
|
||||
if (this.cachedSpec) return this.cachedSpec
|
||||
const parsed = pointer.parse(this.ptr)
|
||||
|
||||
// We elevate the rootSpec (ConfigSpec) to a dummy ValueSpecObject
|
||||
let ret: ValueSpec = {
|
||||
type: 'object',
|
||||
spec: this.rootSpec,
|
||||
nullable: false,
|
||||
nullByDefault: false,
|
||||
name: 'Config',
|
||||
displayAs: 'Config',
|
||||
uniqueBy: null,
|
||||
}
|
||||
let ptr = []
|
||||
for (let seg of parsed) {
|
||||
switch (ret.type) {
|
||||
case 'object':
|
||||
ret = ret.spec[seg]
|
||||
break
|
||||
case 'union':
|
||||
if (seg === ret.tag.id) {
|
||||
ret = {
|
||||
type: 'enum',
|
||||
default: ret.default,
|
||||
values: Object.keys(ret.variants),
|
||||
name: ret.tag.name,
|
||||
description: ret.tag.description,
|
||||
valueNames: ret.tag.variantNames,
|
||||
}
|
||||
} else {
|
||||
const cfg = this.unseek().seek(pointer.compile(ptr))
|
||||
ret = ret.variants[cfg.config()[ret.tag.id]][seg]
|
||||
}
|
||||
break
|
||||
case 'list':
|
||||
//in essence, for a list we replace the list typed ValueSpecOf with it's internal ListValueSpec, a ValueSpecOf<T> where config @ ptr is of type T[].
|
||||
// we also append default values to it.
|
||||
// note also that jsonKey is not used. jsonKey in this case is an index of an array, like 0, 1, etc.
|
||||
// this implies that every index of a list has an identical inner spec
|
||||
ret = listInnerSpec(ret)
|
||||
break
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
if (ret === undefined) break
|
||||
ptr.push(seg)
|
||||
}
|
||||
this.cachedSpec = ret as ValueSpecOf<T>
|
||||
return this.cachedSpec
|
||||
}
|
||||
|
||||
checkInvalid (): string | null { // null if valid
|
||||
const spec: ValueSpec = this.spec()
|
||||
const cfg = this.config()
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
if (!cfg) {
|
||||
return spec.nullable ? null : `${spec.name} is missing.`
|
||||
} else if (typeof cfg === 'string') {
|
||||
if (!spec.pattern || new RegExp(spec.pattern).test(cfg)) {
|
||||
return null
|
||||
} else {
|
||||
return spec.patternDescription
|
||||
}
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'number':
|
||||
if (!cfg) {
|
||||
return spec.nullable ? null : `${spec.name} is missing.`
|
||||
} else if (typeof cfg === 'number') {
|
||||
if (spec.integral && cfg != Math.trunc(cfg)) {
|
||||
return `${spec.name} must be an integer.`
|
||||
}
|
||||
try {
|
||||
Range.from(spec.range).checkIncludes(cfg)
|
||||
return null
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected number, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'boolean':
|
||||
if (typeof cfg === 'boolean') {
|
||||
return null
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected boolean, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'enum':
|
||||
if (typeof cfg === 'string') {
|
||||
return spec.values.includes(cfg) ? null : `${cfg} is not a valid selection.`
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'list':
|
||||
if (Array.isArray(cfg)) {
|
||||
const range = Range.from(spec.range)
|
||||
const min = range.integralMin()
|
||||
const max = range.integralMax()
|
||||
const length = cfg.length
|
||||
if (min && length < min) {
|
||||
return spec.subtype === 'enum' ? 'Not enough options selected.' : 'List is too short.'
|
||||
}
|
||||
if (max && length > max) {
|
||||
return spec.subtype === 'enum' ? 'Too many options selected.' : 'List is too long.'
|
||||
}
|
||||
for (let idx in cfg) {
|
||||
let cursor = this.seekNext(idx)
|
||||
if (cursor.checkInvalid()) {
|
||||
return `Item #${idx + 1} is invalid. ${cursor.checkInvalid()}`
|
||||
}
|
||||
for (let idx2 in cfg) {
|
||||
if (idx !== idx2 && cursor.equals(this.seekNext(idx2))) {
|
||||
return `Item #${idx + 1} is not unique.`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected array, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'object':
|
||||
if (!cfg) {
|
||||
return spec.nullable ? null : `${spec.name} is missing.`
|
||||
} else if (typeof cfg === 'object' && !Array.isArray(cfg)) {
|
||||
for (let idx in spec.spec) {
|
||||
if (this.seekNext(idx).checkInvalid()) {
|
||||
return `${spec.spec[idx].name} is invalid.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected object, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
case 'pointer':
|
||||
return null
|
||||
case 'union':
|
||||
if (typeof cfg === 'object' && !Array.isArray(cfg)) {
|
||||
if (typeof cfg[spec.tag.id] === 'string') {
|
||||
for (let idx in spec.variants[cfg[spec.tag.id]]) {
|
||||
if (this.seekNext(idx).checkInvalid()) {
|
||||
return `${spec.variants[cfg[spec.tag.id]][idx].name} is invalid.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}/${spec.tag.id}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
} else {
|
||||
throw new TypeError(`${this.ptr}: expected object, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isNew (): boolean {
|
||||
const oldCfg = this.oldConfig()
|
||||
const mappedCfg = this.mappedConfig()
|
||||
if (mappedCfg && oldCfg && typeof mappedCfg === 'object' && typeof oldCfg === 'object') {
|
||||
for (let key in mappedCfg) {
|
||||
if (this.seekNext(key).isNew()) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
return mappedCfg !== oldCfg
|
||||
}
|
||||
}
|
||||
|
||||
isEdited (): boolean {
|
||||
const cfg = this.config()
|
||||
const mappedCfg = this.mappedConfig()
|
||||
if (cfg && mappedCfg && typeof cfg === 'object' && typeof mappedCfg === 'object') {
|
||||
const spec = this.spec()
|
||||
let allKeys
|
||||
if (spec.type === 'union') {
|
||||
let unionSpec = spec as ValueSpecOf<'union'>
|
||||
const labelForSelection = unionSpec.tag.id
|
||||
allKeys = new Set([...Object.keys(unionSpec.variants[cfg[labelForSelection]])])
|
||||
} else {
|
||||
allKeys = new Set([...Object.keys(cfg), ...Object.keys(mappedCfg)])
|
||||
}
|
||||
|
||||
for (let key of allKeys) {
|
||||
if (this.seekNext(key).isEdited()) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
return cfg !== mappedCfg
|
||||
}
|
||||
}
|
||||
|
||||
equals (cursor: ConfigCursor<T>): boolean {
|
||||
const lhs = this.config()
|
||||
const rhs = cursor.config()
|
||||
const spec: ValueSpec = this.spec()
|
||||
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'enum':
|
||||
return lhs === rhs
|
||||
case 'object':
|
||||
case 'union':
|
||||
return isEqual(spec.uniqueBy, this as ConfigCursor<'object' | 'union'>, cursor as ConfigCursor<'object' | 'union'>)
|
||||
case 'list':
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false
|
||||
}
|
||||
for (let idx = 0; idx < lhs.length; idx++) {
|
||||
if (!this.seekNext(`${idx}`).equals(cursor.seekNext(`${idx}`))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getAnnotations (): Annotations<T> {
|
||||
const spec: ValueSpec = this.spec()
|
||||
switch (spec.type) {
|
||||
case 'object': {
|
||||
const ret: Annotations<'object'> = {
|
||||
self: {
|
||||
invalid: this.checkInvalid(),
|
||||
edited: this.isEdited(),
|
||||
added: this.isNew(),
|
||||
},
|
||||
members: { },
|
||||
}
|
||||
for (let key in spec.spec) {
|
||||
let annotation: any = this.seekNext(key).getAnnotations()
|
||||
if ('self' in annotation) {
|
||||
annotation = annotation.self
|
||||
}
|
||||
ret.members[key] = annotation
|
||||
}
|
||||
return ret as Annotations<T>
|
||||
}
|
||||
case 'union': {
|
||||
const ret: Annotations<'union'> = {
|
||||
self: {
|
||||
invalid: this.checkInvalid(),
|
||||
edited: this.isEdited(),
|
||||
added: this.isNew(),
|
||||
},
|
||||
members: {
|
||||
[spec.tag.id]: this.seekNext<'enum'>(spec.tag.id).getAnnotations(),
|
||||
},
|
||||
}
|
||||
for (let key in spec.variants[this.config()[spec.tag.id]]) {
|
||||
let annotation: any = this.seekNext(key).getAnnotations()
|
||||
if ('self' in annotation) {
|
||||
annotation = annotation.self
|
||||
}
|
||||
ret.members[key] = annotation
|
||||
}
|
||||
return ret as Annotations<T>
|
||||
}
|
||||
case 'list': {
|
||||
const ret: Annotations<'list'> = {
|
||||
self: {
|
||||
invalid: this.checkInvalid(),
|
||||
edited: this.isEdited(),
|
||||
added: this.isNew(),
|
||||
},
|
||||
members: [],
|
||||
}
|
||||
for (let key in this.config()) {
|
||||
let annotation: any = this.seekNext(key).getAnnotations()
|
||||
if ('self' in annotation) {
|
||||
annotation = annotation.self
|
||||
}
|
||||
ret.members[key] = annotation
|
||||
}
|
||||
return ret as Annotations<T>
|
||||
}
|
||||
default:
|
||||
return {
|
||||
invalid: this.checkInvalid(),
|
||||
edited: this.isEdited(),
|
||||
added: this.isNew(),
|
||||
} as Annotations<T>
|
||||
}
|
||||
}
|
||||
|
||||
async createFirstEntryForList () {
|
||||
const spec: ValueSpec = this.spec()
|
||||
|
||||
if (spec.type === 'object' && !this.config()) {
|
||||
pointer.set(this.rootConfig, this.ptr, getDefaultObject(spec.spec))
|
||||
}
|
||||
|
||||
if (spec.type === 'union' && !this.config()) {
|
||||
pointer.set(this.rootConfig, this.ptr, getDefaultUnion(spec))
|
||||
}
|
||||
}
|
||||
|
||||
injectModalData (res: { data?: any }): void {
|
||||
if (res.data !== undefined) {
|
||||
pointer.set(this.rootConfig, this.ptr, res.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEqual (uniqueBy: UniqueBy, lhs: ConfigCursor<'object'>, rhs: ConfigCursor<'object'>): boolean {
|
||||
if (uniqueBy === null) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
return lhs.seekNext(uniqueBy).equals(rhs.seekNext(uniqueBy))
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (isEqual(subSpec, lhs, rhs)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!isEqual(subSpec, lhs, rhs)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
133
ui/src/app/app-config/config-types.ts
Normal file
133
ui/src/app/app-config/config-types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export interface ConfigSpec { [key: string]: ValueSpec }
|
||||
|
||||
export type ValueType = 'string' | 'number' | 'boolean' | 'enum' | 'list' | 'object' | 'pointer' | 'union'
|
||||
export type ValueSpec = ValueSpecOf<ValueType>
|
||||
|
||||
// core spec types. These types provide the metadata for performing validations
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends 'string' ? ValueSpecString :
|
||||
T extends 'number' ? ValueSpecNumber :
|
||||
T extends 'boolean' ? ValueSpecBoolean :
|
||||
T extends 'enum' ? ValueSpecEnum :
|
||||
T extends 'list' ? ValueSpecList :
|
||||
T extends 'object' ? ValueSpecObject :
|
||||
T extends 'pointer' ? ValueSpecPointer :
|
||||
T extends 'union' ? ValueSpecUnion :
|
||||
never
|
||||
|
||||
export interface ValueSpecString extends ListValueSpecString, WithStandalone {
|
||||
type: 'string'
|
||||
default?: DefaultString
|
||||
nullable: boolean
|
||||
masked: boolean
|
||||
copyable: boolean
|
||||
}
|
||||
|
||||
export interface ValueSpecNumber extends ListValueSpecNumber, WithStandalone {
|
||||
type: 'number'
|
||||
nullable: boolean
|
||||
default?: number
|
||||
}
|
||||
|
||||
export interface ValueSpecEnum extends ListValueSpecEnum, WithStandalone {
|
||||
type: 'enum'
|
||||
default: string
|
||||
}
|
||||
|
||||
export interface ValueSpecBoolean extends WithStandalone {
|
||||
type: 'boolean'
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface ValueSpecUnion extends ListValueSpecUnion, WithStandalone {
|
||||
type: 'union'
|
||||
}
|
||||
|
||||
export interface ValueSpecPointer extends WithStandalone {
|
||||
type: 'pointer'
|
||||
subtype: 'app' | 'system'
|
||||
target: 'lan-address' | 'tor-address' | 'config'
|
||||
'app-id': string
|
||||
}
|
||||
|
||||
export interface ValueSpecObject extends ListValueSpecObject, WithStandalone {
|
||||
type: 'object'
|
||||
nullable: boolean
|
||||
nullByDefault: boolean
|
||||
}
|
||||
|
||||
export interface WithStandalone {
|
||||
name: string
|
||||
description?: string
|
||||
changeWarning?: string
|
||||
}
|
||||
|
||||
// no lists of booleans, lists, pointers
|
||||
export type ListValueSpecType = 'string' | 'number' | 'enum' | 'object' | 'union'
|
||||
|
||||
// represents a spec for the values of a list
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
T extends 'string' ? ListValueSpecString :
|
||||
T extends 'number' ? ListValueSpecNumber :
|
||||
T extends 'enum' ? ListValueSpecEnum :
|
||||
T extends 'object' ? ListValueSpecObject :
|
||||
T extends 'union' ? ListValueSpecUnion :
|
||||
never
|
||||
|
||||
// represents a spec for a list
|
||||
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
|
||||
export interface ValueSpecListOf<T extends ListValueSpecType> extends WithStandalone {
|
||||
type: 'list'
|
||||
subtype: T
|
||||
spec: ListValueSpecOf<T>
|
||||
range: string // '[0,1]' (inclusive) OR '[0,*)' (right unbounded), normal math rules
|
||||
default: string[] | number[] | DefaultString[] | object[]
|
||||
}
|
||||
|
||||
// sometimes the type checker needs just a little bit of help
|
||||
export function isValueSpecListOf<S extends ListValueSpecType> (t: ValueSpecList, s: S): t is ValueSpecListOf<S> {
|
||||
return t.subtype === s
|
||||
}
|
||||
|
||||
export interface ListValueSpecString {
|
||||
pattern?: string
|
||||
patternDescription?: string
|
||||
}
|
||||
|
||||
export interface ListValueSpecNumber {
|
||||
range: string
|
||||
integral: boolean
|
||||
units?: string
|
||||
}
|
||||
|
||||
export interface ListValueSpecEnum {
|
||||
values: string[]
|
||||
valueNames: { [value: string]: string }
|
||||
}
|
||||
|
||||
export interface ListValueSpecObject {
|
||||
spec: ConfigSpec //this is a mapped type of the config object at this level, replacing the object's values with specs on those values
|
||||
uniqueBy: UniqueBy //indicates whether duplicates can be permitted in the list
|
||||
displayAs?: string //this should be a handlebars template which can make use of the entire config which corresponds to 'spec'
|
||||
}
|
||||
|
||||
export type UniqueBy = null | string | { any: UniqueBy[] } | { all: UniqueBy[] }
|
||||
|
||||
export interface ListValueSpecUnion {
|
||||
tag: UnionTagSpec
|
||||
variants: { [key: string]: ConfigSpec }
|
||||
displayAs?: string //this may be a handlebars template which can conditionally (on tag.id) make use of each union's entries, or if left blank will display as tag.id
|
||||
uniqueBy: UniqueBy
|
||||
default: string //this should be the variantName which one prefers a user to start with by default when creating a new union instance in a list
|
||||
}
|
||||
|
||||
export interface UnionTagSpec {
|
||||
id: string //The name of the field containing one of the union variants
|
||||
name: string
|
||||
description?: string
|
||||
variantNames: { //the name of each variant
|
||||
[variant: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DefaultString = string | { charset: string, len: number }
|
||||
413
ui/src/app/app-config/config-utilities.ts
Normal file
413
ui/src/app/app-config/config-utilities.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
ValueSpec, ValueSpecList, DefaultString, ValueSpecUnion, ConfigSpec,
|
||||
ValueSpecObject, ValueSpecString, ValueSpecEnum, ValueSpecNumber,
|
||||
ValueSpecBoolean, ValueSpecPointer, ValueSpecOf, ListValueSpecType
|
||||
} from './config-types'
|
||||
|
||||
export interface Annotation {
|
||||
invalid: string | null
|
||||
edited: boolean
|
||||
added: boolean
|
||||
}
|
||||
|
||||
export type Annotations<T extends string> =
|
||||
T extends 'object' | 'union' ? { self: Annotation, members: { [key: string]: Annotation } } :
|
||||
T extends 'list' ? { self: Annotation, members: Annotation[] } :
|
||||
Annotation
|
||||
|
||||
export class Range {
|
||||
min?: number
|
||||
max?: number
|
||||
minInclusive: boolean
|
||||
maxInclusive: boolean
|
||||
|
||||
|
||||
static from (s: string): Range {
|
||||
const r = new Range()
|
||||
r.minInclusive = s.startsWith('[')
|
||||
r.maxInclusive = s.endsWith(']')
|
||||
const [minStr, maxStr] = s.split(',').map(a => a.trim())
|
||||
r.min = minStr === '(*' ? undefined : Number(minStr.slice(1))
|
||||
r.max = maxStr === '*)' ? undefined : Number(maxStr.slice(0, -1))
|
||||
return r
|
||||
}
|
||||
|
||||
checkIncludes (n: number) {
|
||||
if (this.hasMin() !== undefined && ((!this.minInclusive && this.min == n || (this.min > n)))) {
|
||||
throw new Error(`Value must be ${this.minMessage()}.`)
|
||||
}
|
||||
if (this.hasMax() && ((!this.maxInclusive && this.max == n || (this.max < n)))) {
|
||||
throw new Error(`Value must be ${this.maxMessage()}.`)
|
||||
}
|
||||
}
|
||||
|
||||
hasMin (): boolean {
|
||||
return this.min !== undefined
|
||||
}
|
||||
|
||||
hasMax (): boolean {
|
||||
return this.max !== undefined
|
||||
}
|
||||
|
||||
minMessage (): string {
|
||||
return `greater than${this.minInclusive ? ' or equal to' : ''} ${this.min}`
|
||||
}
|
||||
|
||||
maxMessage (): string {
|
||||
return `less than${this.maxInclusive ? ' or equal to' : ''} ${this.max}`
|
||||
}
|
||||
|
||||
description (): string {
|
||||
let message = 'Value can be any number.'
|
||||
|
||||
if (this.hasMin() || this.hasMax()) {
|
||||
message = 'Value must be'
|
||||
}
|
||||
|
||||
if (this.hasMin() && this.hasMax()) {
|
||||
message = `${message} ${this.minMessage()} AND ${this.maxMessage()}.`
|
||||
} else if (this.hasMin() && !this.hasMax()) {
|
||||
message = `${message} ${this.minMessage()}.`
|
||||
} else if (!this.hasMin() && this.hasMax()) {
|
||||
message = `${message} ${this.maxMessage()}.`
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
integralMin (): number | undefined {
|
||||
if (this.min) {
|
||||
const ceil = Math.ceil(this.min)
|
||||
if (this.minInclusive) {
|
||||
return ceil
|
||||
} else {
|
||||
if (ceil === this.min) {
|
||||
return ceil + 1
|
||||
} else {
|
||||
return ceil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
integralMax (): number | undefined {
|
||||
if (this.max) {
|
||||
const floor = Math.floor(this.max)
|
||||
if (this.maxInclusive) {
|
||||
return floor
|
||||
} else {
|
||||
if (floor === this.max) {
|
||||
return floor - 1
|
||||
} else {
|
||||
return floor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// converts a ValueSpecList, i.e. a spec for a list, to its inner ListValueSpec, i.e., a spec for the values within the list.
|
||||
// We then augment it with the defalt values (e.g. nullable: false) to make a
|
||||
export function listInnerSpec (listSpec: ValueSpecList): ValueSpecOf<ListValueSpecType> {
|
||||
return {
|
||||
type: listSpec.subtype,
|
||||
nullable: false,
|
||||
name: listSpec.name,
|
||||
description: listSpec.description,
|
||||
changeWarning: listSpec.changeWarning,
|
||||
...listSpec.spec as any, //listSpec.spec is a ListValueSpecOf listSpec.subtype
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSpecToConfigValue (spec: ValueSpec, value: any): any {
|
||||
if (value === undefined) return undefined
|
||||
switch (spec.type) {
|
||||
case 'string': return mapStringSpec(value)
|
||||
case 'number': return mapNumberSpec(value)
|
||||
case 'boolean': return mapBooleanSpec(spec, value)
|
||||
case 'enum': return mapEnumSpec(spec, value)
|
||||
case 'list': return mapListSpec(spec, value)
|
||||
case 'object': return mapObjectSpec(spec, value)
|
||||
case 'union': return mapUnionSpec(spec, value)
|
||||
case 'pointer': return value
|
||||
}
|
||||
}
|
||||
|
||||
export function mapConfigSpec (configSpec: ConfigSpec, value: any): object {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
Object.entries(configSpec).map(([key, val]) => {
|
||||
value[key] = mapSpecToConfigValue(val, value[key])
|
||||
if (value[key] === undefined) {
|
||||
value[key] = getDefaultConfigValue(val)
|
||||
}
|
||||
})
|
||||
return value
|
||||
} else {
|
||||
return getDefaultObject(configSpec)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapObjectSpec (spec: ValueSpecObject, value: any): object {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return mapConfigSpec(spec.spec, value)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function mapUnionSpec (spec: ValueSpecUnion, value: any): object {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const variant = mapEnumSpec({
|
||||
...spec.tag,
|
||||
type: 'enum',
|
||||
default: spec.default,
|
||||
values: Object.keys(spec.variants),
|
||||
valueNames: spec.tag.variantNames,
|
||||
}, value[spec.tag.id])
|
||||
value = mapConfigSpec(spec.variants[variant], value)
|
||||
value[spec.tag.id] = variant
|
||||
return value
|
||||
} else {
|
||||
return getDefaultUnion(spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStringSpec (value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function mapNumberSpec (value: any): number {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function mapEnumSpec (spec: ValueSpecEnum, value: any): string {
|
||||
if (typeof value === 'string' && spec.values.includes(value)) {
|
||||
return value
|
||||
} else {
|
||||
return spec.default
|
||||
}
|
||||
}
|
||||
|
||||
export function mapListSpec (spec: ValueSpecList, value: any): string[] | number[] | object[] {
|
||||
if (Array.isArray(value)) {
|
||||
const innerSpec = listInnerSpec(spec)
|
||||
return value.map(item => mapSpecToConfigValue(innerSpec, item))
|
||||
} else {
|
||||
return getDefaultList(spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapBooleanSpec (spec: ValueSpecBoolean, value: any): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
} else {
|
||||
return spec.default
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultConfigValue (spec: ValueSpec): string | number | object | string[] | number[] | object[] | boolean | null {
|
||||
switch (spec.type) {
|
||||
case 'object':
|
||||
return spec.nullByDefault ? null : getDefaultObject(spec.spec)
|
||||
case 'union':
|
||||
return getDefaultUnion(spec)
|
||||
case 'string':
|
||||
return spec.default ? getDefaultString(spec.default) : null
|
||||
case 'number':
|
||||
return spec.default || null
|
||||
case 'list':
|
||||
return getDefaultList(spec)
|
||||
case 'enum':
|
||||
case 'boolean':
|
||||
return spec.default
|
||||
case 'pointer':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultObject (spec: ConfigSpec): object {
|
||||
const obj = { }
|
||||
Object.entries(spec).map(([key, val]) => {
|
||||
obj[key] = getDefaultConfigValue(val)
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
export function getDefaultList (spec: ValueSpecList): string[] | number[] | object[] {
|
||||
if (spec.subtype === 'object') {
|
||||
const l = (spec.default as any[])
|
||||
const range = Range.from(spec.range)
|
||||
while (l.length < range.integralMin()) {
|
||||
l.push(getDefaultConfigValue(listInnerSpec(spec)))
|
||||
}
|
||||
return l as string[] | number[] | object[]
|
||||
} else {
|
||||
const l = (spec.default as any[]).map(d => getDefaultConfigValue({ ...listInnerSpec(spec), default: d }))
|
||||
return l as string[] | number[] | object[]
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultUnion (spec: ValueSpecUnion): object {
|
||||
return { [spec.tag.id]: spec.default, ...getDefaultObject(spec.variants[spec.default]) }
|
||||
}
|
||||
|
||||
export function getDefaultMapTagKey (defaultSpec: DefaultString = '', value: object): string {
|
||||
const keySrc = getDefaultString(defaultSpec)
|
||||
|
||||
const keys = Object.keys(value)
|
||||
|
||||
let key = keySrc
|
||||
let idx = 1
|
||||
while (keys.includes(key)) {
|
||||
key = `${keySrc}-${idx++}`
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export function getDefaultString (defaultSpec: DefaultString): string {
|
||||
if (typeof defaultSpec === 'string') {
|
||||
return defaultSpec
|
||||
} else {
|
||||
let s = ''
|
||||
for (let i = 0; i < defaultSpec.len; i++) {
|
||||
s = s + getRandomCharInSet(defaultSpec.charset)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultDescription (spec: ValueSpec): string {
|
||||
let toReturn: string | undefined
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
if (typeof spec.default === 'string') {
|
||||
toReturn = spec.default
|
||||
} else if (typeof spec.default === 'object') {
|
||||
toReturn = 'random'
|
||||
}
|
||||
break
|
||||
case 'number':
|
||||
if (typeof spec.default === 'number') {
|
||||
toReturn = String(spec.default)
|
||||
}
|
||||
break
|
||||
case 'boolean':
|
||||
toReturn = spec.default === true ? 'True' : 'False'
|
||||
break
|
||||
case 'enum':
|
||||
toReturn = spec.valueNames[spec.default]
|
||||
break
|
||||
}
|
||||
|
||||
return toReturn || ''
|
||||
}
|
||||
|
||||
// a,g,h,A-Z,,,,-
|
||||
export function getRandomCharInSet (charset: string): string {
|
||||
const set = stringToCharSet(charset)
|
||||
let charIdx = Math.floor(Math.random() * set.len)
|
||||
for (let range of set.ranges) {
|
||||
if (range.len > charIdx) {
|
||||
return String.fromCharCode(range.start.charCodeAt(0) + charIdx)
|
||||
}
|
||||
charIdx -= range.len
|
||||
}
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
function stringToCharSet (charset: string): CharSet {
|
||||
let set: CharSet = { ranges: [], len: 0 }
|
||||
let start: string | null = null
|
||||
let end: string | null = null
|
||||
let in_range = false
|
||||
for (let char of charset) {
|
||||
switch (char) {
|
||||
case ',':
|
||||
if (start !== null && end !== null) {
|
||||
if (start!.charCodeAt(0) > end!.charCodeAt(0)) {
|
||||
throw new Error('start > end of charset')
|
||||
}
|
||||
const len = end.charCodeAt(0) - start.charCodeAt(0) + 1
|
||||
set.ranges.push({
|
||||
start,
|
||||
end,
|
||||
len,
|
||||
})
|
||||
set.len += len
|
||||
start = null
|
||||
end = null
|
||||
in_range = false
|
||||
} else if (start !== null && !in_range) {
|
||||
set.len += 1
|
||||
set.ranges.push({ start, end: start, len: 1 })
|
||||
start = null
|
||||
} else if (start !== null && in_range) {
|
||||
end = ','
|
||||
} else if (start === null && end === null && !in_range) {
|
||||
start = ','
|
||||
} else {
|
||||
throw new Error('unexpected ","')
|
||||
}
|
||||
break
|
||||
case '-':
|
||||
if (start === null) {
|
||||
start = '-'
|
||||
} else if (!in_range) {
|
||||
in_range = true
|
||||
} else if (in_range && end === null) {
|
||||
end = '-'
|
||||
} else {
|
||||
throw new Error('unexpected "-"')
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (start === null) {
|
||||
start = char
|
||||
} else if (in_range && end === null) {
|
||||
end = char
|
||||
} else {
|
||||
throw new Error(`unexpected "${char}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (start !== null && end !== null) {
|
||||
if (start!.charCodeAt(0) > end!.charCodeAt(0)) {
|
||||
throw new Error('start > end of charset')
|
||||
}
|
||||
const len = end.charCodeAt(0) - start.charCodeAt(0) + 1
|
||||
set.ranges.push({
|
||||
start,
|
||||
end,
|
||||
len,
|
||||
})
|
||||
set.len += len
|
||||
} else if (start !== null) {
|
||||
set.len += 1
|
||||
set.ranges.push({
|
||||
start,
|
||||
end: start,
|
||||
len: 1,
|
||||
})
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
interface CharSet {
|
||||
ranges: {
|
||||
start: string
|
||||
end: string
|
||||
len: number
|
||||
}[]
|
||||
len: number
|
||||
}
|
||||
27
ui/src/app/app-config/modal-presentable.ts
Normal file
27
ui/src/app/app-config/modal-presentable.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ConfigCursor } from './config-cursor'
|
||||
import { TrackingModalController } from '../services/tracking-modal-controller.service'
|
||||
|
||||
export class ModalPresentable {
|
||||
constructor (private readonly trackingModalCtrl: TrackingModalController) { }
|
||||
|
||||
async presentModal (cursor: ConfigCursor<any>, callback: () => any) {
|
||||
const modal = await this.trackingModalCtrl.createConfigModal({
|
||||
backdropDismiss: false,
|
||||
presentingElement: await this.trackingModalCtrl.getTop(),
|
||||
componentProps: {
|
||||
cursor,
|
||||
},
|
||||
}, cursor.spec().type)
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
cursor.injectModalData(res)
|
||||
callback()
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
dismissModal (a: any) {
|
||||
return this.trackingModalCtrl.dismiss(a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user