Compare commits
2 commits
main
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f221939e3 | |||
| 7c9023ae20 |
20 changed files with 140 additions and 1314 deletions
|
|
@ -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
1
.idea/dequoter.iml
generated
|
|
@ -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" />
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -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
193
app.go
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
83
frontend/wailsjs/runtime/runtime.d.ts
vendored
83
frontend/wailsjs/runtime/runtime.d.ts
vendored
|
|
@ -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
|
||||
|
|
@ -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
29
go.mod
|
|
@ -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
58
go.sum
|
|
@ -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
14
main.go
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
242
plugins.go
242
plugins.go
|
|
@ -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), "-"), "-")
|
||||
}
|
||||
412
plugins_test.go
412
plugins_test.go
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
129
textfilters.go
129
textfilters.go
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue