fconvert 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/convert +0 -0
- package/dist/fconvert-picker +0 -0
- package/package.json +12 -3
- package/src/cli/commands/convert.ts +16 -9
- package/src/cli/main.ts +5 -5
- package/src/cli/output-picker.ts +135 -0
- package/test/unit/output-picker.test.ts +15 -0
- package/tools/format-picker/go.mod +24 -0
- package/tools/format-picker/go.sum +37 -0
- package/tools/format-picker/main.go +274 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fconvert
|
|
2
2
|
|
|
3
3
|
Fast local CLI conversion tool with graph-based anything-to-anything routing.
|
|
4
4
|
|
|
@@ -12,13 +12,16 @@ bun run convert formats
|
|
|
12
12
|
## Commands
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
bun run convert <input>
|
|
15
|
+
bun run convert <input> [output]
|
|
16
16
|
bun run convert route <input> --to <format>
|
|
17
17
|
bun run convert formats
|
|
18
18
|
bun run convert handlers
|
|
19
19
|
bun run convert doctor
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
If you omit output and `--to`, an interactive Bubble Tea fuzzy picker appears so you can select the output format.
|
|
23
|
+
The output file defaults to the input basename with the selected extension.
|
|
24
|
+
|
|
22
25
|
## Useful flags
|
|
23
26
|
|
|
24
27
|
```bash
|
|
@@ -42,6 +45,7 @@ bun run build
|
|
|
42
45
|
```
|
|
43
46
|
|
|
44
47
|
Compiled binary is written to `dist/convert`.
|
|
48
|
+
The Bubble Tea picker helper is written to `dist/fconvert-picker`.
|
|
45
49
|
|
|
46
50
|
## Project structure
|
|
47
51
|
|
package/dist/convert
CHANGED
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fconvert",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Fast local CLI conversion tool with graph-based anything-to-anything routing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli/main.ts",
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
"convert": "bun run src/cli/main.ts",
|
|
10
10
|
"test": "bun test",
|
|
11
11
|
"typecheck": "bunx tsc --noEmit",
|
|
12
|
-
"build": "bun build src/cli/main.ts --compile --outfile dist/convert"
|
|
12
|
+
"build:cli": "bun build src/cli/main.ts --compile --outfile dist/convert",
|
|
13
|
+
"build:picker": "go build -C tools/format-picker -o ../../dist/fconvert-picker .",
|
|
14
|
+
"build": "bun run build:cli && bun run build:picker"
|
|
13
15
|
},
|
|
14
16
|
"bin": {
|
|
17
|
+
"fconvert": "dist/convert",
|
|
15
18
|
"convert": "dist/convert"
|
|
16
19
|
},
|
|
17
20
|
"devDependencies": {
|
|
@@ -20,6 +23,12 @@
|
|
|
20
23
|
"peerDependencies": {
|
|
21
24
|
"typescript": "^5"
|
|
22
25
|
},
|
|
23
|
-
"keywords": [
|
|
26
|
+
"keywords": [
|
|
27
|
+
"convert",
|
|
28
|
+
"converter",
|
|
29
|
+
"ffmpeg",
|
|
30
|
+
"pandoc",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
24
33
|
"license": "MIT"
|
|
25
34
|
}
|
|
@@ -4,7 +4,8 @@ import { BundleResolver } from "../../bundle/resolve.ts";
|
|
|
4
4
|
import { buildPlanOptions } from "../../core/config.ts";
|
|
5
5
|
import { CliError, ExitCode } from "../../core/errors.ts";
|
|
6
6
|
import { ConsoleLogger } from "../../core/logger.ts";
|
|
7
|
-
import type { ConvertSummary } from "../../core/types.ts";
|
|
7
|
+
import type { ConvertSummary, FileFormat } from "../../core/types.ts";
|
|
8
|
+
import { buildDefaultOutputPath, pickOutputFormatInteractive } from "../output-picker.ts";
|
|
8
9
|
import { ConversionEngine } from "../../executor/executor.ts";
|
|
9
10
|
import { detectInputFormat, resolveOutputFormat } from "../../formats/detect.ts";
|
|
10
11
|
import { FormatRegistry } from "../../formats/registry.ts";
|
|
@@ -42,24 +43,30 @@ export async function runConvertCommand(input: {
|
|
|
42
43
|
|
|
43
44
|
const inputPath = input.positionals[0];
|
|
44
45
|
const positionalOutput = input.positionals[1];
|
|
45
|
-
const
|
|
46
|
+
const explicitOutputPath = input.options.output ?? positionalOutput;
|
|
46
47
|
|
|
47
48
|
if (!inputPath) {
|
|
48
|
-
throw new CliError(
|
|
49
|
+
throw new CliError(
|
|
50
|
+
"Usage: convert <input> [output] [--from fmt] [--to fmt]",
|
|
51
|
+
ExitCode.InvalidArgs,
|
|
52
|
+
);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
const formats = new FormatRegistry();
|
|
56
|
+
const inputFormat = detectInputFormat(inputPath, input.options.from, formats);
|
|
57
|
+
let outputFormat: FileFormat;
|
|
58
|
+
if (explicitOutputPath || input.options.to) {
|
|
59
|
+
outputFormat = resolveOutputFormat(explicitOutputPath, input.options.to, formats);
|
|
60
|
+
} else {
|
|
61
|
+
outputFormat = await pickOutputFormatInteractive(formats, inputPath);
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
const outputPath = explicitOutputPath ?? buildDefaultOutputPath(inputPath, outputFormat);
|
|
65
|
+
|
|
55
66
|
if ((await outputExists(outputPath)) && !input.options.force) {
|
|
56
67
|
throw new CliError(`Output exists: ${outputPath}. Use --force to overwrite.`, ExitCode.InvalidArgs);
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
const formats = new FormatRegistry();
|
|
60
|
-
const inputFormat = detectInputFormat(inputPath, input.options.from, formats);
|
|
61
|
-
const outputFormat = resolveOutputFormat(outputPath, input.options.to, formats);
|
|
62
|
-
|
|
63
70
|
const bundle = new BundleResolver();
|
|
64
71
|
const handlers = new HandlerRegistry();
|
|
65
72
|
const engine = new ConversionEngine(formats, handlers, bundle, logger);
|
package/src/cli/main.ts
CHANGED
|
@@ -9,11 +9,11 @@ import { CliError, ExitCode } from "../core/errors.ts";
|
|
|
9
9
|
import { parseArgs } from "./parse.ts";
|
|
10
10
|
|
|
11
11
|
function printUsage(): void {
|
|
12
|
-
console.log("
|
|
13
|
-
console.log("
|
|
14
|
-
console.log("
|
|
15
|
-
console.log("
|
|
16
|
-
console.log("
|
|
12
|
+
console.log("fconvert <input> [output] [--from fmt] [--to fmt]");
|
|
13
|
+
console.log("fconvert route <input> --to <format>");
|
|
14
|
+
console.log("fconvert formats");
|
|
15
|
+
console.log("fconvert handlers");
|
|
16
|
+
console.log("fconvert doctor");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
async function main(): Promise<void> {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
5
|
+
import { CliError, ExitCode } from "../core/errors.ts";
|
|
6
|
+
import type { FileFormat } from "../core/types.ts";
|
|
7
|
+
import { FormatRegistry } from "../formats/registry.ts";
|
|
8
|
+
|
|
9
|
+
interface PickerPayload {
|
|
10
|
+
prompt: string;
|
|
11
|
+
query: string;
|
|
12
|
+
options: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
extension: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function withExecutableSuffix(path: string): string {
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
return path.endsWith(".exe") ? path : `${path}.exe`;
|
|
22
|
+
}
|
|
23
|
+
return path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
await access(path, fsConstants.F_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolvePickerBinary(): Promise<string | undefined> {
|
|
36
|
+
const envPath = process.env.FCONVERT_PICKER_BIN;
|
|
37
|
+
if (envPath && (await pathExists(envPath))) {
|
|
38
|
+
return envPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const argv0Dir = dirname(Bun.argv[0] ?? process.cwd());
|
|
42
|
+
const argv1Dir = dirname(Bun.argv[1] ?? process.cwd());
|
|
43
|
+
|
|
44
|
+
const candidates = [
|
|
45
|
+
join(process.cwd(), "dist", "fconvert-picker"),
|
|
46
|
+
join(argv0Dir, "fconvert-picker"),
|
|
47
|
+
join(argv0Dir, "dist", "fconvert-picker"),
|
|
48
|
+
join(argv1Dir, "..", "dist", "fconvert-picker"),
|
|
49
|
+
].map(withExecutableSuffix);
|
|
50
|
+
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
if (await pathExists(candidate)) {
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inPath = Bun.which("fconvert-picker");
|
|
58
|
+
if (inPath) {
|
|
59
|
+
return inPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildDefaultOutputPath(inputPath: string, outputFormat: FileFormat): string {
|
|
66
|
+
const inputDirectory = dirname(inputPath);
|
|
67
|
+
const inputName = basename(inputPath);
|
|
68
|
+
const currentExtension = extname(inputName);
|
|
69
|
+
const baseName = currentExtension.length > 0 ? inputName.slice(0, -currentExtension.length) : inputName;
|
|
70
|
+
return join(inputDirectory, `${baseName}.${outputFormat.extension}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pickOutputFormatInteractive(
|
|
74
|
+
registry: FormatRegistry,
|
|
75
|
+
inputPath: string,
|
|
76
|
+
): Promise<FileFormat> {
|
|
77
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
78
|
+
throw new CliError(
|
|
79
|
+
"No output format provided and terminal is not interactive. Pass <output> or --to.",
|
|
80
|
+
ExitCode.InvalidArgs,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pickerBinary = await resolvePickerBinary();
|
|
85
|
+
if (!pickerBinary) {
|
|
86
|
+
throw new CliError(
|
|
87
|
+
"Output picker binary not found. Build it with 'bun run build:picker' or pass --to.",
|
|
88
|
+
ExitCode.EnvironmentError,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "fconvert-picker-"));
|
|
93
|
+
const optionsPath = join(tempRoot, "options.json");
|
|
94
|
+
const resultPath = join(tempRoot, "result.txt");
|
|
95
|
+
|
|
96
|
+
const payload: PickerPayload = {
|
|
97
|
+
prompt: "output format",
|
|
98
|
+
query: extname(inputPath).replace(/^\./, ""),
|
|
99
|
+
options: registry.all().map((format) => ({
|
|
100
|
+
id: format.id,
|
|
101
|
+
name: format.name,
|
|
102
|
+
extension: format.extension,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await writeFile(optionsPath, JSON.stringify(payload), "utf8");
|
|
108
|
+
|
|
109
|
+
const processHandle = Bun.spawn(
|
|
110
|
+
[pickerBinary, "--options", optionsPath, "--result", resultPath],
|
|
111
|
+
{
|
|
112
|
+
stdin: "inherit",
|
|
113
|
+
stdout: "inherit",
|
|
114
|
+
stderr: "inherit",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const exitCode = await processHandle.exited;
|
|
119
|
+
if (exitCode === 130) {
|
|
120
|
+
throw new CliError("Output format selection cancelled", ExitCode.InvalidArgs);
|
|
121
|
+
}
|
|
122
|
+
if (exitCode !== 0) {
|
|
123
|
+
throw new CliError(`Output picker exited with code ${exitCode}`, ExitCode.InternalError);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const chosenId = (await readFile(resultPath, "utf8")).trim();
|
|
127
|
+
const format = registry.getById(chosenId);
|
|
128
|
+
if (!format) {
|
|
129
|
+
throw new CliError(`Picker returned unknown format: ${chosenId}`, ExitCode.InternalError);
|
|
130
|
+
}
|
|
131
|
+
return format;
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { buildDefaultOutputPath } from "../../src/cli/output-picker.ts";
|
|
3
|
+
|
|
4
|
+
test("buildDefaultOutputPath replaces extension with selected format", () => {
|
|
5
|
+
const path = buildDefaultOutputPath("/tmp/sample.input.txt", {
|
|
6
|
+
id: "json",
|
|
7
|
+
name: "JSON",
|
|
8
|
+
extension: "json",
|
|
9
|
+
mime: "application/json",
|
|
10
|
+
category: ["data", "text"],
|
|
11
|
+
aliases: [],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(path).toBe("/tmp/sample.input.json");
|
|
15
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module github.com/timmy6942025/convert/tools/format-picker
|
|
2
|
+
|
|
3
|
+
go 1.22
|
|
4
|
+
|
|
5
|
+
require github.com/charmbracelet/bubbletea v1.3.4
|
|
6
|
+
|
|
7
|
+
require (
|
|
8
|
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
9
|
+
github.com/charmbracelet/lipgloss v1.0.0 // indirect
|
|
10
|
+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
|
11
|
+
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
12
|
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
13
|
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
14
|
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
15
|
+
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
16
|
+
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
17
|
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
18
|
+
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
19
|
+
github.com/muesli/termenv v0.15.2 // indirect
|
|
20
|
+
github.com/rivo/uniseg v0.4.7 // indirect
|
|
21
|
+
golang.org/x/sync v0.11.0 // indirect
|
|
22
|
+
golang.org/x/sys v0.30.0 // indirect
|
|
23
|
+
golang.org/x/text v0.3.8 // indirect
|
|
24
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
2
|
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
3
|
+
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
|
4
|
+
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
|
5
|
+
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
|
|
6
|
+
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
|
|
7
|
+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
|
8
|
+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
|
9
|
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
10
|
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
11
|
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
12
|
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
13
|
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
14
|
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
15
|
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
16
|
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
17
|
+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
18
|
+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
19
|
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
20
|
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
21
|
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
22
|
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
23
|
+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
24
|
+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
25
|
+
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
|
26
|
+
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
|
27
|
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
28
|
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
29
|
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
30
|
+
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
|
31
|
+
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
32
|
+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
33
|
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
34
|
+
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
35
|
+
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
36
|
+
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
|
37
|
+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"flag"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type option struct {
|
|
15
|
+
ID string `json:"id"`
|
|
16
|
+
Name string `json:"name"`
|
|
17
|
+
Extension string `json:"extension"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type payload struct {
|
|
21
|
+
Prompt string `json:"prompt"`
|
|
22
|
+
Query string `json:"query"`
|
|
23
|
+
Options []option `json:"options"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type scoredOption struct {
|
|
27
|
+
option option
|
|
28
|
+
score int
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type model struct {
|
|
32
|
+
prompt string
|
|
33
|
+
query string
|
|
34
|
+
all []option
|
|
35
|
+
filtered []option
|
|
36
|
+
cursor int
|
|
37
|
+
selected *option
|
|
38
|
+
cancelled bool
|
|
39
|
+
width int
|
|
40
|
+
height int
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (m model) Init() tea.Cmd {
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
48
|
+
switch msg := msg.(type) {
|
|
49
|
+
case tea.WindowSizeMsg:
|
|
50
|
+
m.width = msg.Width
|
|
51
|
+
m.height = msg.Height
|
|
52
|
+
return m, nil
|
|
53
|
+
case tea.KeyMsg:
|
|
54
|
+
switch msg.String() {
|
|
55
|
+
case "ctrl+c", "esc":
|
|
56
|
+
m.cancelled = true
|
|
57
|
+
return m, tea.Quit
|
|
58
|
+
case "enter":
|
|
59
|
+
if len(m.filtered) == 0 {
|
|
60
|
+
return m, nil
|
|
61
|
+
}
|
|
62
|
+
selected := m.filtered[m.cursor]
|
|
63
|
+
m.selected = &selected
|
|
64
|
+
return m, tea.Quit
|
|
65
|
+
case "up", "k", "ctrl+p":
|
|
66
|
+
if m.cursor > 0 {
|
|
67
|
+
m.cursor--
|
|
68
|
+
}
|
|
69
|
+
return m, nil
|
|
70
|
+
case "down", "j", "ctrl+n":
|
|
71
|
+
if m.cursor < len(m.filtered)-1 {
|
|
72
|
+
m.cursor++
|
|
73
|
+
}
|
|
74
|
+
return m, nil
|
|
75
|
+
case "backspace", "ctrl+h":
|
|
76
|
+
if len(m.query) == 0 {
|
|
77
|
+
return m, nil
|
|
78
|
+
}
|
|
79
|
+
m.query = m.query[:len(m.query)-1]
|
|
80
|
+
m.refilter()
|
|
81
|
+
return m, nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if msg.Type == tea.KeyRunes {
|
|
85
|
+
m.query += msg.String()
|
|
86
|
+
m.refilter()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return m, nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func (m model) View() string {
|
|
94
|
+
var builder strings.Builder
|
|
95
|
+
builder.WriteString(fmt.Sprintf("%s > %s\n", m.prompt, m.query))
|
|
96
|
+
|
|
97
|
+
if len(m.filtered) == 0 {
|
|
98
|
+
builder.WriteString(" no matches\n")
|
|
99
|
+
builder.WriteString(" enter to keep typing, esc to cancel")
|
|
100
|
+
return builder.String()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
maxRows := 8
|
|
104
|
+
if m.height > 6 {
|
|
105
|
+
maxRows = m.height - 4
|
|
106
|
+
}
|
|
107
|
+
if maxRows < 4 {
|
|
108
|
+
maxRows = 4
|
|
109
|
+
}
|
|
110
|
+
if maxRows > len(m.filtered) {
|
|
111
|
+
maxRows = len(m.filtered)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
start := 0
|
|
115
|
+
if m.cursor >= maxRows {
|
|
116
|
+
start = m.cursor - maxRows + 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
end := start + maxRows
|
|
120
|
+
if end > len(m.filtered) {
|
|
121
|
+
end = len(m.filtered)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for index := start; index < end; index++ {
|
|
125
|
+
prefix := " "
|
|
126
|
+
if index == m.cursor {
|
|
127
|
+
prefix = "> "
|
|
128
|
+
}
|
|
129
|
+
item := m.filtered[index]
|
|
130
|
+
builder.WriteString(fmt.Sprintf("%s%-10s .%-6s %s\n", prefix, item.ID, item.Extension, item.Name))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
builder.WriteString(" enter select esc cancel")
|
|
134
|
+
return builder.String()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func (m *model) refilter() {
|
|
138
|
+
query := strings.ToLower(strings.TrimSpace(m.query))
|
|
139
|
+
scored := make([]scoredOption, 0, len(m.all))
|
|
140
|
+
|
|
141
|
+
for _, item := range m.all {
|
|
142
|
+
score := fuzzyScore(query, item)
|
|
143
|
+
if score < 0 {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
scored = append(scored, scoredOption{option: item, score: score})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sort.SliceStable(scored, func(i, j int) bool {
|
|
150
|
+
if scored[i].score == scored[j].score {
|
|
151
|
+
return scored[i].option.ID < scored[j].option.ID
|
|
152
|
+
}
|
|
153
|
+
return scored[i].score > scored[j].score
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
m.filtered = make([]option, 0, len(scored))
|
|
157
|
+
for _, item := range scored {
|
|
158
|
+
m.filtered = append(m.filtered, item.option)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if m.cursor >= len(m.filtered) {
|
|
162
|
+
m.cursor = len(m.filtered) - 1
|
|
163
|
+
}
|
|
164
|
+
if m.cursor < 0 {
|
|
165
|
+
m.cursor = 0
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func fuzzyScore(query string, item option) int {
|
|
170
|
+
if query == "" {
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
candidate := strings.ToLower(item.ID + " " + item.Extension + " " + item.Name)
|
|
175
|
+
queryRunes := []rune(query)
|
|
176
|
+
candidateRunes := []rune(candidate)
|
|
177
|
+
|
|
178
|
+
score := 0
|
|
179
|
+
queryIndex := 0
|
|
180
|
+
streak := 0
|
|
181
|
+
lastMatch := -2
|
|
182
|
+
|
|
183
|
+
for index, value := range candidateRunes {
|
|
184
|
+
if queryIndex >= len(queryRunes) {
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
if value != queryRunes[queryIndex] {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
score += 10
|
|
192
|
+
if index == lastMatch+1 {
|
|
193
|
+
streak++
|
|
194
|
+
score += 4 * streak
|
|
195
|
+
} else {
|
|
196
|
+
streak = 1
|
|
197
|
+
}
|
|
198
|
+
lastMatch = index
|
|
199
|
+
queryIndex++
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if queryIndex != len(queryRunes) {
|
|
203
|
+
return -1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
score -= len(candidateRunes) / 6
|
|
207
|
+
if strings.HasPrefix(strings.ToLower(item.ID), query) {
|
|
208
|
+
score += 15
|
|
209
|
+
}
|
|
210
|
+
return score
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func readPayload(path string) (payload, error) {
|
|
214
|
+
raw, err := os.ReadFile(path)
|
|
215
|
+
if err != nil {
|
|
216
|
+
return payload{}, err
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var data payload
|
|
220
|
+
if err := json.Unmarshal(raw, &data); err != nil {
|
|
221
|
+
return payload{}, err
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if data.Prompt == "" {
|
|
225
|
+
data.Prompt = "output format"
|
|
226
|
+
}
|
|
227
|
+
return data, nil
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func main() {
|
|
231
|
+
optionsPath := flag.String("options", "", "path to JSON options payload")
|
|
232
|
+
resultPath := flag.String("result", "", "path to output selected id")
|
|
233
|
+
flag.Parse()
|
|
234
|
+
|
|
235
|
+
if *optionsPath == "" || *resultPath == "" {
|
|
236
|
+
fmt.Fprintln(os.Stderr, "usage: fconvert-picker --options <file> --result <file>")
|
|
237
|
+
os.Exit(2)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
data, err := readPayload(*optionsPath)
|
|
241
|
+
if err != nil {
|
|
242
|
+
fmt.Fprintln(os.Stderr, err)
|
|
243
|
+
os.Exit(1)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
picker := model{
|
|
247
|
+
prompt: data.Prompt,
|
|
248
|
+
query: data.Query,
|
|
249
|
+
all: data.Options,
|
|
250
|
+
}
|
|
251
|
+
picker.refilter()
|
|
252
|
+
|
|
253
|
+
program := tea.NewProgram(picker)
|
|
254
|
+
result, err := program.Run()
|
|
255
|
+
if err != nil {
|
|
256
|
+
fmt.Fprintln(os.Stderr, err)
|
|
257
|
+
os.Exit(1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
finalModel, ok := result.(model)
|
|
261
|
+
if !ok {
|
|
262
|
+
fmt.Fprintln(os.Stderr, "unexpected picker model type")
|
|
263
|
+
os.Exit(1)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if finalModel.cancelled || finalModel.selected == nil {
|
|
267
|
+
os.Exit(130)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if err := os.WriteFile(*resultPath, []byte(finalModel.selected.ID+"\n"), 0o644); err != nil {
|
|
271
|
+
fmt.Fprintln(os.Stderr, err)
|
|
272
|
+
os.Exit(1)
|
|
273
|
+
}
|
|
274
|
+
}
|