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.
package/src/uno/uno.ts ADDED
@@ -0,0 +1,226 @@
1
+ import {
2
+ Command,
3
+ HttpClient,
4
+ HttpClientRequest,
5
+ type HttpClientResponse,
6
+ } from "@effect/platform";
7
+ import { Effect, flow, Layer, Match, Schedule, Schema, String } from "effect";
8
+ import type { OutputPath } from "../shared";
9
+ import { decodeUnoResponse } from "./uno-response";
10
+ import { parseXML } from "./xml-parser";
11
+
12
+ /**
13
+ * Error thrown when the uno server fails to start or when a conversion fails.
14
+ */
15
+ export class UnoError extends Schema.TaggedError<UnoError>()("UnoError", {
16
+ message: Schema.String,
17
+ reason: Schema.Literal(
18
+ "StartFailed",
19
+ "Unknown",
20
+ "InputFileNotFound",
21
+ "BadOutputExtension",
22
+ "MethodNotFound",
23
+ "PermissionDenied",
24
+ ),
25
+ cause: Schema.optional(Schema.Unknown),
26
+ }) {}
27
+
28
+ export const testRunning = Effect.fn(function* (url: string) {
29
+ return yield* Effect.tryPromise({
30
+ try: () =>
31
+ fetch(url, {
32
+ method: "POST",
33
+ body: `<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>`,
34
+ }),
35
+ catch: (e) =>
36
+ new UnoError({
37
+ reason: "StartFailed",
38
+ message: globalThis.String(e),
39
+ }),
40
+ }).pipe(
41
+ Effect.flatMap((res) =>
42
+ res.ok
43
+ ? Effect.void
44
+ : new UnoError({
45
+ reason: "StartFailed",
46
+ message: "Server not ready",
47
+ }),
48
+ ),
49
+ );
50
+ });
51
+
52
+ export const ensureRunning = flow(
53
+ testRunning,
54
+ Effect.retry({
55
+ times: 40,
56
+ schedule: Schedule.spaced("250 millis"),
57
+ }),
58
+ );
59
+
60
+ /**
61
+ * UnoServer service. The default implementation will try to spawn a new `unoserver` process.
62
+ */
63
+ export class UnoServer extends Effect.Service<UnoServer>()(
64
+ "libre-convert-effect/index/UnoServer",
65
+ {
66
+ scoped: Effect.gen(function* () {
67
+ const acquire = Effect.gen(function* () {
68
+ const process = yield* Command.start(Command.make("unoserver"));
69
+
70
+ yield* ensureRunning(`http://localhost:2003/RPC2`).pipe(
71
+ Effect.catchAll(
72
+ () =>
73
+ new UnoError({
74
+ reason: "StartFailed",
75
+ message: "Failed to start server",
76
+ }),
77
+ ),
78
+ );
79
+
80
+ return process;
81
+ });
82
+
83
+ yield* Effect.acquireRelease(acquire, (process) =>
84
+ Effect.ignore(process.kill()),
85
+ );
86
+ return {
87
+ /**
88
+ * The url of the uno server
89
+ */
90
+ url: "http://localhost:2003/RPC2",
91
+ };
92
+ }),
93
+ },
94
+ ) {
95
+ /**
96
+ * Note that while any url can be passed, libreoffice will expect the given files
97
+ * to be on disk and will write them to disk, so to be actually useful the server
98
+ * should probably utilize the same file system as your process.
99
+ *
100
+ * This url can be useful if the uno server is running inside a docker (with a mounted file system)
101
+ */
102
+ static remoteWithURL = (url: string) =>
103
+ Layer.scoped(
104
+ UnoServer,
105
+ Effect.gen(function* () {
106
+ yield* ensureRunning(url);
107
+ return UnoServer.make({
108
+ url,
109
+ });
110
+ }),
111
+ );
112
+ /**
113
+ * Static layer that expects the uno server to be running on localhost:2003
114
+ */
115
+ static Remote = UnoServer.remoteWithURL("http://localhost:2003/RPC2");
116
+ }
117
+
118
+ const convertRequest = (input: string, output: string) => {
119
+ const body = `<?xml version="1.0"?>
120
+ <methodCall>
121
+ <methodName>convert</methodName>
122
+ <params>
123
+ <param><value><string>${input}</string></value></param>
124
+ <param><value><nil/></value></param>
125
+ <param><value><string>${output}</string></value></param>
126
+ </params>
127
+ </methodCall>
128
+ `;
129
+
130
+ return HttpClientRequest.post("").pipe(HttpClientRequest.bodyText(body));
131
+ };
132
+
133
+ const compareRequest = (input: string, output: string) => {
134
+ const body = `<?xml version="1.0"?>
135
+ <methodCall>
136
+ <methodName>compare</methodName>
137
+ <params>
138
+ <param><value><string>${input}</string></value></param>
139
+ <param><value><nil/></value></param>
140
+ <param><value><string>${output}</string></value></param>
141
+ </params>
142
+ </methodCall>
143
+ `;
144
+
145
+ return HttpClientRequest.post("").pipe(HttpClientRequest.bodyText(body));
146
+ };
147
+
148
+ const getReason = Match.type<{ faultCode: number; faultString: string }>().pipe(
149
+ Match.when(
150
+ { faultCode: 1, faultString: String.includes("does not exist") },
151
+ () => "InputFileNotFound" as const,
152
+ ),
153
+ Match.when(
154
+ {
155
+ faultCode: 1,
156
+ faultString: String.includes("Unknown export file type"),
157
+ },
158
+ () => "BadOutputExtension" as const,
159
+ ),
160
+ Match.when(
161
+ {
162
+ faultCode: 1,
163
+ faultString: String.includes("is not supported"),
164
+ },
165
+ () => "MethodNotFound" as const,
166
+ ),
167
+ Match.when(
168
+ {
169
+ faultCode: 1,
170
+ faultString: String.includes("PermissionError"),
171
+ },
172
+ () => "PermissionDenied" as const,
173
+ ),
174
+ Match.when(
175
+ {
176
+ faultCode: 1,
177
+ faultString: String.includes("Permission denied"),
178
+ },
179
+ () => "PermissionDenied" as const,
180
+ ),
181
+ Match.orElse(() => "Unknown" as const),
182
+ );
183
+
184
+ const handleResponse = (response: HttpClientResponse.HttpClientResponse) =>
185
+ response.text.pipe(
186
+ Effect.map(parseXML),
187
+ Effect.flatMap(decodeUnoResponse),
188
+ Effect.flatMap((decoded) => {
189
+ if ("faultString" in decoded) {
190
+ return new UnoError({
191
+ reason: getReason(decoded),
192
+ message: decoded.faultString,
193
+ cause: decoded,
194
+ });
195
+ }
196
+ return Effect.succeed(response);
197
+ }),
198
+ );
199
+
200
+ export class UnoClient extends Effect.Service<UnoClient>()(
201
+ "libre-convert-effect/uno/UnoClient",
202
+ {
203
+ scoped: Effect.gen(function* () {
204
+ const { url } = yield* UnoServer;
205
+
206
+ const client = (yield* HttpClient.HttpClient).pipe(
207
+ HttpClient.mapRequestInput(flow(HttpClientRequest.prependUrl(url))),
208
+ HttpClient.filterStatusOk,
209
+ );
210
+
211
+ return {
212
+ client,
213
+ convert(input: string, output: OutputPath) {
214
+ return client
215
+ .execute(convertRequest(input, output))
216
+ .pipe(Effect.flatMap(handleResponse));
217
+ },
218
+ compare(input: string, output: string) {
219
+ return client
220
+ .execute(compareRequest(input, output))
221
+ .pipe(Effect.flatMap(handleResponse));
222
+ },
223
+ };
224
+ }),
225
+ },
226
+ ) {}
@@ -0,0 +1,16 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+
3
+ const parser = new XMLParser();
4
+
5
+ export function parseXML(input: string): unknown {
6
+ return parser.parse(input);
7
+ }
8
+
9
+ if (import.meta.vitest) {
10
+ const { it, expect } = import.meta.vitest;
11
+ it("should parse xml", () => {
12
+ const xml = `<root><child>text</child></root>`;
13
+ const result = parseXML(xml);
14
+ expect(result).toEqual({ root: { child: "text" } });
15
+ });
16
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "lib": ["es2023"],
5
+ "moduleDetection": "force",
6
+ "module": "preserve",
7
+ "moduleResolution": "bundler",
8
+ "resolveJsonModule": true,
9
+ "strict": true,
10
+ "noUnusedLocals": true,
11
+ "declaration": true,
12
+ "emitDeclarationOnly": true,
13
+ "esModuleInterop": true,
14
+ "isolatedModules": true,
15
+ "verbatimModuleSyntax": true,
16
+ "skipLibCheck": true,
17
+ "types": ["vitest/importMeta"],
18
+ "plugins": [
19
+ {
20
+ "name": "@effect/language-service"
21
+ }
22
+ ]
23
+ },
24
+ "include": ["src"]
25
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ exports: true,
5
+ dts: true,
6
+ define: {
7
+ "import.meta.vitest": "undefined",
8
+ },
9
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["src/**/*.test.ts"],
7
+ includeSource: ["src/**/*.ts"],
8
+ },
9
+ });