fconvert 0.1.4 → 0.1.6

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/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.4",
3
+ "version": "0.1.6",
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",
@@ -7,10 +7,13 @@ import { ConsoleLogger } from "../../core/logger.ts";
7
7
  import type { ConvertSummary, FileFormat } from "../../core/types.ts";
8
8
  import { buildDefaultOutputPath, pickOutputFormatInteractive } from "../output-picker.ts";
9
9
  import { ConversionEngine } from "../../executor/executor.ts";
10
+ import { Workspace } from "../../executor/workspace.ts";
10
11
  import { detectInputFormat, resolveOutputFormat } from "../../formats/detect.ts";
11
12
  import { FormatRegistry } from "../../formats/registry.ts";
12
13
  import { HandlerRegistry } from "../../handlers/registry.ts";
14
+ import { ConversionGraph } from "../../planner/graph.ts";
13
15
  import { explainRoute } from "../../planner/explain.ts";
16
+ import { findReachableFormats } from "../../planner/search.ts";
14
17
 
15
18
  async function outputExists(path: string): Promise<boolean> {
16
19
  try {
@@ -21,6 +24,37 @@ async function outputExists(path: string): Promise<boolean> {
21
24
  }
22
25
  }
23
26
 
27
+ async function findSelectableOutputFormats(input: {
28
+ formats: FormatRegistry;
29
+ inputFormat: FileFormat;
30
+ bundle: BundleResolver;
31
+ logger: ConsoleLogger;
32
+ strict: boolean;
33
+ maxSteps: number;
34
+ timeoutMs?: number;
35
+ }): Promise<FileFormat[]> {
36
+ const workspace = await Workspace.create("convert-probe-");
37
+ const probeHandlers = new HandlerRegistry();
38
+ try {
39
+ await probeHandlers.init({
40
+ workspace,
41
+ bundle: input.bundle,
42
+ logger: input.logger,
43
+ timeoutMs: input.timeoutMs,
44
+ });
45
+
46
+ const graph = new ConversionGraph(input.formats, probeHandlers.availableHandlers(), input.strict);
47
+ const reachable = findReachableFormats(graph, input.inputFormat.id, Math.max(1, input.maxSteps));
48
+ const candidates = input.formats
49
+ .all()
50
+ .filter((format) => reachable.has(format.id))
51
+ .sort((a, b) => a.id.localeCompare(b.id));
52
+ return candidates;
53
+ } finally {
54
+ await workspace.cleanup(false);
55
+ }
56
+ }
57
+
24
58
  export async function runConvertCommand(input: {
25
59
  positionals: string[];
26
60
  options: {
@@ -54,11 +88,21 @@ export async function runConvertCommand(input: {
54
88
 
55
89
  const formats = new FormatRegistry();
56
90
  const inputFormat = detectInputFormat(inputPath, input.options.from, formats);
91
+ const bundle = new BundleResolver();
57
92
  let outputFormat: FileFormat;
58
93
  if (explicitOutputPath || input.options.to) {
59
94
  outputFormat = resolveOutputFormat(explicitOutputPath, input.options.to, formats);
60
95
  } else {
61
- outputFormat = await pickOutputFormatInteractive(formats, inputPath);
96
+ const selectableFormats = await findSelectableOutputFormats({
97
+ formats,
98
+ inputFormat,
99
+ bundle,
100
+ logger,
101
+ strict: input.options.strict,
102
+ maxSteps: input.options.maxSteps,
103
+ timeoutMs: input.options.timeoutMs,
104
+ });
105
+ outputFormat = await pickOutputFormatInteractive(selectableFormats, inputPath);
62
106
  }
63
107
 
64
108
  const outputPath = explicitOutputPath ?? buildDefaultOutputPath(inputPath, outputFormat);
@@ -67,7 +111,6 @@ export async function runConvertCommand(input: {
67
111
  throw new CliError(`Output exists: ${outputPath}. Use --force to overwrite.`, ExitCode.InvalidArgs);
68
112
  }
69
113
 
70
- const bundle = new BundleResolver();
71
114
  const handlers = new HandlerRegistry();
72
115
  const engine = new ConversionEngine(formats, handlers, bundle, logger);
73
116
 
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
4
4
  import { basename, dirname, extname, join } from "node:path";
5
5
  import { CliError, ExitCode } from "../core/errors.ts";
6
6
  import type { FileFormat } from "../core/types.ts";
7
- import { FormatRegistry } from "../formats/registry.ts";
8
7
 
9
8
  interface PickerPayload {
10
9
  prompt: string;
@@ -87,7 +86,7 @@ export function buildDefaultOutputPath(inputPath: string, outputFormat: FileForm
87
86
  }
88
87
 
89
88
  export async function pickOutputFormatInteractive(
90
- registry: FormatRegistry,
89
+ formats: FileFormat[],
91
90
  inputPath: string,
92
91
  ): Promise<FileFormat> {
93
92
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -97,6 +96,13 @@ export async function pickOutputFormatInteractive(
97
96
  );
98
97
  }
99
98
 
99
+ if (formats.length === 0) {
100
+ throw new CliError(
101
+ "No reachable output formats detected for this input with current handlers.",
102
+ ExitCode.UnsupportedRoute,
103
+ );
104
+ }
105
+
100
106
  const pickerBinary = await resolvePickerBinary();
101
107
  if (!pickerBinary) {
102
108
  throw new CliError(
@@ -113,7 +119,7 @@ export async function pickOutputFormatInteractive(
113
119
  prompt: "output format",
114
120
  query: "",
115
121
  preferred: extname(inputPath).replace(/^\./, ""),
116
- options: registry.all().map((format) => ({
122
+ options: formats.map((format) => ({
117
123
  id: format.id,
118
124
  name: format.name,
119
125
  extension: format.extension,
@@ -141,7 +147,7 @@ export async function pickOutputFormatInteractive(
141
147
  }
142
148
 
143
149
  const chosenId = (await readFile(resultPath, "utf8")).trim();
144
- const format = registry.getById(chosenId);
150
+ const format = formats.find((item) => item.id === chosenId);
145
151
  if (!format) {
146
152
  throw new CliError(`Picker returned unknown format: ${chosenId}`, ExitCode.InternalError);
147
153
  }
@@ -75,3 +75,45 @@ export function findRoutes(
75
75
 
76
76
  return routes.sort((a, b) => a.totalCost - b.totalCost);
77
77
  }
78
+
79
+ interface ReachableState {
80
+ node: string;
81
+ steps: number;
82
+ }
83
+
84
+ export function findReachableFormats(
85
+ graph: ConversionGraph,
86
+ fromId: string,
87
+ maxSteps: number,
88
+ ): Set<string> {
89
+ const reached = new Set<string>();
90
+ const bestSteps = new Map<string, number>();
91
+ const queue: ReachableState[] = [{ node: fromId, steps: 0 }];
92
+
93
+ bestSteps.set(fromId, 0);
94
+
95
+ while (queue.length > 0) {
96
+ const current = queue.shift();
97
+ if (!current) {
98
+ break;
99
+ }
100
+
101
+ if (current.steps >= maxSteps) {
102
+ continue;
103
+ }
104
+
105
+ for (const edge of graph.outgoing(current.node)) {
106
+ const nextSteps = current.steps + 1;
107
+ const known = bestSteps.get(edge.to.id);
108
+ if (typeof known === "number" && known <= nextSteps) {
109
+ continue;
110
+ }
111
+
112
+ bestSteps.set(edge.to.id, nextSteps);
113
+ reached.add(edge.to.id);
114
+ queue.push({ node: edge.to.id, steps: nextSteps });
115
+ }
116
+ }
117
+
118
+ return reached;
119
+ }
@@ -2,11 +2,13 @@ module github.com/timmy6942025/convert/tools/format-picker
2
2
 
3
3
  go 1.22
4
4
 
5
- require github.com/charmbracelet/bubbletea v1.3.4
5
+ require (
6
+ github.com/charmbracelet/bubbletea v1.3.4
7
+ github.com/charmbracelet/lipgloss v1.0.0
8
+ )
6
9
 
7
10
  require (
8
11
  github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9
- github.com/charmbracelet/lipgloss v1.0.0 // indirect
10
12
  github.com/charmbracelet/x/ansi v0.8.0 // indirect
11
13
  github.com/charmbracelet/x/term v0.2.1 // indirect
12
14
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -9,6 +9,7 @@ import (
9
9
  "strings"
10
10
 
11
11
  tea "github.com/charmbracelet/bubbletea"
12
+ "github.com/charmbracelet/lipgloss"
12
13
  )
13
14
 
14
15
  type option struct {
@@ -42,6 +43,33 @@ type model struct {
42
43
  height int
43
44
  }
44
45
 
46
+ var (
47
+ frameStyle = lipgloss.NewStyle().
48
+ Padding(0, 1)
49
+
50
+ headerStyle = lipgloss.NewStyle().
51
+ Bold(true)
52
+
53
+ promptStyle = lipgloss.NewStyle().
54
+ Foreground(lipgloss.AdaptiveColor{Light: "241", Dark: "248"})
55
+
56
+ hintStyle = lipgloss.NewStyle().
57
+ Foreground(lipgloss.AdaptiveColor{Light: "245", Dark: "240"})
58
+
59
+ selectedStyle = lipgloss.NewStyle().
60
+ Bold(true).
61
+ Foreground(lipgloss.AdaptiveColor{Light: "39", Dark: "117"})
62
+
63
+ rowStyle = lipgloss.NewStyle().
64
+ Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "252"})
65
+
66
+ footerStyle = lipgloss.NewStyle().
67
+ Foreground(lipgloss.AdaptiveColor{Light: "245", Dark: "240"})
68
+
69
+ emptyStyle = lipgloss.NewStyle().
70
+ Foreground(lipgloss.AdaptiveColor{Light: "203", Dark: "210"})
71
+ )
72
+
45
73
  func (m model) Init() tea.Cmd {
46
74
  return nil
47
75
  }
@@ -94,15 +122,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94
122
 
95
123
  func (m model) View() string {
96
124
  var builder strings.Builder
97
- builder.WriteString(fmt.Sprintf("%s > %s\n", m.prompt, m.query))
125
+ line := fmt.Sprintf("%s > %s", promptStyle.Render(m.prompt), m.query)
126
+ builder.WriteString(headerStyle.Render(line))
127
+ builder.WriteString("\n")
98
128
  if m.query == "" && m.preferred != "" {
99
- builder.WriteString(fmt.Sprintf(" hint: original extension '.%s' is ranked first\n", m.preferred))
129
+ builder.WriteString(hintStyle.Render(fmt.Sprintf("hint: original extension '.%s' is ranked first", m.preferred)))
130
+ builder.WriteString("\n")
100
131
  }
101
132
 
102
133
  if len(m.filtered) == 0 {
103
- builder.WriteString(" no matches\n")
104
- builder.WriteString(" enter to keep typing, esc to cancel")
105
- return builder.String()
134
+ builder.WriteString(emptyStyle.Render("no matches"))
135
+ builder.WriteString("\n")
136
+ builder.WriteString(footerStyle.Render("keys: type to search, backspace delete, esc cancel"))
137
+ return frameStyle.Render(builder.String())
106
138
  }
107
139
 
108
140
  maxRows := 8
@@ -128,15 +160,26 @@ func (m model) View() string {
128
160
 
129
161
  for index := start; index < end; index++ {
130
162
  prefix := " "
163
+ style := rowStyle
131
164
  if index == m.cursor {
132
165
  prefix = "> "
166
+ style = selectedStyle
133
167
  }
134
168
  item := m.filtered[index]
135
- builder.WriteString(fmt.Sprintf("%s%-10s .%-6s %s\n", prefix, item.ID, item.Extension, item.Name))
169
+ line := fmt.Sprintf("%s%-10s .%-6s %s", prefix, item.ID, item.Extension, item.Name)
170
+ builder.WriteString(style.Render(line))
171
+ builder.WriteString("\n")
136
172
  }
137
173
 
138
- builder.WriteString(" enter select esc cancel")
139
- return builder.String()
174
+ status := fmt.Sprintf("%d shown / %d total", len(m.filtered), len(m.all))
175
+ if len(m.filtered) != len(m.all) {
176
+ status = fmt.Sprintf("%d matches / %d total", len(m.filtered), len(m.all))
177
+ }
178
+ builder.WriteString(footerStyle.Render(status))
179
+ builder.WriteString("\n")
180
+ builder.WriteString(footerStyle.Render("keys: up/down move, enter select, esc cancel"))
181
+
182
+ return frameStyle.Render(builder.String())
140
183
  }
141
184
 
142
185
  func (m *model) refilter() {
@@ -249,6 +292,10 @@ func readPayload(path string) (payload, error) {
249
292
  if data.Prompt == "" {
250
293
  data.Prompt = "output format"
251
294
  }
295
+ if data.Query != "" {
296
+ data.Query = strings.TrimSpace(data.Query)
297
+ }
298
+ data.Prompt = strings.TrimSpace(data.Prompt)
252
299
  return data, nil
253
300
  }
254
301