Files

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())
}