fconvert 0.1.0

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.
Files changed (46) hide show
  1. package/README.md +54 -0
  2. package/assets/manifest.json +15 -0
  3. package/bun.lock +26 -0
  4. package/dist/convert +0 -0
  5. package/package.json +25 -0
  6. package/src/artifacts/artifact.ts +21 -0
  7. package/src/artifacts/file.ts +11 -0
  8. package/src/bundle/manifest.ts +10 -0
  9. package/src/bundle/platform.ts +10 -0
  10. package/src/bundle/resolve.ts +88 -0
  11. package/src/cli/commands/convert.ts +105 -0
  12. package/src/cli/commands/doctor.ts +16 -0
  13. package/src/cli/commands/formats.ts +20 -0
  14. package/src/cli/commands/handlers.ts +37 -0
  15. package/src/cli/commands/route.ts +91 -0
  16. package/src/cli/main.ts +69 -0
  17. package/src/cli/parse.ts +127 -0
  18. package/src/core/config.ts +16 -0
  19. package/src/core/errors.ts +18 -0
  20. package/src/core/logger.ts +38 -0
  21. package/src/core/types.ts +77 -0
  22. package/src/diagnostics/doctor.ts +43 -0
  23. package/src/executor/executor.ts +177 -0
  24. package/src/executor/workspace.ts +47 -0
  25. package/src/formats/aliases.ts +8 -0
  26. package/src/formats/common.ts +69 -0
  27. package/src/formats/detect.ts +62 -0
  28. package/src/formats/registry.ts +64 -0
  29. package/src/handlers/base.ts +61 -0
  30. package/src/handlers/bridges/binary.ts +22 -0
  31. package/src/handlers/bridges/text.ts +127 -0
  32. package/src/handlers/exec.ts +47 -0
  33. package/src/handlers/native/ffmpeg.ts +77 -0
  34. package/src/handlers/native/imagemagick.ts +65 -0
  35. package/src/handlers/native/pandoc.ts +66 -0
  36. package/src/handlers/native/sevenzip.ts +115 -0
  37. package/src/handlers/registry.ts +68 -0
  38. package/src/planner/costs.ts +111 -0
  39. package/src/planner/deadends.ts +31 -0
  40. package/src/planner/explain.ts +12 -0
  41. package/src/planner/graph.ts +68 -0
  42. package/src/planner/search.ts +77 -0
  43. package/test/e2e/engine-bridge.test.ts +52 -0
  44. package/test/unit/detect.test.ts +15 -0
  45. package/test/unit/planner.test.ts +46 -0
  46. package/tsconfig.json +31 -0
