// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package config import ( "bytes" "errors" "fmt" "io" "os" "reflect" "slices" "strings" "syscall" "github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2/unstable" ) // Document retains file identity and table order for an isolated edit. Version // is metadata, not a UI tab. Omitted tables follow in schema order. type Document struct { Settings Settings Order []string path string original []byte existed bool applicationBase *Settings applicationFields map[string]bool } func read_document(path string) ([]byte, error) { // Remote SAVEAS can name an arbitrary destination. A FIFO must not // block opening before we can establish that it is a regular file. file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, err } defer file.Close() info, err := file.Stat() if err != nil { return nil, err } if !info.Mode().IsRegular() { return nil, fmt.Errorf("settings destination is not a regular file") } data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) if err == nil && len(data) > 1<<20 { err = fmt.Errorf("settings file exceeds 1 MiB") } return data, err } func Open_document(path string, fallback Settings) (*Document, error) { doc := &Document{Settings: fallback, path: path} data, err := read_document(path) if !errors.Is(err, os.ErrNotExist) { if err != nil { return nil, err } doc.Settings, err = Decode(data) if err != nil { return nil, err } doc.original, doc.existed = data, true var parser unstable.Parser parser.Reset(data) seen := make(map[string]bool) sectionIDs := make(map[string]bool) var collect func([]Section) collect = func(sections []Section) { for _, section := range sections { sectionIDs[section.ID] = true collect(section.Sections) } } collect(Sections()) record := func(parts []string) { // Map old table order to the migrated hierarchy, preserving sibling order. if len(parts) > 0 && (parts[0] == "speech" || parts[0] == "sound") { parts = append([]string{"presentation"}, parts...) } for count := 1; count <= len(parts); count++ { name := strings.Join(parts[:count], ".") if sectionIDs[name] && !seen[name] { doc.Order = append(doc.Order, name) seen[name] = true } } } var visit func(*unstable.Node, []string) visit = func(node *unstable.Node, prefix []string) { parts := append([]string(nil), prefix...) keys := node.Key() for keys.Next() { parts = append(parts, string(keys.Node().Data)) } record(parts) if node.Kind == unstable.KeyValue && node.Value().Kind == unstable.InlineTable { children := node.Value().Children() for children.Next() { visit(children.Node(), parts) } } } var tablePath []string for parser.NextExpression() { node := parser.Expression() switch node.Kind { case unstable.Table: tablePath = nil keys := node.Key() for keys.Next() { tablePath = append(tablePath, string(keys.Node().Data)) } record(tablePath) case unstable.KeyValue: visit(node, tablePath) } } if err := parser.Error(); err != nil { return nil, err } } return doc, nil } // Save checks for external edits made since the menu opened before writing. // Atomic replacement uses the same system/private permissions policy as Save. func (d *Document) Save(settings Settings) error { data, err := read_document(d.path) if d.existed { if err != nil { return err } if !bytes.Equal(data, d.original) { return fmt.Errorf("settings changed since the menu opened") } } else if err == nil { return fmt.Errorf("settings created since the menu opened") } else if !errors.Is(err, os.ErrNotExist) { return err } if err := settings.Validate(); err != nil { return err } if d.applicationBase != nil { return d.save_application(settings) } // The same schema drives persistence and the form, including nested groups. var output bytes.Buffer fmt.Fprintf(&output, "version = %d\n", settings.Version) var encode func([]Section) error encode = func(sections []Section) error { for _, section := range Ordered_sections(sections, d.Order) { value := setting_value(reflect.ValueOf(settings), section.ID) fields := make(map[string]any) for index := range value.NumField() { if value.Field(index).Kind() != reflect.Struct { tag := strings.Split(value.Type().Field(index).Tag.Get("toml"), ",") if slices.Contains(tag[1:], "omitempty") && value.Field(index).IsZero() { continue } fields[tag[0]] = value.Field(index).Interface() } } if len(fields) > 0 { encoded, err := toml.Marshal(fields) if err != nil { return err } fmt.Fprintf(&output, "\n[%s]\n", section.ID) output.Write(encoded) } if err := encode(section.Sections); err != nil { return err } } return nil } if err := encode(Sections()); err != nil { return err } return save_data(d.path, output.Bytes()) }