1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-09-19 19:00:11 +00:00

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.
This commit is contained in:
Harvey Tindall 2024-08-20 20:19:32 +01:00
parent b2771e6cc5
commit 6bad293f74
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
6 changed files with 169 additions and 40 deletions

View File

@ -585,7 +585,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
matrix := tempConfig.Section("matrix") matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true") matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver) matrix.Key("homeserver").SetValue(req.Homeserver)

15
api.go
View File

@ -290,6 +290,8 @@ func (app *appContext) GetConfig(gc *gin.Context) {
val := app.config.Section(sectName).Key(settingName) val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName] s := resp.Sections[sectName].Settings[settingName]
switch setting.Type { switch setting.Type {
case "list":
s.Value = val.StringsWithShadows("|")
case "text", "email", "select", "password", "note": case "text", "email", "select", "password", "note":
s.Value = val.MustString("") s.Value = val.MustString("")
case "number": case "number":
@ -327,7 +329,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
// @Summary Modify app config. // @Summary Modify app config.
// @Produce json // @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 // @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /config [post] // @Router /config [post]
@ -337,7 +339,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
var req configDTO var req configDTO
gc.BindJSON(&req) gc.BindJSON(&req)
// Load a new config, as we set various default values in app.config that shouldn't be stored. // 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 { for section, settings := range req {
if section != "restart-program" { if section != "restart-program" {
_, err := tempConfig.GetSection(section) _, err := tempConfig.GetSection(section)
@ -350,6 +352,15 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
} }
if (section == "discord" || section == "matrix") && setting == "language" { if (section == "discord" || section == "matrix") && setting == "language" {
tempConfig.Section("telegram").Key("language").SetValue(value.(string)) 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("") { } else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string)) tempConfig.Section(section).Key(setting).SetValue(value.(string))
} }

View File

@ -36,7 +36,7 @@ func (app *appContext) MustSetValue(section, key, val string) {
func (app *appContext) loadConfig() error { func (app *appContext) loadConfig() error {
var err error var err error
app.config, err = ini.Load(app.configPath) app.config, err = ini.ShadowLoad(app.configPath)
if err != nil { if err != nil {
return err return err
} }

View File

@ -232,7 +232,7 @@ func start(asDaemon, firstCall bool) {
app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err) app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err)
} }
app.info.Printf(lm.CopyConfig, app.configPath) 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.Section("").Key("first_run").SetValue("true")
tempConfig.SaveTo(app.configPath) tempConfig.SaveTo(app.configPath)
} }

View File

