@pistonite/pure 0.22.2 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pistonite/pure",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "Pure TypeScript libraries for my projects",
6
6
  "homepage": "https://github.com/Pistonite/pure",
@@ -32,8 +32,6 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/file-saver": "^2.0.7",
35
- "eslint": "^9.19.0",
36
- "typescript": "^5.7.2",
37
35
  "vitest": "^3.0.5",
38
36
  "mono-dev": "0.0.0"
39
37
  }
package/src/env.d.ts ADDED
@@ -0,0 +1 @@
1
+ /// <reference lib="dom" />
@@ -206,7 +206,7 @@ class FsFileImpl implements FsFile {
206
206
  this.buffer,
207
207
  );
208
208
  this.isText = true;
209
- } catch (_) {
209
+ } catch {
210
210
  this.content = undefined;
211
211
  this.isText = false;
212
212
  }
@@ -0,0 +1,21 @@
1
+ import type { FsResult, FsVoid } from "./FsError.ts";
2
+
3
+ /**
4
+ * Interface for operating on a file opened standalone (without opening a file system
5
+ */
6
+ export interface FsFileStandalone {
7
+ /** The name of the file */
8
+ readonly name: string;
9
+ /** If the file is writable. May prompt user for permission */
10
+ isWritable(): Promise<boolean>;
11
+ /** Get the size of the file */
12
+ getSize(): Promise<FsResult<number>>;
13
+ /** Get the content of the file as a byte array */
14
+ getBytes(): Promise<FsResult<Uint8Array>>;
15
+ /** Get the last modified time of the file */
16
+ getLastModified(): Promise<FsResult<number>>;
17
+ /** Get the text content of the file*/
18
+ getText(): Promise<FsResult<string>>;
19
+ /** Write content to the file if the implementation supports writing, and permission is granted*/
20
+ write(content: Uint8Array | string): Promise<FsVoid>;
21
+ }
@@ -0,0 +1,53 @@
1
+ import { errstr } from "../result";
2
+
3
+ import { fsErr, FsErr, fsFail, type FsVoid, type FsResult } from "./FsError.ts";
4
+ import type { FsFileStandalone } from "./FsFileStandalone.ts";
5
+
6
+ export class FsFileStandaloneImplFileAPI implements FsFileStandalone {
7
+ public name: string;
8
+ private size: number;
9
+ private lastModified: number;
10
+ private file: File;
11
+
12
+ constructor(file: File) {
13
+ this.name = file.name;
14
+ this.size = file.size;
15
+ this.lastModified = file.lastModified;
16
+ this.file = file;
17
+ }
18
+
19
+ public async isWritable(): Promise<boolean> {
20
+ return false;
21
+ }
22
+
23
+ public async getSize(): Promise<FsResult<number>> {
24
+ return { val: this.size };
25
+ }
26
+
27
+ public async getBytes(): Promise<FsResult<Uint8Array>> {
28
+ try {
29
+ const data = await this.file.bytes();
30
+ return { val: data };
31
+ } catch (e) {
32
+ console.error(e);
33
+ return { err: fsFail(errstr(e)) };
34
+ }
35
+ }
36
+ public async getLastModified(): Promise<FsResult<number>> {
37
+ return { val: this.lastModified };
38
+ }
39
+ public async getText(): Promise<FsResult<string>> {
40
+ try {
41
+ const data = await this.file.text();
42
+ return { val: data };
43
+ } catch (e) {
44
+ console.error(e);
45
+ return { err: fsFail(errstr(e)) };
46
+ }
47
+ }
48
+ public async write(): Promise<FsVoid> {
49
+ return {
50
+ err: fsErr(FsErr.NotSupported, "Write not supported in File API"),
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,152 @@
1
+ import { errstr } from "../result";
2
+
3
+ import { fsErr, FsErr, fsFail, type FsVoid, type FsResult } from "./FsError.ts";
4
+ import type { FsFileStandalone } from "./FsFileStandalone.ts";
5
+
6
+ export class FsFileStandaloneImplHandleAPI implements FsFileStandalone {
7
+ name: string;
8
+ private handle: FileSystemFileHandle;
9
+ constructor(handle: FileSystemFileHandle) {
10
+ this.name = handle.name;
11
+ this.handle = handle;
12
+ }
13
+
14
+ public async isWritable(): Promise<boolean> {
15
+ if (
16
+ !("queryPermission" in this.handle) ||
17
+ !(typeof this.handle.queryPermission === "function")
18
+ ) {
19
+ return false;
20
+ }
21
+ try {
22
+ const permission = await this.handle.queryPermission({
23
+ mode: "readwrite",
24
+ });
25
+ if (permission === "granted") {
26
+ return true;
27
+ }
28
+ if (permission === "denied") {
29
+ return false;
30
+ }
31
+ if (
32
+ !("requestPermission" in this.handle) ||
33
+ !(typeof this.handle.requestPermission === "function")
34
+ ) {
35
+ return false;
36
+ }
37
+ const requestedPermission = await this.handle.requestPermission({
38
+ mode: "readwrite",
39
+ });
40
+ return requestedPermission === "granted";
41
+ } catch (e) {
42
+ console.error(e);
43
+ return false;
44
+ }
45
+ }
46
+
47
+ private async getFile(): Promise<FsResult<File>> {
48
+ try {
49
+ return { val: await this.handle.getFile() };
50
+ } catch (e) {
51
+ if (e && typeof e === "object" && "name" in e) {
52
+ if (e.name === "NotAllowedError") {
53
+ return {
54
+ err: fsErr(FsErr.PermissionDenied, "Permission denied"),
55
+ };
56
+ }
57
+ if (e.name === "NotFoundError") {
58
+ return { err: fsErr(FsErr.NotFound, "File not found") };
59
+ }
60
+ }
61
+ console.error(e);
62
+ return { err: fsFail(errstr(e)) };
63
+ }
64
+ }
65
+
66
+ public async getSize(): Promise<FsResult<number>> {
67
+ const file = await this.getFile();
68
+ if (file.err) {
69
+ return file;
70
+ }
71
+ return { val: file.val.size };
72
+ }
73
+ public async getBytes(): Promise<FsResult<Uint8Array>> {
74
+ const file = await this.getFile();
75
+ if (file.err) {
76
+ return file;
77
+ }
78
+ try {
79
+ const data = await file.val.bytes();
80
+ return { val: data };
81
+ } catch (e) {
82
+ console.error(e);
83
+ return { err: fsFail(errstr(e)) };
84
+ }
85
+ }
86
+ public async getLastModified(): Promise<FsResult<number>> {
87
+ const file = await this.getFile();
88
+ if (file.err) {
89
+ return file;
90
+ }
91
+ return { val: file.val.lastModified };
92
+ }
93
+ public async getText(): Promise<FsResult<string>> {
94
+ const file = await this.getFile();
95
+ if (file.err) {
96
+ return file;
97
+ }
98
+ try {
99
+ const data = await file.val.text();
100
+ return { val: data };
101
+ } catch (e) {
102
+ console.error(e);
103
+ return { err: fsFail(errstr(e)) };
104
+ }
105
+ }
106
+ public async write(content: Uint8Array | string): Promise<FsVoid> {
107
+ const writable = await this.isWritable();
108
+ if (!writable) {
109
+ return {
110
+ err: fsErr(
111
+ FsErr.NotSupported,
112
+ "Permission was not granted or API not supported",
113
+ ),
114
+ };
115
+ }
116
+ try {
117
+ const stream = await this.handle.createWritable();
118
+ await stream.write(content);
119
+ await stream.close();
120
+ return {};
121
+ } catch (e) {
122
+ if (e && typeof e === "object" && "name" in e) {
123
+ if (e.name === "NotAllowedError") {
124
+ return {
125
+ err: fsErr(FsErr.PermissionDenied, "Permission denied"),
126
+ };
127
+ }
128
+ if (e.name === "NotFoundError") {
129
+ return { err: fsErr(FsErr.NotFound, "File not found") };
130
+ }
131
+ if (e.name === "NoMidificationAllowedError") {
132
+ return {
133
+ err: fsErr(
134
+ FsErr.PermissionDenied,
135
+ "Failed to acquire write lock",
136
+ ),
137
+ };
138
+ }
139
+ if (e.name === "AbortError") {
140
+ return { err: fsErr(FsErr.UserAbort, "User abort") };
141
+ }
142
+ if (e.name === "QuotaExceededError") {
143
+ return {
144
+ err: fsErr(FsErr.PermissionDenied, "Quota exceeded"),
145
+ };
146
+ }
147
+ }
148
+ console.error(e);
149
+ return { err: fsFail(errstr(e)) };
150
+ }
151
+ }
152
+ }
package/src/fs/FsOpen.ts CHANGED
@@ -117,6 +117,14 @@ async function createWithPicker(
117
117
  }
118
118
  resolve(createFromFileList(files));
119
119
  });
120
+ inputElement.addEventListener("cancel", () => {
121
+ resolve({
122
+ err: fsErr(
123
+ FsErr.UserAbort,
124
+ "User cancelled the operation",
125
+ ),
126
+ });
127
+ });
120
128
  inputElement.click();
121
129
  },
