Compare commits

..

2 commits

Author SHA1 Message Date
5f221939e3 Add Cmd+L to rerun last command on current line(s)
Co-authored-by: Shelley <shelley@exe.dev>
2026-05-09 12:22:25 +00:00
7c9023ae20 Reuse last prompt value when rerunning last command
Co-authored-by: Shelley <shelley@exe.dev>
2026-05-06 04:13:44 +00:00
20 changed files with 140 additions and 1314 deletions

View file

@ -21,14 +21,12 @@ jobs:
- name: Running Wails doctor
run: |
wails doctor
- name: Installing Dependencies
run: |
npm install
- name: Build
run: |
npm install
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.4
uses: https://lmika.dev/actions/wails-release@v1.0.3
with:
developer-id-cert-base64: ${{ secrets.MACOS_SIGN_P12 }}
developer-id-cert-password: ${{ secrets.MACOS_SIGN_PASSWORD }}
@ -37,7 +35,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},Apps/Dequoter/latest/{filename}'
s3-key: Apps/Dequoter/{version}/{filename}
s3-region: ap-southeast-2
s3-acl: public-read
env:

1
.idea/dequoter.iml generated
View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />

View file

@ -10,33 +10,6 @@ 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, 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|
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.
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
[Download Release](/dequoter-darwin-arm64.zip)
@ -55,4 +28,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.
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

193
app.go
View file

@ -4,10 +4,8 @@ import (
"context"
"fmt"
"log"
"os/exec"
"sort"
"strings"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"lmika.dev/pkg/modash/moslice"
@ -17,23 +15,15 @@ import (
// App struct
type App struct {
store *Store
// mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded.
mu sync.RWMutex
store *Store
uclInst *ucl.Inst
plugins *PluginFilters
ctx context.Context
// baseCtx is the raw Wails context supplied to startup. ctx is derived from it.
baseCtx context.Context
ctx context.Context
}
// 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.Bytes()),
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
uclInst := ucl.New(
ucl.WithModule(builtins.CSV(nil)),
ucl.WithModule(builtins.Fns()),
ucl.WithModule(builtins.FS(nil)),
@ -43,140 +33,17 @@ func newUCLInst(plugins *PluginFilters) *ucl.Inst {
ucl.WithModule(builtins.OS()),
ucl.WithModule(builtins.Strs()),
ucl.WithModule(builtins.Time()),
ucl.WithModule(builtins.URLs()),
)
}
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
app := &App{
return &App{
store: store,
baseCtx: context.Background(),
uclInst: uclInst,
}
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.baseCtx = ctx
dir, err := ensurePluginDir()
if err != nil {
plugins := newPluginFilters()
plugins.recordError(err)
a.setUCLInst(newUCLInst(plugins), plugins)
return
}
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) {
_, 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 := ensurePluginDir()
if err != nil {
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Cannot prepare plugin directory: %v", err),
Error: true,
})
return
}
count := a.loadPluginsFrom(dir)
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "processors-changed")
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) {
_, 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) {
_, plugins, _ := a.current()
if proc, ok := plugins.filters[name]; ok {
return proc, true
}
proc, ok := TextFilters[name]
return proc, ok
a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst)
}
func (a *App) LoadCurrentBuffer() (string, error) {
@ -188,17 +55,7 @@ func (a *App) SaveCurrentBuffer(buffer string) error {
}
func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
_, 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})
}
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 })
@ -206,15 +63,13 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
}
func (a *App) TriggerTextProcess(action string) {
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{
runtime.EventsEmit(a.ctx, "request-text-process", RequestTextProcess{
Action: action,
})
}
func (a *App) PromptUser(label string, resp func(string)) {
_, _, ctx := a.current()
runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) {
runtime.EventsOnce(a.ctx, "prompt-response", func(data ...interface{}) {
if len(data) == 0 {
return
}
@ -224,29 +79,21 @@ func (a *App) PromptUser(label string, resp func(string)) {
}
resp(ans)
})
runtime.EventsEmit(ctx, "prompt-request", PromptRequest{
runtime.EventsEmit(a.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)
filter, ok := TextFilters[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(ctx, span.Text)
outRes, err := filter(a.ctx, span.Text)
if err != nil {
return TextSpan{}, err
}
@ -259,14 +106,14 @@ func (a *App) ProcessText(req ProcessTextRequest) {
}, nil
})
if err != nil {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true,
})
return
}
runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{
runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{
Output: resp,
})
}
@ -281,25 +128,25 @@ func (a *App) ProcessText(req ProcessTextRequest) {
inBfr.WriteString(span.Text)
}
msg, err := filter.Analyze(ctx, inBfr.String())
msg, err := filter.Analyze(a.ctx, inBfr.String())
if err != nil {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running analysis: %v", err.Error()),
Error: true,
})
return
}
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(a.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(ctx, ans)
filter, err := filter.FilterWithArg.OnConfirm(a.ctx, ans)
if err != nil {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true,
})

View file

@ -20,7 +20,7 @@
</div>
</dialog>
<dialog id="command-dialog" data-controller="commands"
data-action="dq-showcommands@window->commands#showCommands dq-rerunlastcommand@window->commands#rerunLastCommand">
data-action="dq-showcommands@window->commands#showCommands dq-rerunlastcommand@window->commands#rerunLastCommand dq-rerunlastcommand-line@window->commands#rerunLastCommandOnLine">
<div class="dialog-body">
<div class="command-input">
<input data-commands-target="commandInput" type="text" placeholder="Enter command"

View file

@ -46,6 +46,14 @@ export const commandPalette = keymap.of([{
return true;
}
}, {
key: "Cmd-l",
run: () => {
let event = new CustomEvent('dq-rerunlastcommand-line');
window.dispatchEvent(event);
return true;
}
}, {
key: "Cmd-k",
run: (view) => {
const {state} = view;

View file

@ -10,35 +10,21 @@ export class CommandsController extends Controller {
"commandSelect",
];
async connect() {
this._lastCommand = null;
this._options = [];
await this._loadProcessors();
window.runtime.EventsOn("processors-changed", () => {
this._loadProcessors();
});
initialize() {
this._lineModeOnce = false;
}
async _loadProcessors() {
let processors = await ListProcessors();
async connect() {
this._lastCommand = null;
let options = processors.map((processor) => {
let processors = await ListProcessors();
processors.forEach((processor) => {
let option = document.createElement("option");
option.value = processor.name;
option.text = processor.label;
return option;
this.commandSelectTarget.appendChild(option);
});
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);
}
this._options = Array.from(this.commandSelectTarget.options);
}
showCommands(ev) {
@ -65,6 +51,7 @@ export class CommandsController extends Controller {
runCommand(ev) {
ev.preventDefault();
this._promptController()?.clearUseLastValue();
textProcessor.runTextCommand(this.commandSelectTarget.value);
this._lastCommand = this.commandSelectTarget.value;
@ -78,7 +65,21 @@ export class CommandsController extends Controller {
return;
}
textProcessor.runTextCommand(this._lastCommand);
this._promptController()?.useLastValueForNextPrompt();
const lineMode = this._lineModeOnce;
this._lineModeOnce = false;
textProcessor.runTextCommand(this._lastCommand, { lineMode });
}
rerunLastCommandOnLine(ev) {
this._lineModeOnce = true;
this.rerunLastCommand(ev);
}
_promptController() {
const el = document.getElementById("prompt-dialog");
if (!el) return null;
return this.application.getControllerForElementAndIdentifier(el, "prompt");
}
_filterOptions(filterText) {

View file

@ -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;
}
}

View file

@ -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(),

View file

@ -10,8 +10,6 @@ export function ProcessText(arg1:main.ProcessTextRequest):Promise<void>;
export function PromptUser(arg1:string,arg2:any):Promise<void>;
export function ReloadPlugins():Promise<void>;
export function SaveCurrentBuffer(arg1:string):Promise<void>;
export function TriggerTextProcess(arg1:string):Promise<void>;

View file

@ -18,10 +18,6 @@ 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);
}

View file

@ -246,85 +246,4 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
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<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [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<void>;
// [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<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [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<void>;
export function ResolveFilePaths(files: File[]): void

View file

@ -48,10 +48,6 @@ 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);
}
@ -239,60 +235,4 @@ 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);
}

29
go.mod
View file

@ -1,19 +1,18 @@
module dequoter
go 1.25.0
go 1.25
require (
github.com/google/uuid v1.6.0
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/wailsapp/wails/v2 v2.17.0
github.com/wailsapp/wails/v2 v2.10.2
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.8
ucl.lmika.dev v0.1.3
)
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
@ -24,7 +23,6 @@ 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
@ -54,14 +52,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.15.3 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // 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.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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
@ -73,7 +71,8 @@ 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.22 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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
@ -81,11 +80,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.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
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
)
// replace github.com/wailsapp/wails/v2 v2.10.2 => /Users/leonmika/go/pkg/mod

58
go.sum
View file

@ -11,8 +11,6 @@ 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=
@ -102,8 +100,6 @@ 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=
@ -375,10 +371,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.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/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/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=
@ -402,11 +398,12 @@ 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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
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/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/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=
@ -516,10 +513,12 @@ 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.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/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/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=
@ -570,8 +569,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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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/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=
@ -618,13 +617,14 @@ 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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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/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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/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,12 +669,14 @@ 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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@ -690,8 +692,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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
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/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=
@ -794,5 +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.8 h1:7+m7grF+jGAR7sL7kmmP6C5l/MDzzLDp6pRNWalH3TM=
ucl.lmika.dev v0.1.8/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU=
ucl.lmika.dev v0.1.3 h1:Y2m+ORnLMA8boQmkXpW41VbkR3bwmnZjDLGVTYbZQic=
ucl.lmika.dev v0.1.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU=

14
main.go
View file

@ -37,20 +37,9 @@ 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.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
_, _, ctx := app.current()
rt.Quit(ctx)
rt.Quit(app.ctx)
})
if runtime.GOOS == "darwin" {
@ -68,7 +57,6 @@ func main() {
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
OnDomReady: app.domReady,
Bind: []interface{}{
app,
},

View file

@ -1,242 +0,0 @@
package main
import (
"bufio"
"bytes"
"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 {
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,
"analyze": p.filterAnalyze,
"map-lines": p.mapLinesBuiltin,
},
}
}
// 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
}
// 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 (
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.
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 under macOS Application Support.
func pluginDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
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]+`)
// 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), "-"), "-")
}

View file

@ -1,412 +0,0 @@
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 }`)
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()
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")
}
})
}
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())
}
})
}

View file

@ -4,8 +4,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
@ -13,7 +11,6 @@ import (
"sort"
"strconv"
"strings"
"unicode/utf8"
"text/template"
@ -126,32 +123,6 @@ 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) {
@ -186,64 +157,6 @@ 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) {
@ -260,48 +173,6 @@ 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) {

View file

@ -1,92 +0,0 @@
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")
}
})
}
}