@@ -0,0 +1,68 @@
1
+ import type { BundleResolver } from "../bundle/resolve.ts";
2
+ import type { Logger } from "../core/logger.ts";
3
+ import type { Workspace } from "../executor/workspace.ts";
4
+ import type { ConversionHandler, HandlerContext } from "./base.ts";
5
+ import { BinaryBridgeHandler } from "./bridges/binary.ts";
6
+ import { TextBridgeHandler } from "./bridges/text.ts";
7
+ import { FfmpegHandler } from "./native/ffmpeg.ts";
8
+ import { ImageMagickHandler } from "./native/imagemagick.ts";
9
+ import { PandocHandler } from "./native/pandoc.ts";
10
+ import { SevenZipHandler } from "./native/sevenzip.ts";
11
+
12
+ export class HandlerRegistry {
13
+ private readonly handlers: ConversionHandler[];
14
+ private readonly availability = new Map<string, boolean>();
15
+
16
+ constructor(handlers?: ConversionHandler[]) {
17
+ this.handlers =
18
+ handlers ??
19
+ [
20
+ new FfmpegHandler(),
21
+ new ImageMagickHandler(),
22
+ new PandocHandler(),
23
+ new SevenZipHandler(),
24
+ new TextBridgeHandler(),
25
+ new BinaryBridgeHandler(),
26
+ ];
27
+ }
28
+
29
+ async init(ctx: {
30
+ workspace: Workspace;
31
+ bundle: BundleResolver;
32
+ logger: Logger;
33
+ timeoutMs?: number;
34
+ }): Promise<void> {
35
+ const handlerContext: HandlerContext = {
36
+ workspace: ctx.workspace,
37
+ bundle: ctx.bundle,
38
+ logger: ctx.logger,
39
+ timeoutMs: ctx.timeoutMs,
40
+ };
41
+
42
+ for (const handler of this.handlers) {
43
+ if (handler.init) {
44
+ await handler.init(handlerContext);
45
+ }
46
+ if (handler.isAvailable) {
47
+ this.availability.set(handler.name, await handler.isAvailable(handlerContext));
48
+ } else {
49
+ this.availability.set(handler.name, true);
50
+ }
51
+ }
52
+ }
53
+
54
+ all(): ConversionHandler[] {
55
+ return [...this.handlers];
56
+ }
57
+
58
+ availableHandlers(): ConversionHandler[] {
59
+ return this.handlers.filter((handler) => this.availability.get(handler.name) ?? true);
60
+ }
61
+
62
+ status(): Array<{ name: string; available: boolean }> {
63
+ return this.handlers.map((handler) => ({
64
+ name: handler.name,
65
+ available: this.availability.get(handler.name) ?? true,
66
+ }));
67
+ }
68
+ }
@@ -0,0 +1,111 @@
1
+ import type { FileFormat } from "../core/types.ts";
2
+ import type { ConversionHandler, HandlerRule } from "../handlers/base.ts";
3
+
4
+ const BASE_STEP_COST = 100;
5
+ const LOSSY_PENALTY = 80;
6
+ const ANY_INPUT_PENALTY = 140;
7
+ const BINARY_PENALTY = 140;
8
+
9
+ const CATEGORY_MATRIX = new Map<string, number>([
10
+ ["text|document", 20],
11
+ ["document|text", 20],
12
+ ["image|vector", 35],
13
+ ["vector|image", 35],
14
+ ["image|document", 40],
15
+ ["document|image", 40],
16
+ ["audio|video", 30],
17
+ ["video|audio", 30],
18
+ ["data|text", 25],
19
+ ["text|data", 25],
20
+ ["data|code", 35],
21
+ ["code|data", 35],
22
+ ["document|presentation", 30],
23
+ ["presentation|document", 30],
24
+ ["document|spreadsheet", 35],
25
+ ["spreadsheet|document", 35],
26
+ ["font|binary", 40],
27
+ ["binary|font", 40],
28
+ ]);
29
+
30
+ function sameCategory(from: FileFormat, to: FileFormat): boolean {
31
+ return from.category.some((category) => to.category.includes(category));
32
+ }
33
+
34
+ function minCategoryTransition(from: FileFormat, to: FileFormat): number {
35
+ let min = Number.POSITIVE_INFINITY;
36
+ for (const source of from.category) {
37
+ for (const target of to.category) {
38
+ if (source === target) {
39
+ return 0;
40
+ }
41
+ const matrix = CATEGORY_MATRIX.get(`${source}|${target}`);
42
+ if (typeof matrix === "number") {
43
+ min = Math.min(min, matrix);
44
+ }
45
+ }
46
+ }
47
+
48
+ if (Number.isFinite(min)) {
49
+ return min;
50
+ }
51
+
52
+ if (from.category.includes("archive") || to.category.includes("archive")) {
53
+ return 90;
54
+ }
55
+ if (from.category.includes("binary") || to.category.includes("binary")) {
56
+ return BINARY_PENALTY;
57
+ }
58
+ return 120;
59
+ }
60
+
61
+ export function categoriesCompatibleStrict(from: FileFormat, to: FileFormat): boolean {
62
+ if (sameCategory(from, to)) {
63
+ return true;
64
+ }
65
+
66
+ const compatibleClusters: string[][] = [
67
+ ["text", "document", "data", "code"],
68
+ ["image", "vector", "document"],
69
+ ["audio", "video"],
70
+ ];
71
+
72
+ for (const cluster of compatibleClusters) {
73
+ const hasFrom = from.category.some((value) => cluster.includes(value));
74
+ const hasTo = to.category.some((value) => cluster.includes(value));
75
+ if (hasFrom && hasTo) {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ if (to.category.includes("archive") || from.category.includes("archive")) {
81
+ return true;
82
+ }
83
+
84
+ return false;
85
+ }
86
+
87
+ export function edgeCost(
88
+ handler: ConversionHandler,
89
+ rule: HandlerRule,
90
+ from: FileFormat,
91
+ to: FileFormat,
92
+ ): number {
93
+ let cost = BASE_STEP_COST;
94
+ cost += rule.cost ?? 0;
95
+ cost += handler.capabilities.startupCost ?? 0;
96
+ cost += minCategoryTransition(from, to);
97
+
98
+ if (rule.from === "*" || handler.capabilities.supportsAnyInput) {
99
+ cost += ANY_INPUT_PENALTY;
100
+ }
101
+ if (rule.lossless === false) {
102
+ cost += LOSSY_PENALTY;
103
+ }
104
+ if (to.category.includes("binary")) {
105
+ cost += BINARY_PENALTY;
106
+ }
107
+
108
+ const priority = handler.capabilities.priority ?? 0;
109
+ cost += Math.max(0, 100 - priority);
110
+ return Math.max(1, cost);
111
+ }
@@ -0,0 +1,31 @@
1
+ import type { PlannedEdge, PlannedRoute } from "../handlers/base.ts";
2
+
3
+ function edgeSignature(edge: PlannedEdge): string {
4
+ return `${edge.handler.name}:${edge.from.id}->${edge.to.id}`;
5
+ }
6
+
7
+ function prefixSignature(edges: PlannedEdge[]): string {
8
+ return edges.map((edge) => edgeSignature(edge)).join("|");
9
+ }
10
+
11
+ export class DeadEndTracker {
12
+ private readonly deadPrefixes = new Set<string>();
13
+
14
+ markDeadPrefix(edges: PlannedEdge[]): void {
15
+ if (edges.length === 0) {
16
+ return;
17
+ }
18
+ this.deadPrefixes.add(prefixSignature(edges));
19
+ }
20
+
21
+ routeBlocked(route: PlannedRoute): boolean {
22
+ for (let index = 0; index < route.edges.length; index += 1) {
23
+ const prefix = route.edges.slice(0, index + 1);
24
+ const signature = prefixSignature(prefix);
25
+ if (this.deadPrefixes.has(signature)) {
26
+ return true;
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ import type { PlannedRoute } from "../handlers/base.ts";
2
+
3
+ export function explainRoute(route: PlannedRoute): string[] {
4
+ return route.edges.map((edge) => `${edge.handler.name}:${edge.from.id}->${edge.to.id}`);
5
+ }
6
+
7
+ export function describeRoutes(routes: PlannedRoute[], limit = 5): string[] {
8
+ return routes.slice(0, limit).map((route, index) => {
9
+ const path = explainRoute(route).join(" | ");
10
+ return `${index + 1}. cost=${route.totalCost} ${path}`;
11
+ });
12
+ }
@@ -0,0 +1,68 @@
1
+ import type { FileFormat } from "../core/types.ts";
2
+ import { FormatRegistry } from "../formats/registry.ts";
3
+ import type { ConversionHandler, PlannedEdge } from "../handlers/base.ts";
4
+ import { categoriesCompatibleStrict, edgeCost } from "./costs.ts";
5
+
6
+ export class ConversionGraph {
7
+ private readonly adjacency = new Map<string, PlannedEdge[]>();
8
+
9
+ constructor(
10
+ private readonly registry: FormatRegistry,
11
+ handlers: ConversionHandler[],
12
+ strict: boolean,
13
+ ) {
14
+ this.build(handlers, strict);
15
+ }
16
+
17
+ private addEdge(edge: PlannedEdge): void {
18
+ const list = this.adjacency.get(edge.from.id) ?? [];
19
+ list.push(edge);
20
+ this.adjacency.set(edge.from.id, list);
21
+ }
22
+
23
+ private resolveRuleTargets(ruleValue: string | "*", allFormats: FileFormat[]): FileFormat[] {
24
+ if (ruleValue === "*") {
25
+ return allFormats;
26
+ }
27
+
28
+ const format = this.registry.getById(ruleValue);
29
+ if (!format) {
30
+ return [];
31
+ }
32
+ return [format];
33
+ }
34
+
35
+ private build(handlers: ConversionHandler[], strict: boolean): void {
36
+ const allFormats = this.registry.all();
37
+
38
+ for (const handler of handlers) {
39
+ for (const rule of handler.rules) {
40
+ const fromCandidates = this.resolveRuleTargets(rule.from, allFormats);
41
+ const toCandidates = this.resolveRuleTargets(rule.to, allFormats);
42
+
43
+ for (const from of fromCandidates) {
44
+ for (const to of toCandidates) {
45
+ if (from.id === to.id) {
46
+ continue;
47
+ }
48
+ if (strict && !categoriesCompatibleStrict(from, to)) {
49
+ continue;
50
+ }
51
+
52
+ this.addEdge({
53
+ handler,
54
+ from,
55
+ to,
56
+ rule,
57
+ cost: edgeCost(handler, rule, from, to),
58
+ });
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ outgoing(formatId: string): PlannedEdge[] {
66
+ return this.adjacency.get(formatId) ?? [];
67
+ }
68
+ }
@@ -0,0 +1,77 @@
1
+ import type { PlanOptions } from "../core/types.ts";
2
+ import type { PlannedRoute } from "../handlers/base.ts";
3
+ import { ConversionGraph } from "./graph.ts";
4
+
5
+ interface SearchState {
6
+ node: string;
7
+ cost: number;
8
+ edges: PlannedRoute["edges"];
9
+ visited: Set<string>;
10
+ }
11
+
12
+ function routeSignature(route: PlannedRoute): string {
13
+ return route.edges
14
+ .map((edge) => `${edge.handler.name}:${edge.from.id}->${edge.to.id}`)
15
+ .join("|");
16
+ }
17
+
18
+ export function findRoutes(
19
+ graph: ConversionGraph,
20
+ fromId: string,
21
+ toId: string,
22
+ options: PlanOptions,
23
+ ): PlannedRoute[] {
24
+ const queue: SearchState[] = [
25
+ {
26
+ node: fromId,
27
+ cost: 0,
28
+ edges: [],
29
+ visited: new Set([fromId]),
30
+ },
31
+ ];
32
+ const routes: PlannedRoute[] = [];
33
+ const seen = new Set<string>();
34
+
35
+ while (queue.length > 0 && routes.length < options.maxCandidates) {
36
+ queue.sort((a, b) => a.cost - b.cost);
37
+ const current = queue.shift();
38
+ if (!current) {
39
+ break;
40
+ }
41
+
42
+ if (current.node === toId && current.edges.length > 0) {
43
+ const route: PlannedRoute = {
44
+ edges: current.edges,
45
+ totalCost: current.cost,
46
+ };
47
+ const signature = routeSignature(route);
48
+ if (!seen.has(signature)) {
49
+ routes.push(route);
50
+ seen.add(signature);
51
+ }
52
+ continue;
53
+ }
54
+
55
+ if (current.edges.length >= options.maxSteps) {
56
+ continue;
57
+ }
58
+
59
+ for (const edge of graph.outgoing(current.node)) {
60
+ if (current.visited.has(edge.to.id)) {
61
+ continue;
62
+ }
63
+
64
+ const nextVisited = new Set(current.visited);
65
+ nextVisited.add(edge.to.id);
66
+
67
+ queue.push({
68
+ node: edge.to.id,
69
+ cost: current.cost + edge.cost,
70
+ edges: [...current.edges, edge],
71
+ visited: nextVisited,
72
+ });
73
+ }
74
+ }
75
+
76
+ return routes.sort((a, b) => a.totalCost - b.totalCost);
77
+ }
@@ -0,0 +1,52 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { BundleResolver } from "../../src/bundle/resolve.ts";
6
+ import { buildPlanOptions } from "../../src/core/config.ts";
7
+ import { ConsoleLogger } from "../../src/core/logger.ts";
8
+ import { ConversionEngine } from "../../src/executor/executor.ts";
9
+ import { FormatRegistry } from "../../src/formats/registry.ts";
10
+ import { BinaryBridgeHandler } from "../../src/handlers/bridges/binary.ts";
11
+ import { TextBridgeHandler } from "../../src/handlers/bridges/text.ts";
12
+ import { HandlerRegistry } from "../../src/handlers/registry.ts";
13
+
14
+ test("engine converts txt to json via text bridge", async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), "convert-e2e-"));
16
+ const inputPath = join(dir, "input.txt");
17
+ const outputPath = join(dir, "output.json");
18
+
19
+ await writeFile(inputPath, "hello");
20
+
21
+ const formats = new FormatRegistry();
22
+ const handlers = new HandlerRegistry([new TextBridgeHandler(), new BinaryBridgeHandler()]);
23
+ const bundle = new BundleResolver();
24
+ const logger = new ConsoleLogger(false, true);
25
+ const engine = new ConversionEngine(formats, handlers, bundle, logger);
26
+
27
+ const inputFormat = formats.getById("txt");
28
+ const outputFormat = formats.getById("json");
29
+ if (!inputFormat || !outputFormat) {
30
+ throw new Error("Required test formats missing");
31
+ }
32
+
33
+ const result = await engine.execute({
34
+ inputPath,
35
+ outputPath,
36
+ inputFormat,
37
+ outputFormat,
38
+ strict: false,
39
+ keepTemp: false,
40
+ plan: buildPlanOptions({
41
+ strict: false,
42
+ maxSteps: 4,
43
+ maxCandidates: 5,
44
+ }),
45
+ });
46
+
47
+ const output = await readFile(outputPath, "utf8");
48
+ expect(result.route.edges.length).toBeGreaterThan(0);
49
+ expect(output.includes('"data": "hello"')).toBeTrue();
50
+
51
+ await rm(dir, { recursive: true, force: true });
52
+ });
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "bun:test";
2
+ import { detectInputFormat, resolveOutputFormat } from "../../src/formats/detect.ts";
3
+ import { FormatRegistry } from "../../src/formats/registry.ts";
4
+
5
+ test("detects input format from extension", () => {
6
+ const registry = new FormatRegistry();
7
+ const format = detectInputFormat("/tmp/photo.jpg", undefined, registry);
8
+ expect(format.id).toBe("jpeg");
9
+ });
10
+
11
+ test("resolves output format from --to", () => {
12
+ const registry = new FormatRegistry();
13
+ const format = resolveOutputFormat(undefined, "pdf", registry);
14
+ expect(format.id).toBe("pdf");
15
+ });
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "bun:test";
2
+ import { FormatRegistry } from "../../src/formats/registry.ts";
3
+ import { ConversionGraph } from "../../src/planner/graph.ts";
4
+ import { findRoutes } from "../../src/planner/search.ts";
5
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult } from "../../src/handlers/base.ts";
6
+
7
+ class MockHandler implements ConversionHandler {
8
+ readonly name: string;
9
+ readonly capabilities = { startupCost: 0, priority: 80, deterministic: true };
10
+ readonly rules;
11
+
12
+ constructor(name: string, rules: ConversionHandler["rules"]) {
13
+ this.name = name;
14
+ this.rules = rules;
15
+ }
16
+
17
+ async convert(_ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
18
+ return {
19
+ output: {
20
+ kind: "file",
21
+ path: request.outputPath,
22
+ },
23
+ };
24
+ }
25
+ }
26
+
27
+ test("planner prefers meaningful route over wildcard", () => {
28
+ const registry = new FormatRegistry();
29
+
30
+ const handlers: ConversionHandler[] = [
31
+ new MockHandler("image-audio-bridge", [{ from: "png", to: "wav", cost: 10 }]),
32
+ new MockHandler("audio-encoder", [{ from: "wav", to: "mp3", cost: 10 }]),
33
+ new MockHandler("wildcard", [{ from: "*", to: "*", cost: 400 }]),
34
+ ];
35
+
36
+ const graph = new ConversionGraph(registry, handlers, false);
37
+ const routes = findRoutes(graph, "png", "mp3", {
38
+ strict: false,
39
+ maxSteps: 4,
40
+ maxCandidates: 5,
41
+ });
42
+
43
+ expect(routes.length).toBeGreaterThan(0);
44
+ const first = routes[0];
45
+ expect(first?.edges.map((edge) => edge.handler.name)).toEqual(["image-audio-bridge", "audio-encoder"]);
46
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "types": ["bun"],
10
+ "allowJs": true,
11
+
12
+ // Bundler mode
13
+ "moduleResolution": "bundler",
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noEmit": true,
17
+
18
+ // Best practices
19
+ "strict": true,
20
+ "skipLibCheck": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedIndexedAccess": true,
23
+ "noImplicitOverride": true,
24
+
25
+ // Some stricter flags (disabled by default)
26
+ "noUnusedLocals": true,
27
+ "noUnusedParameters": true,
28
+ "noPropertyAccessFromIndexSignature": false
29
+ },
30
+ "include": ["src/**/*.ts", "test/**/*.ts"]
31
+ }