122
130
  );
@@ -0,0 +1,264 @@
1
+ import { errstr } from "../result";
2
+
3
+ import { fsErr, FsErr, fsFail, type FsResult } from "./FsError.ts";
4
+ import type { FsFileStandalone } from "./FsFileStandalone.ts";
5
+ import { FsFileStandaloneImplFileAPI } from "./FsFileStandaloneImplFileAPI.ts";
6
+ import { FsFileStandaloneImplHandleAPI } from "./FsFileStandaloneImplHandleAPI.ts";
7
+
8
+ /**
9
+ * Prompt user to open a file
10
+ *
11
+ * FileSystemAccess API is used on supported platforms, which allows writing after
12
+ * user grants the permission once at the time of writing to the file. If failed or not supported,
13
+ * the DOM input element is used as a fallback.
14
+ */
15
+ export const fsOpenFile = async (
16
+ options?: FsFileOpenOptions,
17
+ ): Promise<FsResult<FsFileStandalone>> => {
18
+ const result = await fsOpenFileInternal(false, options || {});
19
+ if (result.err) {
20
+ return result;
21
+ }
22
+ if (!result.val.length) {
23
+ return { err: fsErr(FsErr.UserAbort, "No files selected") };
24
+ }
25
+ return { val: result.val[0] };
26
+ };
27
+
28
+ /**
29
+ * Prompt user to open multiple files
30
+ *
31
+ * FileSystemAccess API is used on supported platforms, which allows writing after
32
+ * user grants the permission once at the time of writing to the file. If failed or not supported,
33
+ * the DOM input element is used as a fallback.
34
+ *
35
+ * The returned array is guaranteed to have at least 1 file.
36
+ */
37
+ export const fsOpenFileMultiple = async (
38
+ options?: FsFileOpenOptions,
39
+ ): Promise<FsResult<FsFileStandalone[]>> => {
40
+ const result = await fsOpenFileInternal(true, options || {});
41
+ if (result.err) {
42
+ return result;
43
+ }
44
+ if (!result.val.length) {
45
+ return { err: fsErr(FsErr.UserAbort, "No files selected") };
46
+ }
47
+ return result;
48
+ };
49
+
50
+ const fsOpenFileInternal = async (
51
+ multiple: boolean,
52
+ options: FsFileOpenOptions,
53
+ ): Promise<FsResult<FsFileStandalone[]>> => {
54
+ if (isFileSystemAccessAPISupportedForStandaloneFileOpen()) {
55
+ const result = await fsOpenFileWithFileSystemAccessAPI(
56
+ multiple,
57
+ options,
58
+ );
59
+ if (result.val || result.err.code === FsErr.UserAbort) {
60
+ return result;
61
+ }
62
+ }
63
+ // fallback if FileSystemAccessAPI is not supported or fails
64
+ return fsOpenFileWithFileAPI(multiple, options);
65
+ };
66
+
67
+ const fsOpenFileWithFileAPI = async (
68
+ multiple: boolean,
69
+ { types }: FsFileOpenOptions,
70
+ ): Promise<FsResult<FsFileStandalone[]>> => {
71
+ const element = document.createElement("input");
72
+ element.type = "file";
73
+ if (multiple) {
74
+ element.multiple = true;
75
+ }
76
+ if (types?.length) {
77
+ const accept = new Set<string>();
78
+ const len = types.length;
79
+ for (let i = 0; i < len; i++) {
80
+ const acceptArray = types[i].accept;
81
+ const acceptLen = acceptArray.length;
82
+ for (let j = 0; j < acceptLen; j++) {
83
+ const acceptValue = acceptArray[j];
84
+ if (typeof acceptValue === "string") {
85
+ accept.add(acceptValue);
86
+ } else {
87
+ const { mime, extensions } = acceptValue;
88
+ if (mime) {
89
+ accept.add(mime);
90
+ }
91
+ if (extensions.length) {
92
+ extensions.forEach((ext) => {
93
+ if (ext) {
94
+ accept.add(ext);
95
+ }
96
+ });
97
+ }
98
+ }
99
+ }
100
+ }
101
+ element.accept = Array.from(accept).join(",");
102
+ }
103
+ try {
104
+ return new Promise((resolve) => {
105
+ element.addEventListener("cancel", () => {
106
+ resolve({
107
+ err: fsErr(FsErr.UserAbort, "cancel listener invoked"),
108
+ });
109
+ });
110
+ element.addEventListener("change", () => {
111
+ if (!element.files?.length) {
112
+ resolve({
113
+ err: fsErr(FsErr.UserAbort, "no files selected"),
114
+ });
115
+ return;
116
+ }
117
+ const array = [];
118
+ for (let i = 0; i < element.files.length; i++) {
119
+ array.push(
120
+ new FsFileStandaloneImplFileAPI(element.files[i]),
121
+ );
122
+ }
123
+ resolve({ val: array });
124
+ });
125
+ element.click();
126
+ });
127
+ } catch (e) {
128
+ return { err: fsFail(errstr(e)) };
129
+ }
130
+ };
131
+
132
+ const fsOpenFileWithFileSystemAccessAPI = async (
133
+ multiple: boolean,
134
+ { id, types, disallowWildcard }: FsFileOpenOptions,
135
+ ): Promise<FsResult<FsFileStandalone[]>> => {
136
+ const convertedTypes = types?.map((type) => {
137
+ const { description, accept } = type;
138
+ const convertedAccept: Record<string, string[]> = {};
139
+ const anyMimeType = [];
140
+ const len = accept.length;
141
+ for (let i = 0; i < len; i++) {
142
+ const acceptValue = accept[i];
143
+ if (typeof acceptValue === "string") {
144
+ if (acceptValue) {
145
+ anyMimeType.push(acceptValue);
146
+ }
147
+ continue;
148
+ }
149
+ const { mime, extensions } = acceptValue;
150
+ if (!mime || mime === "*/*") {
151
+ anyMimeType.push(...extensions);
152
+ continue;
153
+ }
154
+ if (mime in convertedAccept) {
155
+ convertedAccept[mime].push(...extensions);
156
+ } else {
157
+ convertedAccept[mime] = [...extensions];
158
+ }
159
+ }
160
+ if (anyMimeType.length) {
161
+ convertedAccept["*/*"] = anyMimeType;
162
+ }
163
+ return {
164
+ description,
165
+ accept: convertedAccept,
166
+ };
167
+ });
168
+ try {
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ const output = await (globalThis as any).showOpenFilePicker({
171
+ id,
172
+ excludeAcceptAllOption: types && types.length && disallowWildcard,
173
+ multiple,
174
+ types: convertedTypes,
175
+ });
176
+ const len = output.length;
177
+ const convertedOutput = [];
178
+ for (let i = 0; i < len; i++) {
179
+ convertedOutput.push(new FsFileStandaloneImplHandleAPI(output[i]));
180
+ }
181
+ return { val: convertedOutput };
182
+ } catch (e) {
183
+ if (e && typeof e === "object" && "name" in e) {
184
+ if (e.name === "AbortError") {
185
+ return { err: fsErr(FsErr.UserAbort, "User abort") };
186
+ }
187
+ if (e.name === "SecurityError") {
188
+ return { err: fsErr(FsErr.PermissionDenied, "Security error") };
189
+ }
190
+ }
191
+ return { err: fsFail(errstr(e)) };
192
+ }
193
+ };
194
+
195
+ export type FsFileOpenOptions = {
196
+ /**
197
+ * ID for the file open operation
198
+ *
199
+ * Supported implementation can use this to open the picker in the same
200
+ * directory for the same ID
201
+ */
202
+ id?: string;
203
+
204
+ /**
205
+ * If the "*.*" file type should be hidden in the picker.
206
+ *
207
+ * In unsupported implementations, this will be ignored, and the "*.*"
208
+ * file type will always be visible.
209
+ *
210
+ * By default, this is false
211
+ */
212
+ disallowWildcard?: boolean;
213
+
214
+ /** List of file types to accept */
215
+ types?: FsFileOpenType[];
216
+ };
217
+
218
+ export type FsFileOpenType = {
219
+ /**
220
+ * Optional description for the type, which may display in the file picker
221
+ *
222
+ * In unsupported implementations, this will be ignored.
223
+ */
224
+ description?: string;
225
+
226
+ /**
227
+ * List of file mime types or extensions to accept for this file type
228
+ *
229
+ * String elements are file extensions, with the "." prefix. More context
230
+ * can be provided with an object element with mime type and extensions to
231
+ * have better file type descriptions in the picker (if supported).
232
+ */
233
+ accept: (FsFileOpenTypeAccept | string)[];
234
+ };
235
+
236
+ export type FsFileOpenTypeAccept = {
237
+ /** Optional mime type, which the browser can be used to display file type descriptions */
238
+ mime?: string;
239
+ /** extensions to accept (with the "." prefix) */
240
+ extensions: string[];
241
+ };
242
+
243
+ const isFileSystemAccessAPISupportedForStandaloneFileOpen = () => {
244
+ if (!globalThis || !globalThis.isSecureContext) {
245
+ return false;
246
+ }
247
+ if (
248
+ !("showOpenFilePicker" in globalThis) ||
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
250
+ typeof (globalThis as any).showOpenFilePicker !== "function"
251
+ ) {
252
+ return false;
253
+ }
254
+ if (!globalThis.FileSystemFileHandle) {
255
+ return false;
256
+ }
257
+ if (!globalThis.FileSystemFileHandle.prototype.getFile) {
258
+ return false;
259
+ }
260
+ if (!globalThis.FileSystemFileHandle.prototype.createWritable) {
261
+ return false;
262
+ }
263
+ return true;
264
+ };
package/src/fs/index.ts CHANGED
@@ -106,6 +106,7 @@ export {
106
106
  fsOpenReadFrom,
107
107
  fsOpenReadWriteFrom,
108
108
  } from "./FsOpen.ts";
