2025-09-06 10:26:50 +10:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-01-25 11:00:40 +11:00
|
|
|
"fmt"
|
2025-09-06 11:26:54 +10:00
|
|
|
"log"
|
2026-01-25 11:00:40 +11:00
|
|
|
"sort"
|
|
|
|
|
"strings"
|
2026-09-07 22:57:44 +00:00
|
|
|
"sync"
|
2025-09-06 11:26:54 +10:00
|
|
|
|
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
|
"lmika.dev/pkg/modash/moslice"
|
2026-01-25 11:00:40 +11:00
|
|
|
"ucl.lmika.dev/ucl"
|
|
|
|
|
"ucl.lmika.dev/ucl/builtins"
|
2025-09-06 10:26:50 +10:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// App struct
|
|
|
|
|
type App struct {
|
2026-09-07 22:57:44 +00:00
|
|
|
store *Store
|
|
|
|
|
|
|
|
|
|
// mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded.
|
|
|
|
|
mu sync.RWMutex
|
2026-01-25 11:00:40 +11:00
|
|
|
uclInst *ucl.Inst
|
2026-09-07 01:09:21 +00:00
|
|
|
plugins *PluginFilters
|
2026-09-07 22:57:44 +00:00
|
|
|
ctx context.Context
|
2026-01-25 11:00:40 +11:00
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
// baseCtx is the raw Wails context supplied to startup. ctx is derived from it.
|
|
|
|
|
baseCtx context.Context
|
2025-09-06 10:26:50 +10:00
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
// newUCLInst creates a UCL interpreter with the standard modules plus the plugin module.
|
|
|
|
|
func newUCLInst(plugins *PluginFilters) *ucl.Inst {
|
|
|
|
|
return ucl.New(
|
2026-09-07 01:09:21 +00:00
|
|
|
ucl.WithModule(plugins.Module()),
|
2026-09-08 22:03:41 +10:00
|
|
|
ucl.WithModule(builtins.Bytes()),
|
2026-01-25 11:00:40 +11:00
|
|
|
ucl.WithModule(builtins.CSV(nil)),
|
|
|
|
|
ucl.WithModule(builtins.Fns()),
|
|
|
|
|
ucl.WithModule(builtins.FS(nil)),
|
|
|
|
|
ucl.WithModule(builtins.Itrs()),
|
|
|
|
|
ucl.WithModule(builtins.Lists()),
|
|
|
|
|
ucl.WithModule(builtins.Log(nil)),
|
|
|
|
|
ucl.WithModule(builtins.OS()),
|
|
|
|
|
ucl.WithModule(builtins.Strs()),
|
|
|
|
|
ucl.WithModule(builtins.Time()),
|
2026-09-08 22:03:41 +10:00
|
|
|
ucl.WithModule(builtins.URLs()),
|
2026-01-25 11:00:40 +11:00
|
|
|
)
|
2026-09-07 22:57:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewApp creates a new App application struct
|
|
|
|
|
func NewApp(store *Store) *App {
|
|
|
|
|
app := &App{
|
2026-01-26 09:31:14 +11:00
|
|
|
store: store,
|
2026-09-07 22:57:44 +00:00
|
|
|
baseCtx: context.Background(),
|
|
|
|
|
}
|
|
|
|
|
app.setUCLInst(newUCLInst(newPluginFilters()), nil)
|
|
|
|
|
return app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// setUCLInst installs a UCL interpreter and its plugin registry, rebuilding the app context so that
|
|
|
|
|
// filters which fetch the interpreter from the context see the new instance. A nil plugins argument
|
|
|
|
|
// is replaced with an empty registry.
|
|
|
|
|
func (a *App) setUCLInst(inst *ucl.Inst, plugins *PluginFilters) {
|
|
|
|
|
if plugins == nil {
|
|
|
|
|
plugins = newPluginFilters()
|
2026-01-25 11:00:40 +11:00
|
|
|
}
|
2026-09-07 22:57:44 +00:00
|
|
|
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
defer a.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
a.uclInst = inst
|
|
|
|
|
a.plugins = plugins
|
|
|
|
|
a.ctx = context.WithValue(a.baseCtx, uclInstKey, inst)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// current returns the interpreter, plugin registry and context in use.
|
|
|
|
|
func (a *App) current() (*ucl.Inst, *PluginFilters, context.Context) {
|
|
|
|
|
a.mu.RLock()
|
|
|
|
|
defer a.mu.RUnlock()
|
|
|
|
|
return a.uclInst, a.plugins, a.ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadPluginsFrom builds a fresh interpreter and plugin registry, loads the plugin files found under
|
|
|
|
|
// dir into them, and installs them as the current instance. Returns the number of plugin filters
|
|
|
|
|
// registered. Any load errors are available from the new registry's Errors method.
|
|
|
|
|
func (a *App) loadPluginsFrom(dir string) int {
|
|
|
|
|
plugins := newPluginFilters()
|
|
|
|
|
inst := newUCLInst(plugins)
|
|
|
|
|
|
|
|
|
|
plugins.LoadDir(context.WithValue(a.baseCtx, uclInstKey, inst), inst, dir)
|
|
|
|
|
a.setUCLInst(inst, plugins)
|
|
|
|
|
return len(plugins.filters)
|
2025-09-06 10:26:50 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// startup is called when the app starts. The context is saved
|
|
|
|
|
// so we can call the runtime methods
|
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
2026-09-07 22:57:44 +00:00
|
|
|
a.baseCtx = ctx
|
2026-09-07 01:09:21 +00:00
|
|
|
|
|
|
|
|
dir, err := pluginDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("cannot determine plugin directory: %v", err)
|
2026-09-07 22:57:44 +00:00
|
|
|
a.setUCLInst(newUCLInst(newPluginFilters()), nil)
|
2026-09-07 01:09:21 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-07 22:57:44 +00:00
|
|
|
a.loadPluginsFrom(dir)
|
2026-09-07 01:09:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar.
|
|
|
|
|
func (a *App) domReady(ctx context.Context) {
|
2026-09-07 22:57:44 +00:00
|
|
|
_, plugins, _ := a.current()
|
|
|
|
|
a.reportPluginStatus(len(plugins.filters), false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReloadPlugins discards the current plugin filters and UCL interpreter, rescans the plugin
|
|
|
|
|
// directory, and refreshes the command palette. Invoked from the File menu.
|
|
|
|
|
func (a *App) ReloadPlugins() {
|
|
|
|
|
dir, err := pluginDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
_, _, ctx := a.current()
|
|
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
|
|
|
|
Message: fmt.Sprintf("Cannot determine plugin directory: %v", err),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
2026-09-07 01:09:21 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
count := a.loadPluginsFrom(dir)
|
|
|
|
|
|
|
|
|
|
_, _, ctx := a.current()
|
|
|
|
|
runtime.EventsEmit(ctx, "processors-changed")
|
|
|
|
|
a.reportPluginStatus(count, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// reportPluginStatus shows plugin load errors in the status bar. If there are no errors and
|
|
|
|
|
// reloaded is true, a confirmation message is shown instead.
|
|
|
|
|
func (a *App) reportPluginStatus(filterCount int, reloaded bool) {
|
|
|
|
|
_, plugins, ctx := a.current()
|
|
|
|
|
|
|
|
|
|
if errs := plugins.Errors(); len(errs) > 0 {
|
|
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
|
|
|
|
Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if reloaded {
|
|
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
|
|
|
|
Message: fmt.Sprintf("Plugins reloaded: %d filter(s)", filterCount),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-09-07 01:09:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lookupProcessor finds a text processor by action key, preferring plugin-defined filters.
|
|
|
|
|
func (a *App) lookupProcessor(name string) (TextProcessor, bool) {
|
2026-09-07 22:57:44 +00:00
|
|
|
_, plugins, _ := a.current()
|
|
|
|
|
|
|
|
|
|
if proc, ok := plugins.filters[name]; ok {
|
2026-09-07 01:09:21 +00:00
|
|
|
return proc, true
|
|
|
|
|
}
|
|
|
|
|
proc, ok := TextFilters[name]
|
|
|
|
|
return proc, ok
|
2026-01-25 11:00:40 +11:00
|
|
|
}
|
|
|
|
|
|
2026-01-26 09:31:14 +11:00
|
|
|
func (a *App) LoadCurrentBuffer() (string, error) {
|
|
|
|
|
return a.store.LoadBuffer()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) SaveCurrentBuffer(buffer string) error {
|
|
|
|
|
return a.store.SaveBuffer(buffer)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 11:00:40 +11:00
|
|
|
func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
|
2026-09-07 22:57:44 +00:00
|
|
|
_, plugins, _ := a.current()
|
|
|
|
|
|
|
|
|
|
pluginLabels := make(map[string]struct{}, len(plugins.filters))
|
|
|
|
|
for k, v := range plugins.filters {
|
2026-09-07 01:09:21 +00:00
|
|
|
pluginLabels[v.Label] = struct{}{}
|
|
|
|
|
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label})
|
|
|
|
|
}
|
2026-01-25 11:00:40 +11:00
|
|
|
for k, v := range TextFilters {
|
2026-09-07 01:09:21 +00:00
|
|
|
if _, shadowed := pluginLabels[v.Label]; shadowed {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-01-25 11:00:40 +11:00
|
|
|
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label})
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label })
|
|
|
|
|
return resp
|
2025-09-06 10:26:50 +10:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:52:13 +11:00
|
|
|
func (a *App) TriggerTextProcess(action string) {
|
2026-09-07 22:57:44 +00:00
|
|
|
_, _, ctx := a.current()
|
|
|
|
|
runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{
|
2026-03-14 21:52:13 +11:00
|
|
|
Action: action,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) PromptUser(label string, resp func(string)) {
|
2026-09-07 22:57:44 +00:00
|
|
|
_, _, ctx := a.current()
|
|
|
|
|
runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) {
|
2026-03-14 21:52:13 +11:00
|
|
|
if len(data) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ans, ok := data[0].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp(ans)
|
|
|
|
|
})
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "prompt-request", PromptRequest{
|
2026-03-14 21:52:13 +11:00
|
|
|
Label: label,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-06 11:26:54 +10:00
|
|
|
func (a *App) ProcessText(req ProcessTextRequest) {
|
2026-09-07 22:57:44 +00:00
|
|
|
// Capture the context once so the whole operation runs against a single interpreter, even if
|
|
|
|
|
// plugins are reloaded part way through.
|
|
|
|
|
_, _, ctx := a.current()
|
|
|
|
|
|
2026-09-07 01:09:21 +00:00
|
|
|
filter, ok := a.lookupProcessor(req.Action)
|
2025-09-06 11:26:54 +10:00
|
|
|
if !ok {
|
|
|
|
|
log.Printf("Unknown filter: [%s]", req.Action)
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
|
|
|
|
Message: fmt.Sprintf("Unknown filter: %s", req.Action),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
2025-09-06 11:26:54 +10:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 22:47:56 +11:00
|
|
|
applyFilter := func(filter TextFilter) {
|
|
|
|
|
resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) {
|
2026-09-07 22:57:44 +00:00
|
|
|
outRes, err := filter(ctx, span.Text)
|
2026-03-14 22:47:56 +11:00
|
|
|
if err != nil {
|
|
|
|
|
return TextSpan{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return TextSpan{
|
|
|
|
|
Text: outRes.Output,
|
|
|
|
|
Pos: span.Pos,
|
|
|
|
|
Len: span.Len,
|
|
|
|
|
Append: outRes.Append,
|
|
|
|
|
}, nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
2026-03-14 22:47:56 +11:00
|
|
|
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{
|
2026-03-14 22:47:56 +11:00
|
|
|
Output: resp,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 11:00:40 +11:00
|
|
|
switch {
|
|
|
|
|
case filter.Analyze != nil:
|
|
|
|
|
inBfr := strings.Builder{}
|
|
|
|
|
for _, span := range req.Input {
|
|
|
|
|
if inBfr.Len() > 0 {
|
|
|
|
|
inBfr.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
inBfr.WriteString(span.Text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
msg, err := filter.Analyze(ctx, inBfr.String())
|
2025-09-06 11:26:54 +10:00
|
|
|
if err != nil {
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
2026-01-25 11:00:40 +11:00
|
|
|
Message: fmt.Sprintf("Error running analysis: %v", err.Error()),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
|
|
|
|
return
|
2025-09-06 11:26:54 +10:00
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
2026-01-25 11:00:40 +11:00
|
|
|
Message: msg,
|
|
|
|
|
})
|
|
|
|
|
case filter.Filter != nil:
|
2026-03-14 22:47:56 +11:00
|
|
|
applyFilter(filter.Filter)
|
|
|
|
|
case filter.FilterWithArg != nil:
|
|
|
|
|
a.PromptUser(filter.FilterWithArg.Label, func(ans string) {
|
2026-09-07 22:57:44 +00:00
|
|
|
filter, err := filter.FilterWithArg.OnConfirm(ctx, ans)
|
2026-01-25 11:00:40 +11:00
|
|
|
if err != nil {
|
2026-09-07 22:57:44 +00:00
|
|
|
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
|
2026-03-14 22:47:56 +11:00
|
|
|
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
|
|
|
|
|
Error: true,
|
|
|
|
|
})
|
|
|
|
|
return
|
2026-01-25 11:00:40 +11:00
|
|
|
}
|
2026-03-14 22:47:56 +11:00
|
|
|
applyFilter(filter)
|
2026-01-25 11:00:40 +11:00
|
|
|
})
|
|
|
|
|
}
|
2025-09-06 10:26:50 +10:00
|
|
|
}
|