init
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/classification"
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/journal"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Model string `json:"model"`
|
||||
IncludeAmount bool `json:"include_amount"`
|
||||
}
|
||||
type Status struct {
|
||||
SyncError string `json:"sync_error"`
|
||||
IndexError string `json:"index_error"`
|
||||
LastSync string `json:"last_sync"`
|
||||
BankingConfigured bool `json:"banking_configured"`
|
||||
AIConfigured bool `json:"ai_configured"`
|
||||
}
|
||||
type State struct {
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
Connections []Connection `json:"connections"`
|
||||
}
|
||||
type operational struct {
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
}
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
syncRequested chan struct{}
|
||||
}
|
||||
|
||||
func Open(dir string) (*App, error) {
|
||||
j, err := journal.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &App{dir: dir, journal: j, previews: make(map[string]Preview), authStates: make(map[string]authorization), syncRequested: make(chan struct{}, 1)}
|
||||
fail := func(e error) (*App, error) { j.Close(); return nil, e }
|
||||
if err = os.MkdirAll(filepath.Join(dir, "state"), 0700); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Join(dir, "cache"), 0700); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if b, e := os.ReadFile(filepath.Join(dir, "config.toml")); e == nil {
|
||||
for n, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("config.toml:%d: expected key = value", n+1))
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "classification_model":
|
||||
a.settings.Model, err = strconv.Unquote(v)
|
||||
case "include_amount":
|
||||
a.settings.IncludeAmount, err = strconv.ParseBool(v)
|
||||
default:
|
||||
err = fmt.Errorf("unknown setting %q", k)
|
||||
}
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("config.toml:%d: %w", n+1, err))
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(e) {
|
||||
return fail(e)
|
||||
}
|
||||
if b, e := os.ReadFile(filepath.Join(dir, "state", "sync-state.json")); e == nil {
|
||||
if err = json.Unmarshal(b, &a.ops); err != nil {
|
||||
return fail(fmt.Errorf("sync state: %w", err))
|
||||
}
|
||||
} else if !os.IsNotExist(e) {
|
||||
return fail(e)
|
||||
}
|
||||
if a.ops.Consents == nil {
|
||||
a.ops.Consents = make(map[string]Consent)
|
||||
}
|
||||
if a.ops.AccountSync == nil {
|
||||
a.ops.AccountSync = make(map[string]string)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||
a.callbackURL = redirect
|
||||
if appID != "" || key != "" || redirect != "" {
|
||||
if appID == "" || key == "" || redirect == "" {
|
||||
return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables"))
|
||||
}
|
||||
a.bank, err = banking.NewEnableBanking(appID, key, redirect)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err = a.snapshot(context.Background()); err != nil {
|
||||
a.index.Close()
|
||||
return fail(err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (a *App) Close() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return errors.Join(a.index.Close(), a.journal.Close())
|
||||
}
|
||||
func (a *App) snapshot(ctx context.Context) (State, error) {
|
||||
d, rev, err := a.journal.Load()
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if rev != a.indexed {
|
||||
if err = a.index.Rebuild(ctx, d); err != nil {
|
||||
a.indexError = err.Error()
|
||||
} else {
|
||||
a.indexed = rev
|
||||
a.indexError = ""
|
||||
}
|
||||
}
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
}
|
||||
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func (a *App) commit(ctx context.Context, rev string, d domain.Dataset) (State, error) {
|
||||
if rev == "" {
|
||||
return State{}, errors.New("revision is required")
|
||||
}
|
||||
if _, err := a.journal.Commit(rev, d); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func (a *App) Mutate(ctx context.Context, rev string, fn func(*domain.Dataset) error) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s, err := a.snapshot(ctx)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if rev != s.Revision {
|
||||
return State{}, errors.New("revision conflict: reload before editing")
|
||||
}
|
||||
if err = fn(&s.Data); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return a.commit(ctx, rev, s.Data)
|
||||
}
|
||||
func (a *App) Dashboard(ctx context.Context, f analytics.Filter) (analytics.Dashboard, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if _, err := a.snapshot(ctx); err != nil {
|
||||
return analytics.Dashboard{}, err
|
||||
}
|
||||
if a.indexError != "" {
|
||||
return analytics.Dashboard{}, errors.New(a.indexError)
|
||||
}
|
||||
return a.index.Query(ctx, f)
|
||||
}
|
||||
func (a *App) Rebuild(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.indexed = ""
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func atomicFile(path string, b []byte) error {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".state-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err = f.Chmod(0600); err == nil {
|
||||
_, err = f.Write(b)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
func (a *App) saveOps() error {
|
||||
b, err := json.MarshalIndent(a.ops, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
||||
}
|
||||
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s.Model = strings.TrimSpace(s.Model)
|
||||
if len(s.Model) > 200 {
|
||||
return State{}, errors.New("model name is too long")
|
||||
}
|
||||
b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
a.settings = s
|
||||
a.classifier.Model = s.Model
|
||||
a.classifier.IncludeAmount = s.IncludeAmount
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user