From 7c9023ae20c8169eed9a5a6fe7e0c1e6c83573ce Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 6 May 2026 04:13:44 +0000 Subject: [PATCH 01/12] Reuse last prompt value when rerunning last command Co-authored-by: Shelley --- .../src/controllers/commands_controller.js | 8 ++++++++ frontend/src/controllers/prompt_controller.js | 20 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/src/controllers/commands_controller.js b/frontend/src/controllers/commands_controller.js index 5d74380..6bdc0b6 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -47,6 +47,7 @@ export class CommandsController extends Controller { runCommand(ev) { ev.preventDefault(); + this._promptController()?.clearUseLastValue(); textProcessor.runTextCommand(this.commandSelectTarget.value); this._lastCommand = this.commandSelectTarget.value; @@ -60,9 +61,16 @@ export class CommandsController extends Controller { return; } + this._promptController()?.useLastValueForNextPrompt(); textProcessor.runTextCommand(this._lastCommand); } + _promptController() { + const el = document.getElementById("prompt-dialog"); + if (!el) return null; + return this.application.getControllerForElementAndIdentifier(el, "prompt"); + } + _filterOptions(filterText) { let inputText = filterText.toLowerCase(); let inputTerms = filterText.toLowerCase().split(/\s+/).map(s => s.trim()).filter(s => s !== ""); diff --git a/frontend/src/controllers/prompt_controller.js b/frontend/src/controllers/prompt_controller.js index c8ff4e3..fe10ee0 100644 --- a/frontend/src/controllers/prompt_controller.js +++ b/frontend/src/controllers/prompt_controller.js @@ -8,8 +8,16 @@ export class PromptController extends Controller { connect() { this._callback = null; + this._lastValue = null; + this._useLastNext = false; window.runtime.EventsOn("prompt-request", (data) => { + if (this._useLastNext && this._lastValue !== null) { + this._useLastNext = false; + window.runtime.EventsEmit("prompt-response", this._lastValue); + return; + } + this._useLastNext = false; this.prompt(data.label, (res) => { window.runtime.EventsEmit("prompt-response", res); }); @@ -19,14 +27,16 @@ export class PromptController extends Controller { prompt(label, callback) { this._callback = callback; this.labelTarget.textContent = label; - this.inputTarget.value = ""; + this.inputTarget.value = this._lastValue || ""; this.element.showModal(); + this.inputTarget.select(); this.inputTarget.focus(); } submit(ev) { ev.preventDefault(); let value = this.inputTarget.value; + this._lastValue = value; this.element.close(); if (this._callback) { this._callback(value); @@ -39,4 +49,12 @@ export class PromptController extends Controller { this._callback = null; this.element.close(); } + + useLastValueForNextPrompt() { + this._useLastNext = true; + } + + clearUseLastValue() { + this._useLastNext = false; + } } From 5f221939e3af74ad43306da80572338be93d9b7a Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 9 May 2026 12:22:25 +0000 Subject: [PATCH 02/12] Add Cmd+L to rerun last command on current line(s) Co-authored-by: Shelley --- frontend/index.html | 2 +- frontend/src/cmplugins.js | 8 +++++++ .../src/controllers/commands_controller.js | 13 +++++++++++- frontend/src/services.js | 21 ++++++++++++++++--- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 43a2da8..708247a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,7 +20,7 @@ + data-action="dq-showcommands@window->commands#showCommands dq-rerunlastcommand@window->commands#rerunLastCommand dq-rerunlastcommand-line@window->commands#rerunLastCommandOnLine">
{ + let event = new CustomEvent('dq-rerunlastcommand-line'); + window.dispatchEvent(event); + + return true; + } +}, { key: "Cmd-k", run: (view) => { const {state} = view; diff --git a/frontend/src/controllers/commands_controller.js b/frontend/src/controllers/commands_controller.js index 6bdc0b6..ec69f24 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -10,6 +10,10 @@ export class CommandsController extends Controller { "commandSelect", ]; + initialize() { + this._lineModeOnce = false; + } + async connect() { this._lastCommand = null; @@ -62,7 +66,14 @@ export class CommandsController extends Controller { } this._promptController()?.useLastValueForNextPrompt(); - textProcessor.runTextCommand(this._lastCommand); + const lineMode = this._lineModeOnce; + this._lineModeOnce = false; + textProcessor.runTextCommand(this._lastCommand, { lineMode }); + } + + rerunLastCommandOnLine(ev) { + this._lineModeOnce = true; + this.rerunLastCommand(ev); } _promptController() { diff --git a/frontend/src/services.js b/frontend/src/services.js index 88da2e0..2d65b6d 100644 --- a/frontend/src/services.js +++ b/frontend/src/services.js @@ -46,16 +46,31 @@ class TextProcessor { }); } - async runTextCommand(command) { + async runTextCommand(command, opts) { if (this._editor === undefined) { return; } + const lineMode = opts && opts.lineMode; let ranges = this._editor.state.selection.ranges; - let shouldBeAll = ranges.reduce((a, r) => a && r.from === r.to, true); + let hasSelection = ranges.some(r => r.from !== r.to); let inputs = []; - if (shouldBeAll) { + if (lineMode && !hasSelection) { + this._appendInsertPos = undefined; + const doc = this._editor.state.doc; + const seen = new Set(); + for (let r of ranges) { + const line = doc.lineAt(r.head); + if (seen.has(line.number)) continue; + seen.add(line.number); + inputs.push({ + text: line.text, + pos: line.from, + len: line.to - line.from, + }); + } + } else if (!hasSelection) { this._appendInsertPos = this._editor.state.selection.main.head; inputs.push({ text: this._editor.state.doc.toString(), From b25e3e378f4c22b5118e2d47d7ca92e201f9747d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 25 May 2026 20:05:05 +1000 Subject: [PATCH 03/12] Updated release build --- .forgejo/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index edbbdd7..47c0989 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -21,9 +21,12 @@ jobs: - name: Running Wails doctor run: | wails doctor - - name: Build + - name: Installing Dependencies + working-directory: ./frontend run: | npm install + - name: Build + run: | wails build -clean -platform darwin/arm64 -ldflags "-X main.VersionNumber=`git describe --tags --abbrev=0`" - name: Release uses: https://lmika.dev/actions/wails-release@v1.0.3 @@ -35,7 +38,7 @@ jobs: notarization-api-issuer-id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} extra-build-flags: -ldflags "-X main.VersionNumber=${{ github.ref_name }}" s3-bucket: lmika-public-files - s3-key: Apps/Dequoter/{version}/{filename} + s3-key: 'Apps/Dequoter/{version}/{filename},Apps/Dequoter/latest/{filename}' s3-region: ap-southeast-2 s3-acl: public-read env: From 000f6790d441031b08d74125286c6c3f12bb0be8 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 25 May 2026 20:05:25 +1000 Subject: [PATCH 04/12] Bumped the release build version --- .forgejo/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 47c0989..eded102 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -29,7 +29,7 @@ jobs: run: | wails build -clean -platform darwin/arm64 -ldflags "-X main.VersionNumber=`git describe --tags --abbrev=0`" - name: Release - uses: https://lmika.dev/actions/wails-release@v1.0.3 + uses: https://lmika.dev/actions/wails-release@v1.0.4 with: developer-id-cert-base64: ${{ secrets.MACOS_SIGN_P12 }} developer-id-cert-password: ${{ secrets.MACOS_SIGN_PASSWORD }} From d1f51f5d7b21f85e1451c3df5c802d8161e3ac44 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 25 May 2026 20:20:36 +1000 Subject: [PATCH 05/12] Fixed npm dir --- .forgejo/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index eded102..392e612 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -22,7 +22,6 @@ jobs: run: | wails doctor - name: Installing Dependencies - working-directory: ./frontend run: | npm install - name: Build From 44e4d09c3afe7033545f2c02bf9986edf3672737 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 24 Jun 2026 20:42:34 +1000 Subject: [PATCH 06/12] Added commands for base64 and JWT decoding --- textfilters.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/textfilters.go b/textfilters.go index 1bd4334..e3ef6d7 100644 --- a/textfilters.go +++ b/textfilters.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -123,6 +124,32 @@ var TextFilters = map[string]TextProcessor{ return TextFilterResponse{Output: result}, nil }, }, + "compact-json": { + Label: "JSON: Compact", + Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { + var ( + dst bytes.Buffer + inBfr bytes.Buffer + outBfr bytes.Buffer + ) + + scnr := bufio.NewScanner(strings.NewReader(input)) + for scnr.Scan() { + line := scnr.Text() + inBfr.WriteString(line) + inBfr.WriteString("\n") + + if err := json.Compact(&outBfr, inBfr.Bytes()); err == nil { + dst.WriteString(outBfr.String()) + dst.WriteString("\n") + inBfr.Reset() + outBfr.Reset() + } + } + + return TextFilterResponse{Output: dst.String() + inBfr.String()}, nil + }, + }, "format-json": { Label: "JSON: Format", Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { @@ -173,6 +200,48 @@ var TextFilters = map[string]TextProcessor{ return TextFilterResponse{Output: string(jsonBytes)}, nil }, }, + "jwt-decode": { + Label: "JWT: Decode", + Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { + tokens := strings.Split(input, ".") + if len(tokens) != 3 { + return TextFilterResponse{}, errors.New("invalid JWT format") + } + + header, err := base64.RawURLEncoding.DecodeString(tokens[0]) + if err != nil { + return TextFilterResponse{}, err + } + payload, err := base64.RawURLEncoding.DecodeString(tokens[1]) + if err != nil { + return TextFilterResponse{}, err + } + + var headerJSON, payloadJSON bytes.Buffer + json.Indent(&headerJSON, header, "", " ") + json.Indent(&payloadJSON, payload, "", " ") + + return TextFilterResponse{Output: headerJSON.String() + "\n" + payloadJSON.String()}, nil + }, + }, + "base64-decode": { + Label: "Base64: Decode", + Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { + dst, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return TextFilterResponse{}, err + } + + return TextFilterResponse{Output: string(dst)}, nil + }, + }, + "base64-encode": { + Label: "Base64: Encode", + Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { + dst := base64.StdEncoding.EncodeToString([]byte(input)) + return TextFilterResponse{Output: dst}, nil + }, + }, "lorem-ipsum": { Label: "Generate: Lorem Ipsum", Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { From cb9c05a57b761919985193b5d53c9712f430ceac Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 7 Aug 2026 05:10:16 +0000 Subject: [PATCH 07/12] Added CSV to ASCII table --- textfilters.go | 60 +++++++++++++++++++++++++++++ textfilters_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 textfilters_test.go diff --git a/textfilters.go b/textfilters.go index e3ef6d7..ed8af7b 100644 --- a/textfilters.go +++ b/textfilters.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/csv" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "sort" "strconv" "strings" + "unicode/utf8" "text/template" @@ -184,6 +186,64 @@ var TextFilters = map[string]TextProcessor{ return TextFilterResponse{Output: dst.String()}, nil }, }, + "csv-to-ascii-table": { + Label: "Convert: CSV to ASCII Table", + Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { + endNL := "" + if strings.HasSuffix(input, "\n") { + endNL = "\n" + } + + reader := csv.NewReader(strings.NewReader(input)) + reader.FieldsPerRecord = -1 + records, err := reader.ReadAll() + if err != nil { + return TextFilterResponse{}, err + } + if len(records) == 0 { + return TextFilterResponse{Output: input}, nil + } + + var colWidths []int + for _, record := range records { + for i, cell := range record { + if i >= len(colWidths) { + colWidths = append(colWidths, 0) + } + colWidths[i] = max(colWidths[i], utf8.RuneCountInString(cell)) + } + } + + writeRow := func(dst *strings.Builder, cells []string) { + line := make([]string, len(colWidths)) + for i, width := range colWidths { + cell := "" + if i < len(cells) { + cell = cells[i] + } + line[i] = cell + strings.Repeat(" ", width-utf8.RuneCountInString(cell)) + } + dst.WriteString(strings.TrimRight(strings.Join(line, " "), " ")) + dst.WriteString("\n") + } + + dashes := make([]string, len(colWidths)) + for i, width := range colWidths { + dashes[i] = strings.Repeat("-", width) + } + dashLine := strings.Join(dashes, " ") + "\n" + + var dst strings.Builder + writeRow(&dst, records[0]) + dst.WriteString(dashLine) + for _, record := range records[1:] { + writeRow(&dst, record) + } + dst.WriteString(dashLine) + + return TextFilterResponse{Output: strings.TrimSuffix(dst.String(), "\n") + endNL}, nil + }, + }, "convert-yaml-to-json": { Label: "Convert: YAML to JSON", Filter: func(ctx context.Context, input string) (resp TextFilterResponse, err error) { diff --git a/textfilters_test.go b/textfilters_test.go new file mode 100644 index 0000000..fde1603 --- /dev/null +++ b/textfilters_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "testing" +) + +func TestCSVToASCIITable(t *testing.T) { + filter := TextFilters["csv-to-ascii-table"].Filter + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "basic table with quoted value", + input: "alpha,bravo\n" + + "1,b\n" + + "\"long value\",something\n", + want: "alpha bravo\n" + + "---------- ---------\n" + + "1 b\n" + + "long value something\n" + + "---------- ---------\n", + }, + { + name: "no trailing newline", + input: "a,b\n1,2", + want: "a b\n" + + "- -\n" + + "1 2\n" + + "- -", + }, + { + name: "quoted field containing comma", + input: "name,desc\n\"x, y\",z\n", + want: "name desc\n" + + "---- ----\n" + + "x, y z\n" + + "---- ----\n", + }, + { + name: "ragged rows padded with empty cells", + input: "a,b,c\n1\n2,3\n", + want: "a b c\n" + + "- - -\n" + + "1\n" + + "2 3\n" + + "- - -\n", + }, + { + name: "header only", + input: "one,two\n", + want: "one two\n" + + "--- ---\n" + + "--- ---\n", + }, + { + name: "empty input unchanged", + input: "", + want: "", + }, + { + name: "invalid CSV returns error", + input: "a,\"b\nc", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := filter(context.Background(), tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got output %q", resp.Output) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Output != tt.want { + t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", resp.Output, tt.want) + } + if resp.Append { + t.Errorf("Append should be false") + } + }) + } +} From ac437b74099a87398de70c0f85362115946e7dd0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 7 Sep 2026 01:09:21 +0000 Subject: [PATCH 08/12] Added plugin support for Dequoter --- README.md | 21 ++++ app.go | 43 ++++++- main.go | 1 + plugins.go | 144 ++++++++++++++++++++++ plugins_test.go | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 plugins.go create mode 100644 plugins_test.go diff --git a/README.md b/README.md index f727d8a..b17f494 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,27 @@ where all your chances will be lost when you close it. This project uses [Wails](https://wails.io) to build a desktop application with a Go backend and a Vite frontend. +## Plugins + +Dequoter can be extended with new filters written in [UCL](https://ucl.lmika.dev). +On startup, if the directory `$HOME/.config/dequoter/plugins` exists, every file ending in `.ucl` +found in it (including subdirectories) is evaluated. Filters are defined with the `d:filter` builtin: + +``` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +``` + +`d:filter` takes a label, which is what appears in the command palette (Cmd+P), and a proc. The proc is +called with the text to process as its only argument. The result of the proc becomes the filter output: +strings are used as-is, other values are converted to their string form, and a proc that returns nothing +is reported as an error. Errors raised with `error` are shown in the status bar. + +A plugin filter with the same label as a built-in filter replaces it in the palette. If two plugin files +define the same label, the one loaded later wins (files are loaded in lexical path order). Files that +fail to load are skipped and reported in the status bar; the remaining files are still loaded. + ## Download [Download Release](/dequoter-darwin-arm64.zip) diff --git a/app.go b/app.go index 7148f8b..3a8f012 100644 --- a/app.go +++ b/app.go @@ -17,13 +17,16 @@ import ( type App struct { store *Store uclInst *ucl.Inst + plugins *PluginFilters ctx context.Context } // NewApp creates a new App application struct func NewApp(store *Store) *App { + plugins := newPluginFilters() uclInst := ucl.New( + ucl.WithModule(plugins.Module()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -37,6 +40,7 @@ func NewApp(store *Store) *App { return &App{ store: store, uclInst: uclInst, + plugins: plugins, } } @@ -44,6 +48,35 @@ func NewApp(store *Store) *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) + + dir, err := pluginDir() + if err != nil { + log.Printf("cannot determine plugin directory: %v", err) + return + } + a.plugins.LoadDir(a.ctx, a.uclInst, dir) +} + +// domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar. +func (a *App) domReady(ctx context.Context) { + errs := a.plugins.Errors() + if len(errs) == 0 { + return + } + + runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]), + Error: true, + }) +} + +// lookupProcessor finds a text processor by action key, preferring plugin-defined filters. +func (a *App) lookupProcessor(name string) (TextProcessor, bool) { + if proc, ok := a.plugins.filters[name]; ok { + return proc, true + } + proc, ok := TextFilters[name] + return proc, ok } func (a *App) LoadCurrentBuffer() (string, error) { @@ -55,7 +88,15 @@ func (a *App) SaveCurrentBuffer(buffer string) error { } func (a *App) ListProcessors() (resp []ListProcessorsResponse) { + pluginLabels := make(map[string]struct{}, len(a.plugins.filters)) + for k, v := range a.plugins.filters { + pluginLabels[v.Label] = struct{}{} + resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) + } for k, v := range TextFilters { + if _, shadowed := pluginLabels[v.Label]; shadowed { + continue + } resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) } sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label }) @@ -85,7 +126,7 @@ func (a *App) PromptUser(label string, resp func(string)) { } func (a *App) ProcessText(req ProcessTextRequest) { - filter, ok := TextFilters[req.Action] + filter, ok := a.lookupProcessor(req.Action) if !ok { log.Printf("Unknown filter: [%s]", req.Action) return diff --git a/main.go b/main.go index 2145e34..cc7ade6 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,7 @@ func main() { }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + OnDomReady: app.domReady, Bind: []interface{}{ app, }, diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..86dc977 --- /dev/null +++ b/plugins.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "ucl.lmika.dev/ucl" +) + +const pluginKeyPrefix = "plugin:" + +// PluginFilters is the registry of text filters defined by UCL plugin files. +type PluginFilters struct { + inst *ucl.Inst + filters map[string]TextProcessor + errors []error +} + +func newPluginFilters() *PluginFilters { + return &PluginFilters{ + filters: make(map[string]TextProcessor), + } +} + +// Module returns the "d" UCL module, exposing d:filter. +func (p *PluginFilters) Module() ucl.Module { + return ucl.Module{ + Name: "d", + Builtins: map[string]ucl.BuiltinHandler{ + "filter": p.filterBuiltin, + }, + } +} + +// filterBuiltin implements `d:filter LABEL PROC`. PROC receives the filter input as its only +// argument and its result becomes the filter output. +func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + label string + proc ucl.Invokable + ) + + if err := args.Bind(&label, &proc); err != nil { + return nil, fmt.Errorf("d:filter: expected LABEL and PROC: %w", err) + } + if strings.TrimSpace(label) == "" { + return nil, errors.New("d:filter: label must not be empty") + } + if proc.IsNil() { + return nil, errors.New("d:filter: PROC must not be nil") + } + + p.filters[pluginKeyPrefix+slugify(label)] = TextProcessor{ + Label: label, + Filter: func(ctx context.Context, input string) (TextFilterResponse, error) { + res, err := proc.Invoke(ctx, input) + if err != nil { + return TextFilterResponse{}, err + } + + switch v := res.(type) { + case nil: + return TextFilterResponse{}, errors.New("filter returned no value") + case string: + return TextFilterResponse{Output: v}, nil + default: + return TextFilterResponse{Output: fmt.Sprint(v)}, nil + } + }, + } + return nil, nil +} + +// LoadDir evaluates every *.ucl file found under dir (recursively, in lexical order). Files +// that fail to load are recorded in Errors() and skipped; loading continues with the rest. +// A missing directory is not an error. +func (p *PluginFilters) LoadDir(ctx context.Context, inst *ucl.Inst, dir string) { + if _, err := os.Stat(dir); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + p.recordError(fmt.Errorf("%s: %w", dir, err)) + } + return + } + + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + p.recordError(fmt.Errorf("%s: %w", path, err)) + return nil + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".ucl") { + return nil + } + + if err := p.loadFile(ctx, inst, path); err != nil { + p.recordError(fmt.Errorf("%s: %w", path, err)) + } + return nil + }) +} + +func (p *PluginFilters) loadFile(ctx context.Context, inst *ucl.Inst, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + _, err = inst.Eval(ctx, f) + return err +} + +func (p *PluginFilters) recordError(err error) { + log.Printf("plugin load error: %v", err) + p.errors = append(p.errors, err) +} + +// Errors returns the errors encountered while loading plugin files. +func (p *PluginFilters) Errors() []error { + return p.errors +} + +// pluginDir returns the plugin directory: $HOME/.config/dequoter/plugins +func pluginDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "dequoter", "plugins"), nil +} + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify converts a label into a key-safe slug: lower-cased, with runs of characters other than +// [a-z0-9] replaced by a single hyphen and leading/trailing hyphens removed. +func slugify(label string) string { + return strings.Trim(nonSlugChars.ReplaceAllString(strings.ToLower(label), "-"), "-") +} diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..522d2e6 --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,320 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" +) + +func newTestPlugins() (*PluginFilters, *ucl.Inst) { + plugins := newPluginFilters() + inst := ucl.New( + ucl.WithModule(plugins.Module()), + ucl.WithModule(builtins.Strs()), + ) + return plugins, inst +} + +func writePluginFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestPluginFilters_LoadDir(t *testing.T) { + ctx := context.Background() + + t.Run("registers filter from upper-case example", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "upper.ucl", ` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + proc, ok := plugins.filters["plugin:string-to-upper-case"] + if !ok { + t.Fatalf("filter not registered, have %v", plugins.filters) + } + if proc.Label != "String: To Upper Case" { + t.Errorf("label = %q", proc.Label) + } + res, err := proc.Filter(ctx, "abc") + if err != nil { + t.Fatal(err) + } + if res.Output != "ABC" || res.Append { + t.Errorf("got %+v", res) + } + }) + + t.Run("finds nested files and ignores non-ucl files", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "sub/dir/nested.ucl", `d:filter "Nested" { |in| $in }`) + writePluginFile(t, dir, "notes.txt", `d:filter "Ignored" { |in| $in }`) + writePluginFile(t, dir, "README.md", `this is not ucl {`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if _, ok := plugins.filters["plugin:nested"]; !ok { + t.Error("nested filter not registered") + } + if _, ok := plugins.filters["plugin:ignored"]; ok { + t.Error("non-ucl file should have been ignored") + } + }) + + t.Run("missing directory is not an error", func(t *testing.T) { + plugins, inst := newTestPlugins() + + plugins.LoadDir(ctx, inst, filepath.Join(t.TempDir(), "does-not-exist")) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if len(plugins.filters) != 0 { + t.Errorf("unexpected filters: %v", plugins.filters) + } + }) + + t.Run("bad file is reported and sibling still loads", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "a-bad.ucl", `d:filter "Broken" { |in| `) + writePluginFile(t, dir, "b-good.ucl", `d:filter "Good" { |in| $in }`) + writePluginFile(t, dir, "c-bad-usage.ucl", `d:filter "Only Label"`) + + plugins.LoadDir(ctx, inst, dir) + + errs := plugins.Errors() + if len(errs) != 2 { + t.Fatalf("expected 2 errors, got %v", errs) + } + if !strings.Contains(errs[0].Error(), "a-bad.ucl") { + t.Errorf("first error should name the file: %v", errs[0]) + } + if !strings.Contains(errs[1].Error(), "c-bad-usage.ucl") { + t.Errorf("second error should name the file: %v", errs[1]) + } + if _, ok := plugins.filters["plugin:good"]; !ok { + t.Error("good filter not registered") + } + if _, ok := plugins.filters["plugin:broken"]; ok { + t.Error("broken filter should not be registered") + } + }) + + t.Run("later definition with same label wins", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "01-first.ucl", `d:filter "Dup" { |in| "first" }`) + writePluginFile(t, dir, "02-second.ucl", `d:filter "Dup" { |in| "second" }`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.filters) != 1 { + t.Fatalf("expected 1 filter, got %v", plugins.filters) + } + res, err := plugins.filters["plugin:dup"].Filter(ctx, "x") + if err != nil { + t.Fatal(err) + } + if res.Output != "second" { + t.Errorf("output = %q", res.Output) + } + }) + + t.Run("procs defined in plugin files are reusable", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "helpers.ucl", ` +proc shout { |s| strs:to-upper $s } +d:filter "Shout" { |in| shout $in } +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + res, err := plugins.filters["plugin:shout"].Filter(ctx, "hi") + if err != nil { + t.Fatal(err) + } + if res.Output != "HI" { + t.Errorf("output = %q", res.Output) + } + }) +} + +func TestPluginFilters_ProcResults(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + input string + want string + wantErr string + }{ + { + name: "string result used as-is", + script: `d:filter "F" { |in| strs:to-upper $in }`, + input: "abc", + want: "ABC", + }, + { + name: "non-string result is stringified", + script: `d:filter "F" { |in| add 40 2 }`, + input: "ignored", + want: "42", + }, + { + name: "nil result is an error", + script: `d:filter "F" { |in| }`, + input: "abc", + wantErr: "filter returned no value", + }, + { + name: "error raised in proc propagates", + script: `d:filter "F" { |in| error "boom" }`, + input: "abc", + wantErr: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err != nil { + t.Fatal(err) + } + proc, ok := plugins.filters["plugin:f"] + if !ok { + t.Fatalf("filter not registered") + } + + res, err := proc.Filter(ctx, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v (res %+v)", tt.wantErr, err, res) + } + return + } + if err != nil { + t.Fatal(err) + } + if res.Output != tt.want { + t.Errorf("output = %q, want %q", res.Output, tt.want) + } + }) + } +} + +func TestPluginFilters_FilterBuiltinValidation(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + }{ + {name: "missing proc", script: `d:filter "Label"`}, + {name: "no args", script: `d:filter`}, + {name: "empty label", script: `d:filter " " { |in| $in }`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err == nil { + t.Fatal("expected an error") + } + if len(plugins.filters) != 0 { + t.Errorf("no filter should be registered, got %v", plugins.filters) + } + }) + } +} + +func TestSlugify(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"String: To Upper Case", "string-to-upper-case"}, + {" Lines: Go Template… ", "lines-go-template"}, + {"already-a-slug", "already-a-slug"}, + {"MiXeD CaSe 123", "mixed-case-123"}, + {"---", ""}, + } + for _, tt := range tests { + if got := slugify(tt.in); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestApp_PluginIntegration(t *testing.T) { + ctx := context.Background() + app := NewApp(nil) + dir := t.TempDir() + writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`) + writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`) + + app.plugins.LoadDir(ctx, app.uclInst, dir) + + t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { + resp := app.ListProcessors() + + byLabel := map[string][]string{} + for _, r := range resp { + byLabel[r.Label] = append(byLabel[r.Label], r.Name) + } + if names := byLabel["String: To Upper Case"]; len(names) != 1 || names[0] != "plugin:string-to-upper-case" { + t.Errorf("expected only the plugin entry for the shadowed label, got %v", names) + } + if names := byLabel["Custom: Echo"]; len(names) != 1 || names[0] != "plugin:custom-echo" { + t.Errorf("expected plugin entry, got %v", names) + } + if names := byLabel["String: To Lower Case"]; len(names) != 1 || names[0] != "lower-case" { + t.Errorf("built-in should be unaffected, got %v", names) + } + for i := 1; i < len(resp); i++ { + if resp[i-1].Label > resp[i].Label { + t.Errorf("response not sorted by label at %d: %q > %q", i, resp[i-1].Label, resp[i].Label) + } + } + }) + + t.Run("lookupProcessor prefers plugin then falls back to built-in", func(t *testing.T) { + if proc, ok := app.lookupProcessor("plugin:custom-echo"); !ok || proc.Label != "Custom: Echo" { + t.Errorf("plugin lookup failed: %v %v", proc, ok) + } + if proc, ok := app.lookupProcessor("lower-case"); !ok || proc.Label != "String: To Lower Case" { + t.Errorf("built-in lookup failed: %v %v", proc, ok) + } + if _, ok := app.lookupProcessor("nope"); ok { + t.Error("unknown key should not be found") + } + }) +} From 18604ec5a64d71cd026ba4df9ffafef63536a639 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 7 Sep 2026 22:57:44 +0000 Subject: [PATCH 09/12] Added plugin reload --- README.md | 3 + app.go | 155 ++++++++++++++---- .../src/controllers/commands_controller.js | 24 ++- main.go | 7 +- plugins_test.go | 94 ++++++++++- 5 files changed, 247 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index b17f494..11a7169 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ A plugin filter with the same label as a built-in filter replaces it in the pale define the same label, the one loaded later wins (files are loaded in lexical path order). Files that fail to load are skipped and reported in the status bar; the remaining files are still loaded. +After editing plugin files, choose File → Reload Plugins (Shift+Cmd+R) to rescan the directory without +restarting. This discards the previous plugin filters and UCL interpreter state before loading the files again. + ## Download [Download Release](/dequoter-darwin-arm64.zip) diff --git a/app.go b/app.go index 3a8f012..e46d5b9 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "log" "sort" "strings" + "sync" "github.com/wailsapp/wails/v2/pkg/runtime" "lmika.dev/pkg/modash/moslice" @@ -15,17 +16,21 @@ import ( // App struct type App struct { - store *Store + store *Store + + // mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded. + mu sync.RWMutex uclInst *ucl.Inst plugins *PluginFilters + ctx context.Context - ctx context.Context + // baseCtx is the raw Wails context supplied to startup. ctx is derived from it. + baseCtx context.Context } -// NewApp creates a new App application struct -func NewApp(store *Store) *App { - plugins := newPluginFilters() - uclInst := ucl.New( +// newUCLInst creates a UCL interpreter with the standard modules plus the plugin module. +func newUCLInst(plugins *PluginFilters) *ucl.Inst { + return ucl.New( ucl.WithModule(plugins.Module()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), @@ -37,42 +42,118 @@ func NewApp(store *Store) *App { ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), ) - return &App{ +} + +// NewApp creates a new App application struct +func NewApp(store *Store) *App { + app := &App{ store: store, - uclInst: uclInst, - plugins: plugins, + 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() + } + + 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) } // 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) { - a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) + a.baseCtx = ctx dir, err := pluginDir() if err != nil { log.Printf("cannot determine plugin directory: %v", err) + a.setUCLInst(newUCLInst(newPluginFilters()), nil) return } - a.plugins.LoadDir(a.ctx, a.uclInst, dir) + a.loadPluginsFrom(dir) } // domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar. func (a *App) domReady(ctx context.Context) { - errs := a.plugins.Errors() - if len(errs) == 0 { + _, 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, + }) return } - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ - Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]), - Error: true, - }) + 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), + }) + } } // lookupProcessor finds a text processor by action key, preferring plugin-defined filters. func (a *App) lookupProcessor(name string) (TextProcessor, bool) { - if proc, ok := a.plugins.filters[name]; ok { + _, plugins, _ := a.current() + + if proc, ok := plugins.filters[name]; ok { return proc, true } proc, ok := TextFilters[name] @@ -88,8 +169,10 @@ func (a *App) SaveCurrentBuffer(buffer string) error { } func (a *App) ListProcessors() (resp []ListProcessorsResponse) { - pluginLabels := make(map[string]struct{}, len(a.plugins.filters)) - for k, v := range a.plugins.filters { + _, plugins, _ := a.current() + + pluginLabels := make(map[string]struct{}, len(plugins.filters)) + for k, v := range plugins.filters { pluginLabels[v.Label] = struct{}{} resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) } @@ -104,13 +187,15 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) { } func (a *App) TriggerTextProcess(action string) { - runtime.EventsEmit(a.ctx, "request-text-process", RequestTextProcess{ + _, _, ctx := a.current() + runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{ Action: action, }) } func (a *App) PromptUser(label string, resp func(string)) { - runtime.EventsOnce(a.ctx, "prompt-response", func(data ...interface{}) { + _, _, ctx := a.current() + runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) { if len(data) == 0 { return } @@ -120,21 +205,29 @@ func (a *App) PromptUser(label string, resp func(string)) { } resp(ans) }) - runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{ + runtime.EventsEmit(ctx, "prompt-request", PromptRequest{ Label: label, }) } func (a *App) ProcessText(req ProcessTextRequest) { + // Capture the context once so the whole operation runs against a single interpreter, even if + // plugins are reloaded part way through. + _, _, ctx := a.current() + filter, ok := a.lookupProcessor(req.Action) if !ok { log.Printf("Unknown filter: [%s]", req.Action) + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + Message: fmt.Sprintf("Unknown filter: %s", req.Action), + Error: true, + }) return } applyFilter := func(filter TextFilter) { resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) { - outRes, err := filter(a.ctx, span.Text) + outRes, err := filter(ctx, span.Text) if err != nil { return TextSpan{}, err } @@ -147,14 +240,14 @@ func (a *App) ProcessText(req ProcessTextRequest) { }, nil }) if err != nil { - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) return } - runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{ + runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{ Output: resp, }) } @@ -169,25 +262,25 @@ func (a *App) ProcessText(req ProcessTextRequest) { inBfr.WriteString(span.Text) } - msg, err := filter.Analyze(a.ctx, inBfr.String()) + msg, err := filter.Analyze(ctx, inBfr.String()) if err != nil { - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running analysis: %v", err.Error()), Error: true, }) return } - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: msg, }) case filter.Filter != nil: applyFilter(filter.Filter) case filter.FilterWithArg != nil: a.PromptUser(filter.FilterWithArg.Label, func(ans string) { - filter, err := filter.FilterWithArg.OnConfirm(a.ctx, ans) + filter, err := filter.FilterWithArg.OnConfirm(ctx, ans) if err != nil { - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) diff --git a/frontend/src/controllers/commands_controller.js b/frontend/src/controllers/commands_controller.js index 5d74380..928863a 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -12,15 +12,33 @@ export class CommandsController extends Controller { async connect() { this._lastCommand = null; + this._options = []; + await this._loadProcessors(); + window.runtime.EventsOn("processors-changed", () => { + this._loadProcessors(); + }); + } + + async _loadProcessors() { let processors = await ListProcessors(); - processors.forEach((processor) => { + + let options = processors.map((processor) => { let option = document.createElement("option"); option.value = processor.name; option.text = processor.label; - this.commandSelectTarget.appendChild(option); + return option; }); - this._options = Array.from(this.commandSelectTarget.options); + this._options = options; + this.commandSelectTarget.replaceChildren(...options); + + if (this._lastCommand !== null && !options.some(opt => opt.value === this._lastCommand)) { + this._lastCommand = null; + } + + if (this.element.open) { + this._filterOptions(this.commandInputTarget.value); + } } showCommands(ev) { diff --git a/main.go b/main.go index cc7ade6..73b5430 100644 --- a/main.go +++ b/main.go @@ -37,9 +37,14 @@ func main() { appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()` } fileMenu := appMenu.AddSubmenu("File") + fileMenu.AddText("Reload Plugins", keys.Combo("r", keys.CmdOrCtrlKey, keys.ShiftKey), func(_ *menu.CallbackData) { + app.ReloadPlugins() + }) + fileMenu.AddSeparator() fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { // `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package - rt.Quit(app.ctx) + _, _, ctx := app.current() + rt.Quit(ctx) }) if runtime.GOOS == "darwin" { diff --git a/plugins_test.go b/plugins_test.go index 522d2e6..f82f69e 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -281,7 +281,10 @@ func TestApp_PluginIntegration(t *testing.T) { writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`) writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`) - app.plugins.LoadDir(ctx, app.uclInst, dir) + if n := app.loadPluginsFrom(dir); n != 2 { + t.Fatalf("expected 2 filters loaded, got %d", n) + } + _ = ctx t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { resp := app.ListProcessors() @@ -318,3 +321,92 @@ func TestApp_PluginIntegration(t *testing.T) { } }) } + +func TestApp_ReloadPlugins(t *testing.T) { + ctx := context.Background() + app := NewApp(nil) + dir := t.TempDir() + + writePluginFile(t, dir, "a.ucl", ` +proc helper { |s| strs:to-upper $s } +d:filter "Filter A" { |in| helper $in } +`) + + if n := app.loadPluginsFrom(dir); n != 1 { + t.Fatalf("expected 1 filter, got %d", n) + } + + firstInst, firstPlugins, firstCtx := app.current() + if uclInstFromContext(firstCtx) != firstInst { + t.Fatal("context does not carry the current interpreter") + } + if res, err := firstInst.EvalString(ctx, `helper "x"`); err != nil || res != "X" { + t.Fatalf("helper proc should be defined: %v %v", res, err) + } + filterA, ok := firstPlugins.filters["plugin:filter-a"] + if !ok { + t.Fatal("Filter A not registered") + } + + // Replace a.ucl with b.ucl and reload. + if err := os.Remove(filepath.Join(dir, "a.ucl")); err != nil { + t.Fatal(err) + } + writePluginFile(t, dir, "b.ucl", `d:filter "Filter B" { |in| strs:to-lower $in }`) + + if n := app.loadPluginsFrom(dir); n != 1 { + t.Fatalf("expected 1 filter after reload, got %d", n) + } + + secondInst, secondPlugins, secondCtx := app.current() + if secondInst == firstInst { + t.Error("interpreter should have been rebuilt") + } + if uclInstFromContext(secondCtx) != secondInst { + t.Error("context should carry the new interpreter") + } + if _, ok := secondPlugins.filters["plugin:filter-a"]; ok { + t.Error("Filter A should have been deregistered") + } + if _, ok := app.lookupProcessor("plugin:filter-a"); ok { + t.Error("lookupProcessor should no longer find Filter A") + } + if proc, ok := app.lookupProcessor("plugin:filter-b"); !ok || proc.Label != "Filter B" { + t.Errorf("Filter B should be registered, got %v %v", proc, ok) + } + if _, err := secondInst.EvalString(ctx, `helper "x"`); err == nil { + t.Error("helper proc from the removed file should no longer be defined") + } + + t.Run("filter captured before reload still works", func(t *testing.T) { + res, err := filterA.Filter(ctx, "abc") + if err != nil { + t.Fatal(err) + } + if res.Output != "ABC" { + t.Errorf("output = %q", res.Output) + } + }) + + t.Run("reload with bad file reports fresh errors", func(t *testing.T) { + writePluginFile(t, dir, "c.ucl", `d:filter "Broken" { |in|`) + + n := app.loadPluginsFrom(dir) + + _, plugins, _ := app.current() + if n != 1 || len(plugins.filters) != 1 { + t.Errorf("expected only Filter B to survive, got n=%d filters=%v", n, plugins.filters) + } + if errs := plugins.Errors(); len(errs) != 1 || !strings.Contains(errs[0].Error(), "c.ucl") { + t.Errorf("expected one error naming c.ucl, got %v", errs) + } + + if err := os.Remove(filepath.Join(dir, "c.ucl")); err != nil { + t.Fatal(err) + } + app.loadPluginsFrom(dir) + if _, plugins, _ := app.current(); len(plugins.Errors()) != 0 { + t.Errorf("errors should be cleared after a clean reload, got %v", plugins.Errors()) + } + }) +} From add62fbfa054d10e046fb4dc836a64e1e638ab5c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 8 Sep 2026 22:03:41 +1000 Subject: [PATCH 10/12] Added map-lines and upgraded UCL --- .idea/dequoter.iml | 1 + app.go | 2 ++ frontend/wailsjs/go/main/App.d.ts | 2 ++ frontend/wailsjs/go/main/App.js | 4 +++ go.mod | 2 +- go.sum | 6 ++-- plugins.go | 51 +++++++++++++++++++++++++++++-- 7 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.idea/dequoter.iml b/.idea/dequoter.iml index 5e764c4..9c408cc 100644 --- a/.idea/dequoter.iml +++ b/.idea/dequoter.iml @@ -1,6 +1,7 @@ + diff --git a/app.go b/app.go index e46d5b9..37d2bd1 100644 --- a/app.go +++ b/app.go @@ -32,6 +32,7 @@ type App struct { func newUCLInst(plugins *PluginFilters) *ucl.Inst { return ucl.New( ucl.WithModule(plugins.Module()), + ucl.WithModule(builtins.Bytes()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -41,6 +42,7 @@ func newUCLInst(plugins *PluginFilters) *ucl.Inst { ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), + ucl.WithModule(builtins.URLs()), ) } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 56c40cf..f44f801 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -10,6 +10,8 @@ export function ProcessText(arg1:main.ProcessTextRequest):Promise; export function PromptUser(arg1:string,arg2:any):Promise; +export function ReloadPlugins():Promise; + export function SaveCurrentBuffer(arg1:string):Promise; export function TriggerTextProcess(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 144d9f3..78a313d 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,6 +18,10 @@ export function PromptUser(arg1, arg2) { return window['go']['main']['App']['PromptUser'](arg1, arg2); } +export function ReloadPlugins() { + return window['go']['main']['App']['ReloadPlugins'](); +} + export function SaveCurrentBuffer(arg1) { return window['go']['main']['App']['SaveCurrentBuffer'](arg1); } diff --git a/go.mod b/go.mod index 9a8d136..d967a05 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 lmika.dev/pkg/modash v0.1.0 lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b - ucl.lmika.dev v0.1.3 + ucl.lmika.dev v0.1.7 ) require ( diff --git a/go.sum b/go.sum index 04dd12d..94bf82c 100644 --- a/go.sum +++ b/go.sum @@ -796,5 +796,7 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -ucl.lmika.dev v0.1.3 h1:Y2m+ORnLMA8boQmkXpW41VbkR3bwmnZjDLGVTYbZQic= -ucl.lmika.dev v0.1.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.6 h1:cZocpR/IQcy9nvE8u+SSLY7/PE9fd1HIVWhZr/m+U5M= +ucl.lmika.dev v0.1.6/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= +ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/plugins.go b/plugins.go index 86dc977..baaf476 100644 --- a/plugins.go +++ b/plugins.go @@ -1,6 +1,8 @@ package main import ( + "bufio" + "bytes" "context" "errors" "fmt" @@ -18,7 +20,6 @@ const pluginKeyPrefix = "plugin:" // PluginFilters is the registry of text filters defined by UCL plugin files. type PluginFilters struct { - inst *ucl.Inst filters map[string]TextProcessor errors []error } @@ -34,7 +35,8 @@ func (p *PluginFilters) Module() ucl.Module { return ucl.Module{ Name: "d", Builtins: map[string]ucl.BuiltinHandler{ - "filter": p.filterBuiltin, + "filter": p.filterBuiltin, + "map-lines": p.mapLinesBuiltin, }, } } @@ -78,6 +80,51 @@ func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (a return nil, nil } +// mapLinesBuiltin implements `d:map-lines INPUT PROC` which applies a transform for each line of the input. +func (p *PluginFilters) mapLinesBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + input string + proc ucl.Invokable + ) + + if err := args.Bind(&input, &proc); err != nil { + return nil, fmt.Errorf("d:map-lines: expected INPUT and PROC: %w", err) + } + + var ( + dst bytes.Buffer + dstLine bytes.Buffer + ) + + scnr := bufio.NewScanner(strings.NewReader(input)) + isFirst := true + for scnr.Scan() { + dstLine.Reset() + line := scnr.Text() + + out, err := proc.Invoke(ctx, line) + if err != nil { + return TextFilterResponse{}, err + } else if out == nil { + continue + } else if strOut, ok := out.(fmt.Stringer); ok { + dstLine.WriteString(strOut.String()) + } else { + dstLine.WriteString(fmt.Sprint(out)) + } + + if isFirst { + isFirst = false + } else { + dst.WriteString("\n") + } + + dst.WriteString(dstLine.String()) + } + + return dst.String(), nil +} + // LoadDir evaluates every *.ucl file found under dir (recursively, in lexical order). Files // that fail to load are recorded in Errors() and skipped; loading continues with the rest. // A missing directory is not an error. From 9751be125e5f4995e3d689c4a88c1b67d6c556a7 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 9 Sep 2026 21:08:46 +1000 Subject: [PATCH 11/12] Moved the plugin directory --- README.md | 9 ++++++--- app.go | 27 ++++++++++++++++++++++----- go.sum | 2 -- main.go | 6 ++++++ plugins.go | 15 +++++++++++++-- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 11a7169..ef8a722 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ This project uses [Wails](https://wails.io) to build a desktop application with ## Plugins Dequoter can be extended with new filters written in [UCL](https://ucl.lmika.dev). -On startup, if the directory `$HOME/.config/dequoter/plugins` exists, every file ending in `.ucl` -found in it (including subdirectories) is evaluated. Filters are defined with the `d:filter` builtin: +On startup, Dequoter creates `$HOME/Library/Application Support/dev.lmika.dequoter/plugins` if needed, +then evaluates every file ending in `.ucl` found in it (including subdirectories). +Filters are defined with the `d:filter` builtin: ``` d:filter "String: To Upper Case" { |in| @@ -33,6 +34,8 @@ fail to load are skipped and reported in the status bar; the remaining files are After editing plugin files, choose File → Reload Plugins (Shift+Cmd+R) to rescan the directory without restarting. This discards the previous plugin filters and UCL interpreter state before loading the files again. +Use File → Open Plugins in Finder or File → Open Plugins in Terminal to access the directory. +If you have plugins in the old `$HOME/.config/dequoter/plugins` directory, move them to the new location. ## Download @@ -52,4 +55,4 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app.go b/app.go index 37d2bd1..8a467ef 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os/exec" "sort" "strings" "sync" @@ -96,10 +97,11 @@ func (a *App) loadPluginsFrom(dir string) int { func (a *App) startup(ctx context.Context) { a.baseCtx = ctx - dir, err := pluginDir() + dir, err := ensurePluginDir() if err != nil { - log.Printf("cannot determine plugin directory: %v", err) - a.setUCLInst(newUCLInst(newPluginFilters()), nil) + plugins := newPluginFilters() + plugins.recordError(err) + a.setUCLInst(newUCLInst(plugins), plugins) return } a.loadPluginsFrom(dir) @@ -114,11 +116,11 @@ func (a *App) domReady(ctx context.Context) { // 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() + dir, err := ensurePluginDir() if err != nil { _, _, ctx := a.current() runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ - Message: fmt.Sprintf("Cannot determine plugin directory: %v", err), + Message: fmt.Sprintf("Cannot prepare plugin directory: %v", err), Error: true, }) return @@ -131,6 +133,21 @@ func (a *App) ReloadPlugins() { a.reportPluginStatus(count, true) } +// openPluginDir opens the plugin directory using the specified macOS application. +func (a *App) openPluginDir(application string) { + dir, err := ensurePluginDir() + if err == nil { + err = exec.Command("/usr/bin/open", "-a", application, dir).Run() + } + if err != nil { + _, _, ctx := a.current() + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + Message: fmt.Sprintf("Cannot open plugins in %s: %v", application, err), + Error: 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) { diff --git a/go.sum b/go.sum index 94bf82c..f3643af 100644 --- a/go.sum +++ b/go.sum @@ -796,7 +796,5 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -ucl.lmika.dev v0.1.6 h1:cZocpR/IQcy9nvE8u+SSLY7/PE9fd1HIVWhZr/m+U5M= -ucl.lmika.dev v0.1.6/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/main.go b/main.go index 73b5430..167c39f 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,12 @@ func main() { fileMenu.AddText("Reload Plugins", keys.Combo("r", keys.CmdOrCtrlKey, keys.ShiftKey), func(_ *menu.CallbackData) { app.ReloadPlugins() }) + fileMenu.AddText("Open Plugins in Finder", nil, func(_ *menu.CallbackData) { + app.openPluginDir("Finder") + }) + fileMenu.AddText("Open Plugins in Terminal", nil, func(_ *menu.CallbackData) { + app.openPluginDir("Terminal") + }) fileMenu.AddSeparator() fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { // `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package diff --git a/plugins.go b/plugins.go index baaf476..036062e 100644 --- a/plugins.go +++ b/plugins.go @@ -173,13 +173,24 @@ func (p *PluginFilters) Errors() []error { return p.errors } -// pluginDir returns the plugin directory: $HOME/.config/dequoter/plugins +// pluginDir returns the plugin directory under macOS Application Support. func pluginDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".config", "dequoter", "plugins"), nil + return filepath.Join(home, "Library", "Application Support", "dev.lmika.dequoter", "Plugins"), nil +} + +func ensurePluginDir() (string, error) { + dir, err := pluginDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("create plugin directory: %w", err) + } + return dir, nil } var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) From dea9393d4d56227b60cf64b0baecf2ade6711e33 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 22 Oct 2026 21:59:39 +1100 Subject: [PATCH 12/12] Added d:analyze and upgraded UCL --- frontend/wailsjs/runtime/runtime.d.ts | 83 ++++++++++++++++++++++++++- frontend/wailsjs/runtime/runtime.js | 60 +++++++++++++++++++ go.mod | 29 +++++----- go.sum | 58 +++++++++---------- plugins.go | 40 +++++++++++++ 5 files changed, 225 insertions(+), 45 deletions(-) diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 623397b..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) { return window.runtime.EventsOff(eventName, ...additionalEventNames); } +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + export function EventsOnce(eventName, callback) { return EventsOnMultiple(eventName, callback, 1); } @@ -235,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/go.mod b/go.mod index d967a05..704fc91 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,19 @@ module dequoter -go 1.25 +go 1.25.0 require ( github.com/google/uuid v1.6.0 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/wailsapp/wails/v2 v2.10.2 + github.com/wailsapp/wails/v2 v2.17.0 gopkg.in/yaml.v3 v3.0.1 lmika.dev/pkg/modash v0.1.0 lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b - ucl.lmika.dev v0.1.7 + ucl.lmika.dev v0.1.8 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest v0.11.30 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect @@ -23,6 +24,7 @@ require ( github.com/bep/debounce v1.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.16 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -52,14 +54,14 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect - github.com/labstack/echo/v4 v4.13.3 // indirect - github.com/labstack/gommon v0.4.2 // indirect + github.com/labstack/echo/v4 v4.15.3 // indirect + github.com/labstack/gommon v0.5.0 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/u v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/netlify/open-api/v2 v2.48.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect @@ -71,8 +73,7 @@ require ( github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.19 // indirect - github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/yuin/goldmark v1.7.16 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -80,11 +81,11 @@ require ( go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect ) // replace github.com/wailsapp/wails/v2 v2.10.2 => /Users/leonmika/go/pkg/mod diff --git a/go.sum b/go.sum index f3643af..c0be4ea 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= @@ -100,6 +102,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E= +github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= @@ -371,10 +375,10 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= -github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/labstack/echo/v4 v4.15.3 h1:lIdG4kK5RdMyhwCwSc4AmSQsLBb3AVwok6S8PX/9kwQ= +github.com/labstack/echo/v4 v4.15.3/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= @@ -398,12 +402,11 @@ github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwM github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -513,12 +516,10 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= -github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= -github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= -github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk= -github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/wails/v2 v2.17.0 h1:pJqX9ogqW4PPE/gSeCQUcYmpPghxEpHajONw9rPj1pg= +github.com/wailsapp/wails/v2 v2.17.0/go.mod h1:WUqCTXDAPqkcYhjUawowutjzCEgx6Ri8P51E9SozpZw= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -569,8 +570,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -617,14 +618,13 @@ golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -638,8 +638,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -669,14 +669,12 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -692,8 +690,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -796,5 +794,5 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= -ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.8 h1:7+m7grF+jGAR7sL7kmmP6C5l/MDzzLDp6pRNWalH3TM= +ucl.lmika.dev v0.1.8/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/plugins.go b/plugins.go index 036062e..b4d772d 100644 --- a/plugins.go +++ b/plugins.go @@ -36,6 +36,7 @@ func (p *PluginFilters) Module() ucl.Module { Name: "d", Builtins: map[string]ucl.BuiltinHandler{ "filter": p.filterBuiltin, + "analyze": p.filterAnalyze, "map-lines": p.mapLinesBuiltin, }, } @@ -80,6 +81,45 @@ func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (a return nil, nil } +// filterBuiltin implements `d:analyze LABEL PROC`. PROC receives the filter input as its only +// argument and its result is displayed as the result in the status bar. +func (p *PluginFilters) filterAnalyze(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + label string + proc ucl.Invokable + ) + + if err := args.Bind(&label, &proc); err != nil { + return nil, fmt.Errorf("d:analyze: expected LABEL and PROC: %w", err) + } + if strings.TrimSpace(label) == "" { + return nil, errors.New("d:analyze: label must not be empty") + } + if proc.IsNil() { + return nil, errors.New("d:analyze: PROC must not be nil") + } + + p.filters[pluginKeyPrefix+slugify(label)] = TextProcessor{ + Label: label, + Analyze: func(ctx context.Context, input string) (string, error) { + res, err := proc.Invoke(ctx, input) + if err != nil { + return "", err + } + + switch v := res.(type) { + case nil: + return "", errors.New("filter returned no value") + case string: + return v, nil + default: + return fmt.Sprint(v), nil + } + }, + } + return nil, nil +} + // mapLinesBuiltin implements `d:map-lines INPUT PROC` which applies a transform for each line of the input. func (p *PluginFilters) mapLinesBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { var (