effect-libreoffice 1.0.0 → 1.0.5
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/LICENSE +7 -0
- package/README.md +10 -6
- package/dist/index.d.mts +98 -0
- package/dist/index.mjs +289 -0
- package/package.json +4 -1
- package/.github/workflows/ci.yml +0 -40
- package/.vscode/settings.json +0 -3
- package/Dockerfile +0 -12
- package/biome.json +0 -43
- package/compose.yml +0 -19
- package/docker/ubuntu.Dockerfile +0 -3
- package/examples/cloud-run/compose.yml +0 -5
- package/examples/cloud-run/uno.Dockerfile +0 -9
- package/fixtures/test.txt +0 -7
- package/src/index.test.ts +0 -126
- package/src/index.ts +0 -180
- package/src/misc/benchmark.ts +0 -116
- package/src/misc/scratchpad.ts +0 -26
- package/src/shared.ts +0 -30
- package/src/ubuntu-docker.test.ts +0 -61
- package/src/uno/schema-utils.test.ts +0 -58
- package/src/uno/schema-utils.ts +0 -44
- package/src/uno/uno-response.ts +0 -102
- package/src/uno/uno.test.ts +0 -192
- package/src/uno/uno.ts +0 -226
- package/src/uno/xml-parser.ts +0 -16
- package/tsconfig.json +0 -25
- package/tsdown.config.ts +0 -9
- package/vitest.config.ts +0 -9
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright 2025 Filip Weiss
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# effect-libreoffice
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/effect-libreoffice)
|
|
4
|
+
[](https://github.com/fiws/effect-libreoffice/blob/main/LICENSE)
|
|
5
|
+
[](https://effect.website/)
|
|
6
|
+
|
|
3
7
|
A Effect-based wrapper for LibreOffice, providing multiple strategies for document conversion.
|
|
4
8
|
|
|
5
9
|
## Implementations
|
|
@@ -11,12 +15,12 @@ This library offers two distinct implementations for interacting with LibreOffic
|
|
|
11
15
|
|
|
12
16
|
### Comparison
|
|
13
17
|
|
|
14
|
-
| Feature | LibreOfficeCmd (Default) | UnoClient (Uno)
|
|
15
|
-
| :-------------- | :--------------------------------------- |
|
|
16
|
-
| **Method** | Spawns a new process for each conversion | Connects to a long-running server
|
|
17
|
-
| **Performance** | Slower (~440ms/file) | Fast (~60ms/file)
|
|
18
|
-
| **Setup** | Requires LibreOffice installed locally | Requires
|
|
19
|
-
| **Best For** | CLI tools, low volume, simple setup | Servers, high volume, performance critical
|
|
18
|
+
| Feature | LibreOfficeCmd (Default) | UnoClient (Uno) |
|
|
19
|
+
| :-------------- | :--------------------------------------- | :--------------------------------------------------------- |
|
|
20
|
+
| **Method** | Spawns a new process for each conversion | Connects to a long-running server |
|
|
21
|
+
| **Performance** | Slower (~440ms/file) | Fast (~60ms/file) |
|
|
22
|
+
| **Setup** | Requires LibreOffice installed locally | Requires [unoserver](https://github.com/unoconv/unoserver) |
|
|
23
|
+
| **Best For** | CLI tools, low volume, simple setup | Servers, high volume, performance critical |
|
|
20
24
|
|
|
21
25
|
## Usage
|
|
22
26
|
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { FileSystem, HttpClient, HttpClientResponse, Path } from "@effect/platform";
|
|
2
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
3
|
+
import * as _effect_platform_Error0 from "@effect/platform/Error";
|
|
4
|
+
import * as _effect_platform_CommandExecutor0 from "@effect/platform/CommandExecutor";
|
|
5
|
+
import * as effect_Types0 from "effect/Types";
|
|
6
|
+
import * as effect_Cause0 from "effect/Cause";
|
|
7
|
+
import * as effect_Scope0 from "effect/Scope";
|
|
8
|
+
import * as _effect_platform_HttpClientError0 from "@effect/platform/HttpClientError";
|
|
9
|
+
import * as effect_ParseResult0 from "effect/ParseResult";
|
|
10
|
+
|
|
11
|
+
//#region src/shared.d.ts
|
|
12
|
+
type KnownSupportedOutputFormat = "pdf" | "docx" | "doc" | "odt" | "html" | "rtf" | "epub" | "jpg" | "txt";
|
|
13
|
+
type OutputPath = `${string}.${KnownSupportedOutputFormat}` | (string & {});
|
|
14
|
+
type Reason = "InputFileNotFound" | "StartFailed" | "Unknown" | "BadOutputExtension" | "MethodNotFound" | "PermissionDenied";
|
|
15
|
+
declare const LibreOfficeError_base: new <A extends Record<string, any> = {}>(args: effect_Types0.Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }) => effect_Cause0.YieldableError & {
|
|
16
|
+
readonly _tag: "LibreOfficeError";
|
|
17
|
+
} & Readonly<A>;
|
|
18
|
+
declare class LibreOfficeError extends LibreOfficeError_base<{
|
|
19
|
+
reason: Reason;
|
|
20
|
+
message: string;
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
}> {}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/uno/uno.d.ts
|
|
25
|
+
declare const UnoError_base: Schema.TaggedErrorClass<UnoError, "UnoError", {
|
|
26
|
+
readonly _tag: Schema.tag<"UnoError">;
|
|
27
|
+
} & {
|
|
28
|
+
message: typeof Schema.String;
|
|
29
|
+
reason: Schema.Literal<["StartFailed", "Unknown", "InputFileNotFound", "BadOutputExtension", "MethodNotFound", "PermissionDenied"]>;
|
|
30
|
+
cause: Schema.optional<typeof Schema.Unknown>;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Error thrown when the uno server fails to start or when a conversion fails.
|
|
34
|
+
*/
|
|
35
|
+
declare class UnoError extends UnoError_base {}
|
|
36
|
+
declare const UnoServer_base: Effect.Service.Class<UnoServer, "libre-convert-effect/index/UnoServer", {
|
|
37
|
+
readonly scoped: Effect.Effect<{
|
|
38
|
+
/**
|
|
39
|
+
* The url of the uno server
|
|
40
|
+
*/
|
|
41
|
+
url: string;
|
|
42
|
+
}, _effect_platform_Error0.PlatformError | UnoError, effect_Scope0.Scope | _effect_platform_CommandExecutor0.CommandExecutor>;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* UnoServer service. The default implementation will try to spawn a new `unoserver` process.
|
|
46
|
+
*/
|
|
47
|
+
declare class UnoServer extends UnoServer_base {
|
|
48
|
+
/**
|
|
49
|
+
* Note that while any url can be passed, libreoffice will expect the given files
|
|
50
|
+
* to be on disk and will write them to disk, so to be actually useful the server
|
|
51
|
+
* should probably utilize the same file system as your process.
|
|
52
|
+
*
|
|
53
|
+
* This url can be useful if the uno server is running inside a docker (with a mounted file system)
|
|
54
|
+
*/
|
|
55
|
+
static remoteWithURL: (url: string) => Layer.Layer<UnoServer, UnoError, never>;
|
|
56
|
+
/**
|
|
57
|
+
* Static layer that expects the uno server to be running on localhost:2003
|
|
58
|
+
*/
|
|
59
|
+
static Remote: Layer.Layer<UnoServer, UnoError, never>;
|
|
60
|
+
}
|
|
61
|
+
declare const UnoClient_base: Effect.Service.Class<UnoClient, "libre-convert-effect/uno/UnoClient", {
|
|
62
|
+
readonly scoped: Effect.Effect<{
|
|
63
|
+
client: HttpClient.HttpClient.With<_effect_platform_HttpClientError0.HttpClientError, never>;
|
|
64
|
+
convert(input: string, output: OutputPath): Effect.Effect<HttpClientResponse.HttpClientResponse, _effect_platform_HttpClientError0.HttpClientError | UnoError | effect_ParseResult0.ParseError, never>;
|
|
65
|
+
compare(input: string, output: string): Effect.Effect<HttpClientResponse.HttpClientResponse, _effect_platform_HttpClientError0.HttpClientError | UnoError | effect_ParseResult0.ParseError, never>;
|
|
66
|
+
}, never, HttpClient.HttpClient | UnoServer>;
|
|
67
|
+
}>;
|
|
68
|
+
declare class UnoClient extends UnoClient_base {}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/index.d.ts
|
|
71
|
+
declare const LibreOfficeCmd_base: Context.ReferenceClass<LibreOfficeCmd, "libre-convert-effect/index/LibreOfficeCmd", string[]>;
|
|
72
|
+
declare class LibreOfficeCmd extends LibreOfficeCmd_base {}
|
|
73
|
+
declare const LibreOffice_base: Effect.Service.Class<LibreOffice, "libre-convert-effect/index/LibreOffice", {
|
|
74
|
+
readonly scoped: Effect.Effect<{
|
|
75
|
+
/**
|
|
76
|
+
* Converts a file to a different format.
|
|
77
|
+
*
|
|
78
|
+
* ### Example
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* const program = Effect.gen(function* () {
|
|
82
|
+
* const libre = yield* LibreOffice;
|
|
83
|
+
* yield* libre.convertLocalFile("/path/to/input.docx", "/path/to/output.pdf");
|
|
84
|
+
* });
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
convertLocalFile: (input: string, output: OutputPath) => Effect.Effect<undefined, LibreOfficeError | _effect_platform_Error0.PlatformError, _effect_platform_CommandExecutor0.CommandExecutor>;
|
|
88
|
+
}, never, FileSystem.FileSystem | Path.Path>;
|
|
89
|
+
}>;
|
|
90
|
+
declare class LibreOffice extends LibreOffice_base {
|
|
91
|
+
/**
|
|
92
|
+
* The Uno layer uses a unoserver to convert files. It is much more
|
|
93
|
+
* performant than the cli but requires a unoserver to be running.
|
|
94
|
+
*/
|
|
95
|
+
static Uno: Layer.Layer<LibreOffice, never, UnoClient>;
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
export { LibreOffice, LibreOfficeCmd };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { Command, FileSystem, HttpClient, HttpClientRequest, Path } from "@effect/platform";
|
|
2
|
+
import { Context, Data, Effect, Layer, Match, Schedule, Schema, Stream, String, flow } from "effect";
|
|
3
|
+
import { XMLParser } from "fast-xml-parser";
|
|
4
|
+
|
|
5
|
+
//#region src/shared.ts
|
|
6
|
+
var LibreOfficeError = class extends Data.TaggedError("LibreOfficeError") {};
|
|
7
|
+
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/uno/schema-utils.ts
|
|
10
|
+
const MemberValue = Schema.Union(Schema.Struct({ int: Schema.Number }), Schema.Struct({ string: Schema.String }));
|
|
11
|
+
const Member = Schema.Struct({
|
|
12
|
+
name: Schema.String,
|
|
13
|
+
value: MemberValue
|
|
14
|
+
});
|
|
15
|
+
const Members = Schema.Array(Member);
|
|
16
|
+
const StructFromMembers = (fields) => Schema.transform(Members, Schema.Struct(fields), {
|
|
17
|
+
strict: false,
|
|
18
|
+
decode: (input) => {
|
|
19
|
+
const output = {};
|
|
20
|
+
for (const member of input) if ("int" in member.value) output[member.name] = member.value.int;
|
|
21
|
+
else if ("string" in member.value) output[member.name] = member.value.string;
|
|
22
|
+
return output;
|
|
23
|
+
},
|
|
24
|
+
encode: (input) => {
|
|
25
|
+
return Object.entries(input).map(([name, value]) => {
|
|
26
|
+
if (typeof value === "number") return {
|
|
27
|
+
name,
|
|
28
|
+
value: { int: value }
|
|
29
|
+
};
|
|
30
|
+
if (typeof value === "string") return {
|
|
31
|
+
name,
|
|
32
|
+
value: { string: value }
|
|
33
|
+
};
|
|
34
|
+
throw new Error(`Unsupported value type for member ${name}: ${typeof value}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/uno/uno-response.ts
|
|
41
|
+
/**
|
|
42
|
+
* Schema for fault response
|
|
43
|
+
*
|
|
44
|
+
* ## Original xml
|
|
45
|
+
* ```xml
|
|
46
|
+
* <?xml version='1.0'?>
|
|
47
|
+
* <methodResponse>
|
|
48
|
+
* <fault>
|
|
49
|
+
* <value>
|
|
50
|
+
* <struct>
|
|
51
|
+
* <member>
|
|
52
|
+
* <name>faultCode</name>
|
|
53
|
+
* <value>
|
|
54
|
+
* <int>1</int>
|
|
55
|
+
* </value>
|
|
56
|
+
* </member>
|
|
57
|
+
* <member>
|
|
58
|
+
* <name>faultString</name>
|
|
59
|
+
* <value>
|
|
60
|
+
* <string><class 'RuntimeError'>:Path /tmp/test-convert/test.txt does not exist.</string>
|
|
61
|
+
* </value>
|
|
62
|
+
* </member>
|
|
63
|
+
* </struct>
|
|
64
|
+
* </value>
|
|
65
|
+
* </fault>
|
|
66
|
+
* </methodResponse>
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
const UnoFault = Schema.Struct({ methodResponse: Schema.Struct({ fault: Schema.Struct({ value: Schema.Struct({ struct: Schema.Struct({ member: StructFromMembers({
|
|
70
|
+
faultCode: Schema.Number,
|
|
71
|
+
faultString: Schema.String
|
|
72
|
+
}) }) }) }) }) }).pipe(Schema.transform(Schema.Struct({
|
|
73
|
+
faultCode: Schema.Number,
|
|
74
|
+
faultString: Schema.String
|
|
75
|
+
}), {
|
|
76
|
+
strict: true,
|
|
77
|
+
decode: (input) => input.methodResponse.fault.value.struct.member,
|
|
78
|
+
encode: (input) => ({ methodResponse: { fault: { value: { struct: { member: input } } } } })
|
|
79
|
+
}), Schema.asSchema);
|
|
80
|
+
/**
|
|
81
|
+
* Schema for empty response (success)
|
|
82
|
+
*
|
|
83
|
+
* ## Original xml
|
|
84
|
+
* ```xml
|
|
85
|
+
* <?xml version='1.0'?>
|
|
86
|
+
* <methodResponse>
|
|
87
|
+
* <params>
|
|
88
|
+
* <param>
|
|
89
|
+
* <value>
|
|
90
|
+
* <nil />
|
|
91
|
+
* </value>
|
|
92
|
+
* </param>
|
|
93
|
+
* </params>
|
|
94
|
+
* </methodResponse>
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
const UnoEmpty = Schema.Struct({ methodResponse: Schema.Struct({ params: Schema.Struct({ param: Schema.Struct({ value: Schema.Struct({ nil: Schema.String }) }) }) }) }).pipe(Schema.asSchema);
|
|
98
|
+
const UnoResponse = Schema.Union(UnoFault, UnoEmpty);
|
|
99
|
+
const decodeUnoResponse = Schema.decodeUnknown(UnoResponse);
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/uno/xml-parser.ts
|
|
103
|
+
const parser = new XMLParser();
|
|
104
|
+
function parseXML(input) {
|
|
105
|
+
return parser.parse(input);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/uno/uno.ts
|
|
110
|
+
/**
|
|
111
|
+
* Error thrown when the uno server fails to start or when a conversion fails.
|
|
112
|
+
*/
|
|
113
|
+
var UnoError = class extends Schema.TaggedError()("UnoError", {
|
|
114
|
+
message: Schema.String,
|
|
115
|
+
reason: Schema.Literal("StartFailed", "Unknown", "InputFileNotFound", "BadOutputExtension", "MethodNotFound", "PermissionDenied"),
|
|
116
|
+
cause: Schema.optional(Schema.Unknown)
|
|
117
|
+
}) {};
|
|
118
|
+
const testRunning = Effect.fn(function* (url) {
|
|
119
|
+
return yield* Effect.tryPromise({
|
|
120
|
+
try: () => fetch(url, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
body: `<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>`
|
|
123
|
+
}),
|
|
124
|
+
catch: (e) => new UnoError({
|
|
125
|
+
reason: "StartFailed",
|
|
126
|
+
message: globalThis.String(e)
|
|
127
|
+
})
|
|
128
|
+
}).pipe(Effect.flatMap((res) => res.ok ? Effect.void : new UnoError({
|
|
129
|
+
reason: "StartFailed",
|
|
130
|
+
message: "Server not ready"
|
|
131
|
+
})));
|
|
132
|
+
});
|
|
133
|
+
const ensureRunning = flow(testRunning, Effect.retry({
|
|
134
|
+
times: 40,
|
|
135
|
+
schedule: Schedule.spaced("250 millis")
|
|
136
|
+
}));
|
|
137
|
+
/**
|
|
138
|
+
* UnoServer service. The default implementation will try to spawn a new `unoserver` process.
|
|
139
|
+
*/
|
|
140
|
+
var UnoServer = class UnoServer extends Effect.Service()("libre-convert-effect/index/UnoServer", { scoped: Effect.gen(function* () {
|
|
141
|
+
const acquire = Effect.gen(function* () {
|
|
142
|
+
const process = yield* Command.start(Command.make("unoserver"));
|
|
143
|
+
yield* ensureRunning(`http://localhost:2003/RPC2`).pipe(Effect.catchAll(() => new UnoError({
|
|
144
|
+
reason: "StartFailed",
|
|
145
|
+
message: "Failed to start server"
|
|
146
|
+
})));
|
|
147
|
+
return process;
|
|
148
|
+
});
|
|
149
|
+
yield* Effect.acquireRelease(acquire, (process) => Effect.ignore(process.kill()));
|
|
150
|
+
return { url: "http://localhost:2003/RPC2" };
|
|
151
|
+
}) }) {
|
|
152
|
+
/**
|
|
153
|
+
* Note that while any url can be passed, libreoffice will expect the given files
|
|
154
|
+
* to be on disk and will write them to disk, so to be actually useful the server
|
|
155
|
+
* should probably utilize the same file system as your process.
|
|
156
|
+
*
|
|
157
|
+
* This url can be useful if the uno server is running inside a docker (with a mounted file system)
|
|
158
|
+
*/
|
|
159
|
+
static remoteWithURL = (url) => Layer.scoped(UnoServer, Effect.gen(function* () {
|
|
160
|
+
yield* ensureRunning(url);
|
|
161
|
+
return UnoServer.make({ url });
|
|
162
|
+
}));
|
|
163
|
+
/**
|
|
164
|
+
* Static layer that expects the uno server to be running on localhost:2003
|
|
165
|
+
*/
|
|
166
|
+
static Remote = UnoServer.remoteWithURL("http://localhost:2003/RPC2");
|
|
167
|
+
};
|
|
168
|
+
const convertRequest = (input, output) => {
|
|
169
|
+
const body = `<?xml version="1.0"?>
|
|
170
|
+
<methodCall>
|
|
171
|
+
<methodName>convert</methodName>
|
|
172
|
+
<params>
|
|
173
|
+
<param><value><string>${input}</string></value></param>
|
|
174
|
+
<param><value><nil/></value></param>
|
|
175
|
+
<param><value><string>${output}</string></value></param>
|
|
176
|
+
</params>
|
|
177
|
+
</methodCall>
|
|
178
|
+
`;
|
|
179
|
+
return HttpClientRequest.post("").pipe(HttpClientRequest.bodyText(body));
|
|
180
|
+
};
|
|
181
|
+
const compareRequest = (input, output) => {
|
|
182
|
+
const body = `<?xml version="1.0"?>
|
|
183
|
+
<methodCall>
|
|
184
|
+
<methodName>compare</methodName>
|
|
185
|
+
<params>
|
|
186
|
+
<param><value><string>${input}</string></value></param>
|
|
187
|
+
<param><value><nil/></value></param>
|
|
188
|
+
<param><value><string>${output}</string></value></param>
|
|
189
|
+
</params>
|
|
190
|
+
</methodCall>
|
|
191
|
+
`;
|
|
192
|
+
return HttpClientRequest.post("").pipe(HttpClientRequest.bodyText(body));
|
|
193
|
+
};
|
|
194
|
+
const getReason = Match.type().pipe(Match.when({
|
|
195
|
+
faultCode: 1,
|
|
196
|
+
faultString: String.includes("does not exist")
|
|
197
|
+
}, () => "InputFileNotFound"), Match.when({
|
|
198
|
+
faultCode: 1,
|
|
199
|
+
faultString: String.includes("Unknown export file type")
|
|
200
|
+
}, () => "BadOutputExtension"), Match.when({
|
|
201
|
+
faultCode: 1,
|
|
202
|
+
faultString: String.includes("is not supported")
|
|
203
|
+
}, () => "MethodNotFound"), Match.when({
|
|
204
|
+
faultCode: 1,
|
|
205
|
+
faultString: String.includes("PermissionError")
|
|
206
|
+
}, () => "PermissionDenied"), Match.when({
|
|
207
|
+
faultCode: 1,
|
|
208
|
+
faultString: String.includes("Permission denied")
|
|
209
|
+
}, () => "PermissionDenied"), Match.orElse(() => "Unknown"));
|
|
210
|
+
const handleResponse = (response) => response.text.pipe(Effect.map(parseXML), Effect.flatMap(decodeUnoResponse), Effect.flatMap((decoded) => {
|
|
211
|
+
if ("faultString" in decoded) return new UnoError({
|
|
212
|
+
reason: getReason(decoded),
|
|
213
|
+
message: decoded.faultString,
|
|
214
|
+
cause: decoded
|
|
215
|
+
});
|
|
216
|
+
return Effect.succeed(response);
|
|
217
|
+
}));
|
|
218
|
+
var UnoClient = class extends Effect.Service()("libre-convert-effect/uno/UnoClient", { scoped: Effect.gen(function* () {
|
|
219
|
+
const { url } = yield* UnoServer;
|
|
220
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.mapRequestInput(flow(HttpClientRequest.prependUrl(url))), HttpClient.filterStatusOk);
|
|
221
|
+
return {
|
|
222
|
+
client,
|
|
223
|
+
convert(input, output) {
|
|
224
|
+
return client.execute(convertRequest(input, output)).pipe(Effect.flatMap(handleResponse));
|
|
225
|
+
},
|
|
226
|
+
compare(input, output) {
|
|
227
|
+
return client.execute(compareRequest(input, output)).pipe(Effect.flatMap(handleResponse));
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}) }) {};
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/index.ts
|
|
234
|
+
const runString = (stream) => stream.pipe(Stream.decodeText(), Stream.runFold(String.empty, String.concat));
|
|
235
|
+
var LibreOfficeCmd = class extends Context.Reference()("libre-convert-effect/index/LibreOfficeCmd", { defaultValue: () => ["soffice", "--headless"] }) {};
|
|
236
|
+
var LibreOffice = class LibreOffice extends Effect.Service()("libre-convert-effect/index/LibreOffice", { scoped: Effect.gen(function* () {
|
|
237
|
+
const fs = yield* FileSystem.FileSystem;
|
|
238
|
+
const path = yield* Path.Path;
|
|
239
|
+
const sem = yield* Effect.makeSemaphore(1);
|
|
240
|
+
return { convertLocalFile: Effect.fn(function* (input, output) {
|
|
241
|
+
const [cmd, ...args] = yield* LibreOfficeCmd;
|
|
242
|
+
const parsedInput = path.parse(input);
|
|
243
|
+
const parsedOutput = path.parse(output);
|
|
244
|
+
if (yield* fs.stat(output).pipe(Effect.map((stat) => stat.type === "Directory"), Effect.catchAll(() => Effect.succeed(false)))) return yield* Effect.fail(new LibreOfficeError({
|
|
245
|
+
reason: "BadOutputExtension",
|
|
246
|
+
message: "Output path is a directory"
|
|
247
|
+
}));
|
|
248
|
+
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "effect-libreoffice-" });
|
|
249
|
+
yield* sem.withPermits(1)(Effect.gen(function* () {
|
|
250
|
+
const process = yield* Command.make(cmd, ...args, "--convert-to", parsedOutput.ext.slice(1), "--outdir", tempDir, input).pipe(Command.start);
|
|
251
|
+
const [exitCode, result] = yield* Effect.all([process.exitCode, runString(process.stderr)], { concurrency: "unbounded" });
|
|
252
|
+
yield* Match.value(String.trim(result)).pipe(Match.when(String.includes("Error: source file could not be loaded"), () => new LibreOfficeError({
|
|
253
|
+
reason: "InputFileNotFound",
|
|
254
|
+
message: result
|
|
255
|
+
})), Match.when(String.includes("Error: no export filter"), () => new LibreOfficeError({
|
|
256
|
+
reason: "BadOutputExtension",
|
|
257
|
+
message: result
|
|
258
|
+
})), Match.when(String.includes("Permission denied"), () => new LibreOfficeError({
|
|
259
|
+
reason: "PermissionDenied",
|
|
260
|
+
message: result
|
|
261
|
+
})), Match.when(String.includes("Error: "), () => new LibreOfficeError({
|
|
262
|
+
reason: "Unknown",
|
|
263
|
+
message: result
|
|
264
|
+
})), Match.orElse(() => Effect.void));
|
|
265
|
+
if (exitCode !== 0) return yield* new LibreOfficeError({
|
|
266
|
+
reason: "Unknown",
|
|
267
|
+
message: result || `Process failed with exit code ${exitCode}`
|
|
268
|
+
});
|
|
269
|
+
const libreOutputPath = path.join(tempDir, String.concat(parsedInput.name, parsedOutput.ext));
|
|
270
|
+
yield* fs.copyFile(libreOutputPath, output);
|
|
271
|
+
}));
|
|
272
|
+
}, Effect.scoped) };
|
|
273
|
+
}) }) {
|
|
274
|
+
/**
|
|
275
|
+
* The Uno layer uses a unoserver to convert files. It is much more
|
|
276
|
+
* performant than the cli but requires a unoserver to be running.
|
|
277
|
+
*/
|
|
278
|
+
static Uno = Layer.scoped(LibreOffice, Effect.gen(function* () {
|
|
279
|
+
const client = yield* UnoClient;
|
|
280
|
+
return LibreOffice.make({ convertLocalFile: flow(client.convert, Effect.as(void 0), Effect.mapError((err) => err instanceof UnoError ? new LibreOfficeError(err) : new LibreOfficeError({
|
|
281
|
+
reason: "Unknown",
|
|
282
|
+
message: `Failed to convert file: ${err}`,
|
|
283
|
+
cause: err
|
|
284
|
+
}))) });
|
|
285
|
+
}));
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
//#endregion
|
|
289
|
+
export { LibreOffice, LibreOfficeCmd };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-libreoffice",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.5",
|
|
5
5
|
"description": "A effect based LibreOffice converter library",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
7
7
|
"module": "./dist/index.mjs",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
".": "./dist/index.mjs",
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
22
25
|
"keywords": [],
|
|
23
26
|
"dependencies": {
|
|
24
27
|
"fast-xml-parser": "^5.3.3"
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
|
|
13
|
-
steps:
|
|
14
|
-
- uses: actions/checkout@v4
|
|
15
|
-
|
|
16
|
-
- name: Install pnpm
|
|
17
|
-
uses: pnpm/action-setup@v4
|
|
18
|
-
|
|
19
|
-
- name: Setup Node.js
|
|
20
|
-
uses: actions/setup-node@v6
|
|
21
|
-
with:
|
|
22
|
-
node-version: 24
|
|
23
|
-
cache: "pnpm"
|
|
24
|
-
|
|
25
|
-
- name: Install dependencies
|
|
26
|
-
run: pnpm install
|
|
27
|
-
|
|
28
|
-
- name: Install LibreOffice
|
|
29
|
-
run: |
|
|
30
|
-
sudo apt-get update
|
|
31
|
-
sudo apt-get install -y --no-install-recommends libreoffice-writer
|
|
32
|
-
|
|
33
|
-
- name: Lint
|
|
34
|
-
run: pnpm biome ci .
|
|
35
|
-
|
|
36
|
-
- name: Type check
|
|
37
|
-
run: pnpm type-check
|
|
38
|
-
|
|
39
|
-
- name: Test
|
|
40
|
-
run: pnpm test
|
package/.vscode/settings.json
DELETED
package/Dockerfile
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
FROM alpine:latest
|
|
2
|
-
|
|
3
|
-
# install fonts https://wiki.alpinelinux.org/wiki/Fonts
|
|
4
|
-
RUN apk add --no-cache font-terminus font-inconsolata font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra
|
|
5
|
-
|
|
6
|
-
# install libreoffice + dependencies
|
|
7
|
-
RUN apk add --no-cache libreoffice-writer python3 py3-pip openjdk11-jre-headless
|
|
8
|
-
|
|
9
|
-
# install unoserver via pip
|
|
10
|
-
RUN pip install unoserver --break-system-packages
|
|
11
|
-
|
|
12
|
-
CMD ["unoserver", "--interface", "0.0.0.0"]
|
package/biome.json
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
|
3
|
-
"vcs": {
|
|
4
|
-
"enabled": true,
|
|
5
|
-
"clientKind": "git",
|
|
6
|
-
"useIgnoreFile": true
|
|
7
|
-
},
|
|
8
|
-
"files": {
|
|
9
|
-
"ignoreUnknown": true
|
|
10
|
-
},
|
|
11
|
-
"formatter": {
|
|
12
|
-
"enabled": true,
|
|
13
|
-
"indentStyle": "space"
|
|
14
|
-
},
|
|
15
|
-
"linter": {
|
|
16
|
-
"enabled": true,
|
|
17
|
-
"rules": {
|
|
18
|
-
"recommended": true,
|
|
19
|
-
"suspicious": {
|
|
20
|
-
"noShadowRestrictedNames": "off"
|
|
21
|
-
},
|
|
22
|
-
"correctness": {
|
|
23
|
-
"noUnusedVariables": {
|
|
24
|
-
"level": "warn",
|
|
25
|
-
"fix": "none"
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
"javascript": {
|
|
31
|
-
"formatter": {
|
|
32
|
-
"quoteStyle": "double"
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
"assist": {
|
|
36
|
-
"enabled": true,
|
|
37
|
-
"actions": {
|
|
38
|
-
"source": {
|
|
39
|
-
"organizeImports": "on"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
package/compose.yml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
services:
|
|
2
|
-
# unoserver:
|
|
3
|
-
# image: libreofficedocker/libreoffice-unoserver:3.22
|
|
4
|
-
# ports:
|
|
5
|
-
# - "2004:2004" # uno rest api
|
|
6
|
-
# - "2003:2003" # uno api
|
|
7
|
-
unoserver:
|
|
8
|
-
build: .
|
|
9
|
-
ports:
|
|
10
|
-
- "2003:2003" # uno api
|
|
11
|
-
volumes:
|
|
12
|
-
- /tmp/test-convert:/tmp/test-convert
|
|
13
|
-
user: "1000:1000"
|
|
14
|
-
environment:
|
|
15
|
-
- HOME=/tmp
|
|
16
|
-
develop:
|
|
17
|
-
watch:
|
|
18
|
-
- action: rebuild
|
|
19
|
-
path: ./Dockerfile
|
package/docker/ubuntu.Dockerfile
DELETED