@ -60,7 +60,7 @@ func migrateBootstrap(app *appContext) {
} }
func migrateEmailConfig(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.")) 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") err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak")
if err != nil { 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) 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 { if err != nil {
return err 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. // Migrate poorly-named and duplicate "url_base" settings to the single "external jfa-go URL" setting.
func migrateExternalURL(app *appContext) { func migrateExternalURL(app *appContext) {
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak")
if err != nil { if err != nil {
app.err.Fatalf("Failed to backup config: %v", err) app.err.Fatalf("Failed to backup config: %v", err)

View File

@ -29,7 +29,7 @@ interface Setting {
requires_restart: boolean; requires_restart: boolean;
advanced?: boolean; advanced?: boolean;
type: string; type: string;
value: string | boolean | number; value: string | boolean | number | string[];
depends_true?: string; depends_true?: string;
depends_false?: string; depends_false?: string;
wiki_link?: string; wiki_link?: string;
@ -37,6 +37,11 @@ interface Setting {
asElement: () => HTMLElement; asElement: () => HTMLElement;
update: (s: Setting) => void; update: (s: Setting) => void;
hide: () => void;
show: () => void;
valueAsString: () => string;
} }
const splitDependant = (section: string, dep: string): string[] => { const splitDependant = (section: string, dep: string): string[] => {
@ -49,17 +54,22 @@ const splitDependant = (section: string, dep: string): string[] => {
class DOMInput { class DOMInput {
protected _input: HTMLInputElement; protected _input: HTMLInputElement;
private _container: HTMLDivElement; protected _container: HTMLDivElement;
private _tooltip: HTMLDivElement; protected _tooltip: HTMLDivElement;
private _required: HTMLSpanElement; protected _required: HTMLSpanElement;
private _restart: HTMLSpanElement; protected _restart: HTMLSpanElement;
private _advanced: boolean; 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) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } 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 = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
this._container.setAttribute("data-name", name) this._container.setAttribute("data-name", name)
const defaultInput = `
<input type="${inputType}" class="input setting-input ~neutral @low mt-2 mb-2">
`;
this._container.innerHTML = ` this._container.innerHTML = `
<label class="label"> <label class="label">
<span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span> <span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span>
@ -120,31 +143,26 @@ class DOMInput {
<i class="icon ri-information-line"></i> <i class="icon ri-information-line"></i>
<span class="content sm"></span> <span class="content sm"></span>
</div> </div>
<input type="${inputType}" class="input ~neutral @low mt-2 mb-2"> ${customInput ? customInput : defaultInput}
</label> </label>
`; `;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement; this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement; this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") 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) { if (setting.depends_false || setting.depends_true) {
let dependant = splitDependant(section, setting.depends_true || setting.depends_false); let dependant = splitDependant(section, setting.depends_true || setting.depends_false);
let state = true; let state = true;
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
}); });
} }
const onValueChange = () => { this._input.onchange = this.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.update(setting); this.update(setting);
} }
@ -173,6 +191,85 @@ class DOMText extends DOMInput implements SText {
set value(v: string) { this._input.value = v; } 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<HTMLInputElement>;
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 = `
<input type="text" class="input ~neutral @low">
<button class="button ~neutral @low center -ml-10 rounded-s-none aria-label="${window.lang.strings("delete")}" title="${window.lang.strings("delete")}">
<i class="ri-close-line"></i>
</button>
`;
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,
`<div class="setting-input flex flex-col gap-2 mt-2 mb-2"></div>`
);
}
}
interface SPassword extends Setting { interface SPassword extends Setting {
value: string; value: string;
} }
@ -214,12 +311,15 @@ class DOMBool implements SBool {
private _restart: HTMLSpanElement; private _restart: HTMLSpanElement;
type: string = "bool"; type: string = "bool";
private _advanced: boolean; private _advanced: boolean;
hide = () => this._input.parentElement.classList.add("unfocused");
show = () => this._input.parentElement.classList.remove("unfocused");
private _advancedListener = (event: settingsBoolEvent) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
} }
@ -268,8 +368,12 @@ class DOMBool implements SBool {
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
valueAsString = (): string => { return ""+this.value; };
get value(): boolean { return this._input.checked; } get value(): boolean { return this._input.checked; }
set value(state: boolean) { this._input.checked = state; } set value(state: boolean) { this._input.checked = state; }
constructor(setting: SBool, section: string, name: string) { constructor(setting: SBool, section: string, name: string) {
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
@ -289,7 +393,7 @@ class DOMBool implements SBool {
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement; this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement;
const onValueChange = () => { 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); document.dispatchEvent(event);
}; };
this._input.onchange = () => { this._input.onchange = () => {
@ -304,9 +408,9 @@ class DOMBool implements SBool {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
}); });
} }
@ -338,11 +442,14 @@ class DOMSelect implements SSelect {
type: string = "bool"; type: string = "bool";
private _advanced: boolean; private _advanced: boolean;
hide = () => this._container.classList.add("unfocused");
show = () => this._container.classList.remove("unfocused");
private _advancedListener = (event: settingsBoolEvent) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
} }
@ -391,6 +498,9 @@ class DOMSelect implements SSelect {
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
valueAsString = (): string => { return ""+this.value; };
get value(): string { return this._select.value; } get value(): string { return this._select.value; }
set value(v: string) { this._select.value = v; } set value(v: string) { this._select.value = v; }
@ -431,14 +541,14 @@ class DOMSelect implements SSelect {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
}); });
} }
const onValueChange = () => { 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); document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); } if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
}; };
@ -478,6 +588,9 @@ class DOMNote implements SNote {
type: string = "note"; type: string = "note";
private _style: string; private _style: string;
hide = () => this._container.classList.add("unfocused");
show = () => this._container.classList.remove("unfocused");
get name(): string { return this._name.textContent; } get name(): string { return this._name.textContent; }
set name(n: string) { this._name.textContent = n; } set name(n: string) { this._name.textContent = n; }
@ -486,6 +599,8 @@ class DOMNote implements SNote {
this._description.innerHTML = d; this._description.innerHTML = d;
} }
valueAsString = (): string => { return ""; };
get value(): string { return ""; } get value(): string { return ""; }
set value(v: string) { return; } set value(v: string) { return; }
@ -521,9 +636,9 @@ class DOMNote implements SNote {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
}); });
} }
@ -603,12 +718,15 @@ class sectionPanel {
case "note": case "note":
setting = new DOMNote(setting as SNote, this._sectionName); setting = new DOMNote(setting as SNote, this._sectionName);
break; break;
case "list":
setting = new DOMList(setting as SList, this._sectionName, name);
break;
} }
if (setting.type != "note") { if (setting.type != "note") {
this.values[name] = ""+setting.value; this.values[name] = ""+setting.value;
document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => { document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => {
// const oldValue = this.values[name]; // const oldValue = this.values[name];
this.values[name] = ""+event.detail; this.values[name] = event.detail;
document.dispatchEvent(new CustomEvent("settings-section-changed")); document.dispatchEvent(new CustomEvent("settings-section-changed"));
}); });
} }