From 6bad293f74fb71f1cf7e173da60701a9982698b0 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Aug 2024 20:19:32 +0100 Subject: [PATCH] config: add support for "list" type "list" is a list of strings, represented in the .ini as repeated entries for a field, e.g. url = myurl1 url = myurl2 Shown in the UI as multiple inputs with delete buttons. --- api-messages.go | 2 +- api.go | 15 +++- config.go | 2 +- main.go | 2 +- migrations.go | 6 +- ts/modules/settings.ts | 182 +++++++++++++++++++++++++++++++++-------- 6 files changed, 169 insertions(+), 40 deletions(-) diff --git a/api-messages.go b/api-messages.go index 46c9f19..cb4f67f 100644 --- a/api-messages.go +++ b/api-messages.go @@ -585,7 +585,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) { respond(401, "Unauthorized", gc) return } - tempConfig, _ := ini.Load(app.configPath) + tempConfig, _ := ini.ShadowLoad(app.configPath) matrix := tempConfig.Section("matrix") matrix.Key("enabled").SetValue("true") matrix.Key("homeserver").SetValue(req.Homeserver) diff --git a/api.go b/api.go index a7b4162..d8f10d5 100644 --- a/api.go +++ b/api.go @@ -290,6 +290,8 @@ func (app *appContext) GetConfig(gc *gin.Context) { val := app.config.Section(sectName).Key(settingName) s := resp.Sections[sectName].Settings[settingName] switch setting.Type { + case "list": + s.Value = val.StringsWithShadows("|") case "text", "email", "select", "password", "note": s.Value = val.MustString("") case "number": @@ -327,7 +329,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { // @Summary Modify app config. // @Produce json -// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." +// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)." // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /config [post] @@ -337,7 +339,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { var req configDTO gc.BindJSON(&req) // Load a new config, as we set various default values in app.config that shouldn't be stored. - tempConfig, _ := ini.Load(app.configPath) + tempConfig, _ := ini.ShadowLoad(app.configPath) for section, settings := range req { if section != "restart-program" { _, err := tempConfig.GetSection(section) @@ -350,6 +352,15 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { } if (section == "discord" || section == "matrix") && setting == "language" { tempConfig.Section("telegram").Key("language").SetValue(value.(string)) + } else if app.configBase.Sections[section].Settings[setting].Type == "list" { + splitValues := strings.Split(value.(string), "|") + for i, v := range splitValues { + if i == 0 { + tempConfig.Section(section).Key(setting).SetValue(v) + } else { + tempConfig.Section(section).Key(setting).AddShadow(v) + } + } } else if value.(string) != app.config.Section(section).Key(setting).MustString("") { tempConfig.Section(section).Key(setting).SetValue(value.(string)) } diff --git a/config.go b/config.go index 2f4a33d..8ea8e12 100644 --- a/config.go +++ b/config.go @@ -36,7 +36,7 @@ func (app *appContext) MustSetValue(section, key, val string) { func (app *appContext) loadConfig() error { var err error - app.config, err = ini.Load(app.configPath) + app.config, err = ini.ShadowLoad(app.configPath) if err != nil { return err } diff --git a/main.go b/main.go index 76002f5..8083563 100644 --- a/main.go +++ b/main.go @@ -232,7 +232,7 @@ func start(asDaemon, firstCall bool) { app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err) } app.info.Printf(lm.CopyConfig, app.configPath) - tempConfig, _ := ini.Load(app.configPath) + tempConfig, _ := ini.ShadowLoad(app.configPath) tempConfig.Section("").Key("first_run").SetValue("true") tempConfig.SaveTo(app.configPath) } diff --git a/migrations.go b/migrations.go index f77b433..a9f03aa 100644 --- a/migrations.go +++ b/migrations.go @@ -60,7 +60,7 @@ func migrateBootstrap(app *appContext) { } func migrateEmailConfig(app *appContext) { - tempConfig, _ := ini.Load(app.configPath) + tempConfig, _ := ini.ShadowLoad(app.configPath) fmt.Println(warning("Part of your email configuration will be migrated to the new \"messages\" section.\nA backup will be made.")) err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") if err != nil { @@ -111,7 +111,7 @@ func migrateEmailStorage(app *appContext) error { return fmt.Errorf("email address was type %T, not string: \"%+v\"\n", addr, addr) } } - config, err := ini.Load(app.configPath) + config, err := ini.ShadowLoad(app.configPath) if err != nil { return err } @@ -467,7 +467,7 @@ func intialiseCustomContent(app *appContext) { // Migrate poorly-named and duplicate "url_base" settings to the single "external jfa-go URL" setting. func migrateExternalURL(app *appContext) { - tempConfig, _ := ini.Load(app.configPath) + tempConfig, _ := ini.ShadowLoad(app.configPath) err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") if err != nil { app.err.Fatalf("Failed to backup config: %v", err) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 40403da..839cc46 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -29,7 +29,7 @@ interface Setting { requires_restart: boolean; advanced?: boolean; type: string; - value: string | boolean | number; + value: string | boolean | number | string[]; depends_true?: string; depends_false?: string; wiki_link?: string; @@ -37,6 +37,11 @@ interface Setting { asElement: () => HTMLElement; update: (s: Setting) => void; + + hide: () => void; + show: () => void; + + valueAsString: () => string; } const splitDependant = (section: string, dep: string): string[] => { @@ -49,17 +54,22 @@ const splitDependant = (section: string, dep: string): string[] => { class DOMInput { protected _input: HTMLInputElement; - private _container: HTMLDivElement; - private _tooltip: HTMLDivElement; - private _required: HTMLSpanElement; - private _restart: HTMLSpanElement; - private _advanced: boolean; + protected _container: HTMLDivElement; + protected _tooltip: HTMLDivElement; + protected _required: HTMLSpanElement; + protected _restart: HTMLSpanElement; + protected _advanced: boolean; + protected _section: string; + protected _name: string; + + hide = () => this._input.parentElement.classList.add("unfocused"); + show = () => this._input.parentElement.classList.remove("unfocused"); private _advancedListener = (event: settingsBoolEvent) => { if (!Boolean(event.detail)) { - this._input.parentElement.classList.add("unfocused"); + this.hide(); } else { - this._input.parentElement.classList.remove("unfocused"); + this.show(); } } @@ -109,10 +119,23 @@ class DOMInput { } } - constructor(inputType: string, setting: Setting, section: string, name: string) { + valueAsString = (): string => { return ""+this.value; }; + + onValueChange = () => { + const event = new CustomEvent(`settings-${this._section}-${this._name}`, { "detail": this.valueAsString() }) + document.dispatchEvent(event); + if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); } + }; + + constructor(inputType: string, setting: Setting, section: string, name: string, customInput?: string) { + this._section = section; + this._name = name; this._container = document.createElement("div"); this._container.classList.add("setting"); this._container.setAttribute("data-name", name) + const defaultInput = ` + + `; this._container.innerHTML = ` `; this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement; this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement; this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; - this._input = this._container.querySelector("input[type=" + inputType + "]") as HTMLInputElement; + this._input = this._container.querySelector(".setting-input") as HTMLInputElement; if (setting.depends_false || setting.depends_true) { let dependant = splitDependant(section, setting.depends_true || setting.depends_false); let state = true; if (setting.depends_false) { state = false; } document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { if (Boolean(event.detail) !== state) { - this._input.parentElement.classList.add("unfocused"); + this.hide(); } else { - this._input.parentElement.classList.remove("unfocused"); + this.show(); } }); } - const onValueChange = () => { - const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value }) - document.dispatchEvent(event); - if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); } - }; - this._input.onchange = onValueChange; + this._input.onchange = this.onValueChange; this.update(setting); } @@ -173,6 +191,85 @@ class DOMText extends DOMInput implements SText { set value(v: string) { this._input.value = v; } } +interface SList extends Setting { + value: string[]; +} + +class DOMList extends DOMInput implements SList { + protected _inputs: HTMLDivElement; + type: string = "list"; + + valueAsString = (): string => { return this.value.join("|"); }; + + get value(): string[] { + let values = []; + const inputs = this._input.querySelectorAll("input") as NodeListOf; + for (let i in inputs) { + if (inputs[i].value) values.push(inputs[i].value); + } + return values; + } + set value(v: string[]) { + this._input.textContent = ``; + for (let val of v) { + let input = this.inputRow(val); + this._input.appendChild(input); + } + const addDummy = () => { + const dummyRow = this.inputRow(); + const input = dummyRow.querySelector("input") as HTMLInputElement; + input.placeholder = window.lang.strings("Add"); + const onDummyChange = () => { + if (!(input.value)) return; + addDummy(); + input.removeEventListener("change", onDummyChange); + input.placeholder = ``; + } + input.addEventListener("change", onDummyChange); + this._input.appendChild(dummyRow); + }; + addDummy(); + } + + private inputRow(v: string = ""): HTMLDivElement { + let container = document.createElement("div") as HTMLDivElement; + container.classList.add("flex", "flex-row", "justify-between"); + container.innerHTML = ` + + + `; + const input = container.querySelector("input") as HTMLInputElement; + input.value = v; + input.onchange = this.onValueChange; + const removeRow = container.querySelector("button") as HTMLButtonElement; + removeRow.onclick = () => { + if (!(container.nextElementSibling)) return; + container.remove(); + this.onValueChange(); + } + return container; + } + + update = (s: Setting) => { + this.name = s.name; + this.description = s.description; + this.required = s.required; + this.requires_restart = s.requires_restart; + this.value = s.value as string[]; + this.advanced = s.advanced; + } + + asElement = (): HTMLDivElement => { return this._container; } + + constructor(setting: Setting, section: string, name: string) { + super("list", setting, section, name, + `
` + ); + } +} + interface SPassword extends Setting { value: string; } @@ -214,12 +311,15 @@ class DOMBool implements SBool { private _restart: HTMLSpanElement; type: string = "bool"; private _advanced: boolean; + + hide = () => this._input.parentElement.classList.add("unfocused"); + show = () => this._input.parentElement.classList.remove("unfocused"); private _advancedListener = (event: settingsBoolEvent) => { if (!Boolean(event.detail)) { - this._input.parentElement.classList.add("unfocused"); + this.hide(); } else { - this._input.parentElement.classList.remove("unfocused"); + this.show(); } } @@ -268,8 +368,12 @@ class DOMBool implements SBool { this._restart.textContent = ""; } } + + valueAsString = (): string => { return ""+this.value; }; + get value(): boolean { return this._input.checked; } set value(state: boolean) { this._input.checked = state; } + constructor(setting: SBool, section: string, name: string) { this._container = document.createElement("div"); this._container.classList.add("setting"); @@ -289,7 +393,7 @@ class DOMBool implements SBool { this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement; const onValueChange = () => { - const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value }) + const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.valueAsString() }) document.dispatchEvent(event); }; this._input.onchange = () => { @@ -304,9 +408,9 @@ class DOMBool implements SBool { if (setting.depends_false) { state = false; } document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { if (Boolean(event.detail) !== state) { - this._input.parentElement.classList.add("unfocused"); + this.hide(); } else { - this._input.parentElement.classList.remove("unfocused"); + this.show(); } }); } @@ -338,11 +442,14 @@ class DOMSelect implements SSelect { type: string = "bool"; private _advanced: boolean; + hide = () => this._container.classList.add("unfocused"); + show = () => this._container.classList.remove("unfocused"); + private _advancedListener = (event: settingsBoolEvent) => { if (!Boolean(event.detail)) { - this._container.classList.add("unfocused"); + this.hide(); } else { - this._container.classList.remove("unfocused"); + this.show(); } } @@ -391,6 +498,9 @@ class DOMSelect implements SSelect { this._restart.textContent = ""; } } + + valueAsString = (): string => { return ""+this.value; }; + get value(): string { return this._select.value; } set value(v: string) { this._select.value = v; } @@ -431,14 +541,14 @@ class DOMSelect implements SSelect { if (setting.depends_false) { state = false; } document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { if (Boolean(event.detail) !== state) { - this._container.classList.add("unfocused"); + this.hide(); } else { - this._container.classList.remove("unfocused"); + this.show(); } }); } const onValueChange = () => { - const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value }) + const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.valueAsString() }) document.dispatchEvent(event); if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); } }; @@ -478,6 +588,9 @@ class DOMNote implements SNote { type: string = "note"; private _style: string; + hide = () => this._container.classList.add("unfocused"); + show = () => this._container.classList.remove("unfocused"); + get name(): string { return this._name.textContent; } set name(n: string) { this._name.textContent = n; } @@ -486,6 +599,8 @@ class DOMNote implements SNote { this._description.innerHTML = d; } + valueAsString = (): string => { return ""; }; + get value(): string { return ""; } set value(v: string) { return; } @@ -521,9 +636,9 @@ class DOMNote implements SNote { if (setting.depends_false) { state = false; } document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { if (Boolean(event.detail) !== state) { - this._container.classList.add("unfocused"); + this.hide(); } else { - this._container.classList.remove("unfocused"); + this.show(); } }); } @@ -603,12 +718,15 @@ class sectionPanel { case "note": setting = new DOMNote(setting as SNote, this._sectionName); break; + case "list": + setting = new DOMList(setting as SList, this._sectionName, name); + break; } if (setting.type != "note") { this.values[name] = ""+setting.value; document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => { // const oldValue = this.values[name]; - this.values[name] = ""+event.detail; + this.values[name] = event.detail; document.dispatchEvent(new CustomEvent("settings-section-changed")); }); }