effect-libreoffice 1.0.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.
@@ -0,0 +1,116 @@
1
+ import * as fs from "node:fs";
2
+ import path from "node:path";
3
+ import { NodeContext, NodeHttpClient } from "@effect/platform-node";
4
+ import { Effect, Layer } from "effect";
5
+ import { Bench } from "tinybench";
6
+ import { LibreOffice } from "../index";
7
+ import { UnoClient, UnoServer } from "../uno/uno";
8
+
9
+ const bench = new Bench({ time: 5000 }); // Run for 5 seconds
10
+
11
+ // Setup paths
12
+ const fixturesDir = path.resolve(__dirname, "../fixtures");
13
+ const outputDir = path.resolve(__dirname, "../tmp/benchmark");
14
+ const inputFile = path.join(fixturesDir, "test.txt");
15
+
16
+ if (!fs.existsSync(outputDir)) {
17
+ fs.mkdirSync(outputDir, { recursive: true });
18
+ }
19
+
20
+ // Ensure input file exists
21
+ if (!fs.existsSync(inputFile)) {
22
+ console.error(`Input file not found: ${inputFile}`);
23
+ process.exit(1);
24
+ }
25
+
26
+ // Setup shared volume for Uno
27
+ const sharedDir = "/tmp/test-convert";
28
+ const sharedInputFile = path.join(sharedDir, "test.txt");
29
+ if (!fs.existsSync(sharedDir)) {
30
+ console.error(`Shared directory not found: ${sharedDir}`);
31
+ process.exit(1);
32
+ }
33
+ fs.copyFileSync(inputFile, sharedInputFile);
34
+
35
+ const outputDefault = path.join(outputDir, "output_default.pdf");
36
+ const outputUno = path.join(sharedDir, "output_uno.pdf");
37
+
38
+ // 1. Default Implementation (CLI)
39
+ // Needs: NodeContext (for FileSystem, Path)
40
+ // AND the resulting effect needs CommandExecutor which comes from NodeContext
41
+ const DefaultEnv = LibreOffice.Default.pipe(
42
+ Layer.provideMerge(NodeContext.layer),
43
+ );
44
+
45
+ const runDefault = Effect.gen(function* () {
46
+ const lo = yield* LibreOffice;
47
+ yield* lo.convertLocalFile(inputFile, outputDefault);
48
+ }).pipe(Effect.provide(DefaultEnv));
49
+
50
+ // 2. Uno Implementation (Remote)
51
+ // Needs: UnoClient, UnoServer.Remote, NodeContext, NodeHttpClient
52
+ const UnoEnv = LibreOffice.Uno.pipe(
53
+ Layer.provide(UnoClient.Default),
54
+ Layer.provide(UnoServer.Remote),
55
+ Layer.provideMerge(NodeHttpClient.layer),
56
+ Layer.provideMerge(NodeContext.layer),
57
+ );
58
+
59
+ const runUno = Effect.gen(function* () {
60
+ const lo = yield* LibreOffice;
61
+ yield* lo.convertLocalFile(sharedInputFile, outputUno);
62
+ }).pipe(Effect.provide(UnoEnv));
63
+
64
+ const runUnoParallel = Effect.gen(function* () {
65
+ const lo = yield* LibreOffice;
66
+ yield* Effect.all(
67
+ Array.from({ length: 6 }, (_, i) =>
68
+ lo.convertLocalFile(
69
+ sharedInputFile,
70
+ path.join(sharedDir, `output_uno_p${i}.pdf`),
71
+ ),
72
+ ),
73
+ { concurrency: "unbounded" },
74
+ );
75
+ }).pipe(Effect.provide(UnoEnv));
76
+
77
+ const main = async () => {
78
+ console.log("Starting benchmark...");
79
+ console.log(`Input: ${inputFile}`);
80
+ console.log("Warming up...");
81
+
82
+ // Verify they work first to avoid silent failures in bench
83
+ try {
84
+ await Effect.runPromise(runDefault);
85
+ console.log("Default implementation verified.");
86
+ } catch (e) {
87
+ console.error("Default implementation failed:", e);
88
+ process.exit(1);
89
+ }
90
+
91
+ try {
92
+ await Effect.runPromise(runUno);
93
+ console.log("Uno implementation verified.");
94
+ } catch (e) {
95
+ console.error("Uno implementation failed:", e);
96
+ process.exit(1);
97
+ }
98
+
99
+ bench
100
+ .add("Default (CLI)", async () => {
101
+ await Effect.runPromise(runDefault);
102
+ })
103
+ .add("Uno (Remote)", async () => {
104
+ await Effect.runPromise(runUno);
105
+ })
106
+ .add("Uno (Remote) - Parallel", async () => {
107
+ await Effect.runPromise(runUnoParallel);
108
+ });
109
+
110
+ console.log("Running benchmark...");
111
+ await bench.run();
112
+
113
+ console.table(bench.table());
114
+ };
115
+
116
+ main().catch(console.error);
@@ -0,0 +1,26 @@
1
+ import { Command } from "@effect/platform";
2
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
+ import { Effect } from "effect";
4
+
5
+ const compose = Command.make("docker", "compose", "up").pipe(
6
+ Command.stdout("inherit"),
7
+ Command.stderr("inherit"),
8
+ );
9
+
10
+ const program = Effect.gen(function* () {
11
+ // const process = yield* Command.start(compose);
12
+
13
+ yield* Effect.acquireRelease(Command.start(compose), (process) =>
14
+ Effect.ignore(process.kill("SIGTERM")),
15
+ );
16
+
17
+ yield* Effect.log("hello");
18
+ yield* Effect.sleep("10 seconds");
19
+ yield* Effect.log("world");
20
+
21
+ // yield* process.kill();
22
+ });
23
+
24
+ NodeRuntime.runMain(
25
+ program.pipe(Effect.scoped, Effect.provide(NodeContext.layer)),
26
+ );
package/src/shared.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { Data } from "effect";
2
+
3
+ export type KnownSupportedOutputFormat =
4
+ | "pdf"
5
+ | "docx"
6
+ | "doc"
7
+ | "odt"
8
+ | "html"
9
+ | "rtf"
10
+ | "epub"
11
+ | "jpg"
12
+ | "txt";
13
+
14
+ export type OutputPath =
15
+ | `${string}.${KnownSupportedOutputFormat}`
16
+ | (string & {});
17
+
18
+ type Reason =
19
+ | "InputFileNotFound"
20
+ | "StartFailed"
21
+ | "Unknown"
22
+ | "BadOutputExtension"
23
+ | "MethodNotFound"
24
+ | "PermissionDenied";
25
+
26
+ export class LibreOfficeError extends Data.TaggedError("LibreOfficeError")<{
27
+ reason: Reason;
28
+ message: string;
29
+ cause?: unknown;
30
+ }> {}
@@ -0,0 +1,61 @@
1
+ import { Path } from "@effect/platform";
2
+ import { NodeContext } from "@effect/platform-node";
3
+ import { assert, expect, it } from "@effect/vitest";
4
+ import { Effect, Layer, Predicate } from "effect";
5
+ import { GenericContainer } from "testcontainers";
6
+ import { LibreOffice, LibreOfficeCmd } from "./index";
7
+
8
+ const UbuntuContainer = Layer.scoped(
9
+ LibreOfficeCmd,
10
+ Effect.gen(function* () {
11
+ const path = yield* Path.Path;
12
+ const dockerfilePath = path.join(process.cwd(), "docker");
13
+
14
+ const container = yield* Effect.acquireRelease(
15
+ Effect.promise(async () => {
16
+ const image = await GenericContainer.fromDockerfile(
17
+ dockerfilePath,
18
+ "ubuntu.Dockerfile",
19
+ ).build("fiws/effect-libreoffice-ubuntu:latest", {
20
+ deleteOnExit: false,
21
+ });
22
+
23
+ return await image
24
+ .withReuse()
25
+ .withCommand(["tail", "-f", "/dev/null"])
26
+ .start();
27
+ }),
28
+ (container) => Effect.promise(() => container.stop()),
29
+ );
30
+
31
+ return ["docker", "exec", container.getId(), "soffice", "--headless"];
32
+ }),
33
+ );
34
+
35
+ const TestLive = LibreOffice.Default.pipe(
36
+ Layer.provideMerge(UbuntuContainer),
37
+ Layer.provideMerge(NodeContext.layer),
38
+ );
39
+
40
+ it.layer(TestLive, { timeout: 120_000 })(
41
+ "Libreoffice (Ubuntu Docker)",
42
+ (it) => {
43
+ it.effect(
44
+ "should fails with source file not found",
45
+ Effect.fn(function* () {
46
+ const libre = yield* LibreOffice;
47
+ const result = yield* libre
48
+ .convertLocalFile("./fixtures/test-not-found.txt", "test.out.pdf")
49
+ .pipe(Effect.flip);
50
+
51
+ expect(result._tag).toBe("LibreOfficeError");
52
+
53
+ assert(
54
+ Predicate.isTagged(result, "LibreOfficeError"),
55
+ "result is not LibreOfficeError",
56
+ );
57
+ expect(result.reason).toBe("InputFileNotFound");
58
+ }),
59
+ );
60
+ },
61
+ );
@@ -0,0 +1,58 @@
1
+ import { Schema } from "effect";
2
+ import { describe, expect, it } from "vitest";
3
+ import { StructFromMembers } from "./schema-utils.js";
4
+
5
+ describe("StructFromMembers", () => {
6
+ it("decodes valid members to struct", () => {
7
+ const Target = StructFromMembers({
8
+ count: Schema.Number,
9
+ message: Schema.String,
10
+ optional: Schema.optional(Schema.String),
11
+ });
12
+
13
+ const input = [
14
+ { name: "count", value: { int: 42 } },
15
+ { name: "message", value: { string: "hello" } },
16
+ ];
17
+
18
+ const result = Schema.decodeSync(Target)(input);
19
+ expect(result).toEqual({ count: 42, message: "hello" });
20
+ });
21
+
22
+ it("encodes struct to members", () => {
23
+ const Target = StructFromMembers({
24
+ count: Schema.Number,
25
+ message: Schema.String,
26
+ });
27
+
28
+ const input = { count: 42, message: "hello" };
29
+ const result = Schema.encodeSync(Target)(input);
30
+
31
+ // Sort to ensure order independence in test, though map preserves order
32
+ const sorted = [...result].sort((a, b) => a.name.localeCompare(b.name));
33
+
34
+ expect(sorted).toEqual([
35
+ { name: "count", value: { int: 42 } },
36
+ { name: "message", value: { string: "hello" } },
37
+ ]);
38
+ });
39
+
40
+ it("fails decode on mismatched types", () => {
41
+ const Target = StructFromMembers({
42
+ count: Schema.Number, // Expects number
43
+ });
44
+
45
+ const input = [{ name: "count", value: { string: "not a number" } }];
46
+
47
+ expect(() => Schema.decodeSync(Target)(input)).toThrow();
48
+ });
49
+
50
+ it("fails decode on missing required fields", () => {
51
+ const Target = StructFromMembers({
52
+ required: Schema.String,
53
+ });
54
+
55
+ const input: unknown[] = [];
56
+ expect(() => Schema.decodeUnknownSync(Target)(input)).toThrow();
57
+ });
58
+ });
@@ -0,0 +1,44 @@
1
+ import { Schema } from "effect";
2
+
3
+ const MemberValue = Schema.Union(
4
+ Schema.Struct({ int: Schema.Number }),
5
+ Schema.Struct({ string: Schema.String }),
6
+ );
7
+
8
+ const Member = Schema.Struct({
9
+ name: Schema.String,
10
+ value: MemberValue,
11
+ });
12
+
13
+ const Members = Schema.Array(Member);
14
+
15
+ export const StructFromMembers = <Fields extends Schema.Struct.Fields>(
16
+ fields: Fields,
17
+ ) =>
18
+ Schema.transform(Members, Schema.Struct(fields), {
19
+ strict: false,
20
+ decode: (input) => {
21
+ const output: Record<string, string | number> = {};
22
+ for (const member of input) {
23
+ if ("int" in member.value) {
24
+ output[member.name] = member.value.int;
25
+ } else if ("string" in member.value) {
26
+ output[member.name] = member.value.string;
27
+ }
28
+ }
29
+ return output;
30
+ },
31
+ encode: (input) => {
32
+ return Object.entries(input).map(([name, value]) => {
33
+ if (typeof value === "number") {
34
+ return { name, value: { int: value } };
35
+ }
36
+ if (typeof value === "string") {
37
+ return { name, value: { string: value } };
38
+ }
39
+ throw new Error(
40
+ `Unsupported value type for member ${name}: ${typeof value}`,
41
+ );
42
+ });
43
+ },
44
+ });
@@ -0,0 +1,102 @@
1
+ import { Schema } from "effect";
2
+
3
+ import { StructFromMembers } from "./schema-utils";
4
+
5
+ /**
6
+ * Schema for fault response
7
+ *
8
+ * ## Original xml
9
+ * ```xml
10
+ * <?xml version='1.0'?>
11
+ * <methodResponse>
12
+ * <fault>
13
+ * <value>
14
+ * <struct>
15
+ * <member>
16
+ * <name>faultCode</name>
17
+ * <value>
18
+ * <int>1</int>
19
+ * </value>
20
+ * </member>
21
+ * <member>
22
+ * <name>faultString</name>
23
+ * <value>
24
+ * <string>&lt;class 'RuntimeError'&gt;:Path /tmp/test-convert/test.txt does not exist.</string>
25
+ * </value>
26
+ * </member>
27
+ * </struct>
28
+ * </value>
29
+ * </fault>
30
+ * </methodResponse>
31
+ * ```
32
+ */
33
+ const UnoFault = Schema.Struct({
34
+ methodResponse: Schema.Struct({
35
+ fault: Schema.Struct({
36
+ value: Schema.Struct({
37
+ struct: Schema.Struct({
38
+ member: StructFromMembers({
39
+ faultCode: Schema.Number,
40
+ faultString: Schema.String,
41
+ }),
42
+ }),
43
+ }),
44
+ }),
45
+ }),
46
+ }).pipe(
47
+ Schema.transform(
48
+ Schema.Struct({
49
+ faultCode: Schema.Number,
50
+ faultString: Schema.String,
51
+ }),
52
+ {
53
+ strict: true,
54
+ decode: (input) => input.methodResponse.fault.value.struct.member,
55
+ encode: (input) => ({
56
+ methodResponse: {
57
+ fault: {
58
+ value: {
59
+ struct: {
60
+ member: input,
61
+ },
62
+ },
63
+ },
64
+ },
65
+ }),
66
+ },
67
+ ),
68
+ Schema.asSchema,
69
+ );
70
+
71
+ /**
72
+ * Schema for empty response (success)
73
+ *
74
+ * ## Original xml
75
+ * ```xml
76
+ * <?xml version='1.0'?>
77
+ * <methodResponse>
78
+ * <params>
79
+ * <param>
80
+ * <value>
81
+ * <nil />
82
+ * </value>
83
+ * </param>
84
+ * </params>
85
+ * </methodResponse>
86
+ * ```
87
+ */
88
+ export const UnoEmpty = Schema.Struct({
89
+ methodResponse: Schema.Struct({
90
+ params: Schema.Struct({
91
+ param: Schema.Struct({
92
+ value: Schema.Struct({
93
+ nil: Schema.String,
94
+ }),
95
+ }),
96
+ }),
97
+ }),
98
+ }).pipe(Schema.asSchema);
99
+
100
+ export const UnoResponse = Schema.Union(UnoFault, UnoEmpty);
101
+
102
+ export const decodeUnoResponse = Schema.decodeUnknown(UnoResponse);
@@ -0,0 +1,192 @@
1
+ import { FileSystem, Path } from "@effect/platform";
2
+ import { NodeContext, NodeHttpClient } from "@effect/platform-node";
3
+ import { assert, expect, it } from "@effect/vitest";
4
+ import { Effect, Layer, Predicate } from "effect";
5
+ import { GenericContainer, Wait } from "testcontainers";
6
+ import { LibreOffice } from "../index";
7
+ import { testRunning, UnoClient, UnoServer } from "./uno";
8
+
9
+ class TempDir extends Effect.Service<TempDir>()(
10
+ "libre-convert-effect/uno/uno.test/TempDir",
11
+ {
12
+ scoped: Effect.gen(function* () {
13
+ const fs = yield* FileSystem.FileSystem;
14
+ return {
15
+ dir: yield* fs.makeTempDirectoryScoped(),
16
+ };
17
+ }),
18
+ },
19
+ ) {}
20
+
21
+ const UnoServerTest = Layer.scoped(
22
+ UnoServer,
23
+ Effect.gen(function* () {
24
+ const { dir: tempDir } = yield* TempDir;
25
+ const container = yield* Effect.acquireRelease(
26
+ Effect.tryPromise(async () => {
27
+ const image = await GenericContainer.fromDockerfile(".").build(
28
+ "fiws/libreoffice-unoserver:latest",
29
+ {
30
+ deleteOnExit: false,
31
+ },
32
+ );
33
+ return await image
34
+ .withExposedPorts(2003)
35
+ .withUser(
36
+ `${process.getuid ? process.getuid() : 1000}:${process.getgid ? process.getgid() : 1000}`,
37
+ )
38
+ .withBindMounts([{ source: tempDir, target: tempDir }])
39
+ .withEnvironment({ HOME: tempDir })
40
+ .withReuse()
41
+ .withWaitStrategy(
42
+ Wait.forLogMessage(/INFO:unoserver:Started./).withStartupTimeout(
43
+ 120_000,
44
+ ),
45
+ )
46
+ .start();
47
+ }),
48
+ (c) => Effect.promise(() => c.stop()),
49
+ );
50
+
51
+ const port = container.getMappedPort(2003);
52
+
53
+ yield* testRunning(`http://localhost:${port}/RPC2`);
54
+ return UnoServer.make({
55
+ url: `http://localhost:${port}/RPC2`,
56
+ });
57
+ }),
58
+ );
59
+
60
+ const UnoLayer = LibreOffice.Uno.pipe(
61
+ Layer.provide(UnoClient.Default),
62
+ Layer.provideMerge(UnoServerTest),
63
+ Layer.provideMerge(TempDir.Default),
64
+ );
65
+
66
+ const TestLive = Layer.provideMerge(
67
+ UnoLayer,
68
+ Layer.merge(NodeContext.layer, NodeHttpClient.layer),
69
+ );
70
+
71
+ it.layer(TestLive, { timeout: 120_000 })("Libreoffice (Uno)", (it) => {
72
+ it.scoped(
73
+ "should convert a file",
74
+ Effect.fn(function* () {
75
+ const fs = yield* FileSystem.FileSystem;
76
+ const path = yield* Path.Path;
77
+ const libre = yield* LibreOffice;
78
+
79
+ const tempDir = yield* fs.makeTempDirectory({
80
+ directory: (yield* TempDir).dir,
81
+ });
82
+ const sourceFile = path.join(tempDir, "test.txt");
83
+ const targetFile = path.join(tempDir, "test.out.pdf");
84
+
85
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
86
+ yield* libre.convertLocalFile(sourceFile, targetFile);
87
+
88
+ const targetContent = yield* fs.readFile(targetFile);
89
+
90
+ const header = new TextDecoder().decode(targetContent.slice(0, 4));
91
+ expect(header).toBe("%PDF");
92
+ }),
93
+ );
94
+
95
+ it.effect(
96
+ "should fails with source file not found",
97
+ Effect.fn(function* () {
98
+ const libre = yield* LibreOffice;
99
+ const result = yield* libre
100
+ .convertLocalFile("./fixtures/test-not-found.txt", "test.out.pdf")
101
+ .pipe(Effect.flip);
102
+ assert(
103
+ Predicate.isTagged(result, "LibreOfficeError"),
104
+ "result is not LibreOfficeError",
105
+ );
106
+ expect(result.reason).toBe("InputFileNotFound");
107
+ }),
108
+ );
109
+
110
+ it.effect(
111
+ "Should work with 2 conversions in parallel",
112
+ Effect.fn(function* () {
113
+ const libre = yield* LibreOffice;
114
+ const fs = yield* FileSystem.FileSystem;
115
+ const path = yield* Path.Path;
116
+
117
+ const tempDir = yield* fs.makeTempDirectory({
118
+ directory: (yield* TempDir).dir,
119
+ });
120
+ const sourceFile = path.join(tempDir, "test.txt");
121
+ const targetFile = path.join(tempDir, "test.out.pdf");
122
+
123
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
124
+
125
+ yield* Effect.all(
126
+ [
127
+ libre.convertLocalFile(sourceFile, targetFile),
128
+ libre.convertLocalFile(sourceFile, targetFile),
129
+ ],
130
+ { concurrency: "unbounded" },
131
+ );
132
+
133
+ const targetContent = yield* fs.readFile(targetFile);
134
+
135
+ const header = new TextDecoder().decode(targetContent.slice(0, 4));
136
+ expect(header).toBe("%PDF");
137
+ }),
138
+ );
139
+
140
+ it.effect(
141
+ "should fail with invalid output extension",
142
+ Effect.fn(function* () {
143
+ const libre = yield* LibreOffice;
144
+ const fs = yield* FileSystem.FileSystem;
145
+ const path = yield* Path.Path;
146
+ const tempDir = yield* fs.makeTempDirectory({
147
+ directory: (yield* TempDir).dir,
148
+ });
149
+ const sourceFile = path.join(tempDir, "test.txt");
150
+ const targetFile = path.join(tempDir, "test.invalidext");
151
+
152
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
153
+
154
+ const result = yield* libre
155
+ .convertLocalFile(sourceFile, targetFile)
156
+ .pipe(Effect.flip);
157
+
158
+ assert(
159
+ Predicate.isTagged(result, "LibreOfficeError"),
160
+ "result is not LibreOfficeError",
161
+ );
162
+ expect(result.reason).toBe("BadOutputExtension");
163
+ }),
164
+ );
165
+
166
+ it.effect(
167
+ "should fail with output as directory",
168
+ Effect.fn(function* () {
169
+ const libre = yield* LibreOffice;
170
+ const fs = yield* FileSystem.FileSystem;
171
+ const path = yield* Path.Path;
172
+ const tempDir = yield* fs.makeTempDirectory({
173
+ directory: (yield* TempDir).dir,
174
+ });
175
+ const sourceFile = path.join(tempDir, "test.txt");
176
+ // Try to write to a directory
177
+ const targetFile = tempDir;
178
+
179
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
180
+
181
+ const result = yield* libre
182
+ .convertLocalFile(sourceFile, targetFile)
183
+ .pipe(Effect.flip);
184
+
185
+ assert(
186
+ Predicate.isTagged(result, "LibreOfficeError"),
187
+ "result is not LibreOfficeError",
188
+ );
189
+ expect(result.reason).toBe("BadOutputExtension");
190
+ }),
191
+ );
192
+ });