109
+ export { fsOpenFile, fsOpenFileMultiple } from "./FsOpenFile.ts";
109
110
  export { fsGetSupportStatus } from "./FsSupportStatus.ts";
110
111
  export {
111
112
  fsRoot,
@@ -119,6 +120,7 @@ export {
119
120
  export { FsErr, fsErr, fsFail } from "./FsError.ts";
120
121
 
121
122
  export type { FsOpenRetryHandler } from "./FsOpen.ts";
123
+ export type * from "./FsOpenFile.ts";
122
124
  export type { FsSupportStatus } from "./FsSupportStatus.ts";
123
125
  export type {
124
126
  FsFileSystem,
@@ -126,4 +128,5 @@ export type {
126
128
  FsCapabilities,
127
129
  } from "./FsFileSystem.ts";
128
130
  export type { FsFile } from "./FsFile.ts";
131
+ export type { FsFileStandalone } from "./FsFileStandalone.ts";
129
132
  export type { FsError, FsResult, FsVoid } from "./FsError.ts";
@@ -4,7 +4,7 @@
4
4
  * Will remove the old style tag(s) if exist
5
5
  */
6
6
  export function injectStyle(id: string, style: string) {
7
- const styleTags = document.querySelectorAll(`style[data-inject="${id}"`);
7
+ const styleTags = document.querySelectorAll(`style[data-inject="${id}"]`);
8
8
  if (styleTags.length !== 1) {
9
9
  const styleTag = document.createElement("style");
10
10
  styleTag.setAttribute("data-inject", id);