201 lines
5.7 KiB
Go
201 lines
5.7 KiB
Go
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
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())
|
|
}
|