Skip to content

Commit c06c4aa

Browse files
committed
add sufile
1 parent 6732313 commit c06c4aa

File tree

4 files changed

+438
-2
lines changed

4 files changed

+438
-2
lines changed

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/classes/SuFile.ts

+350
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import { SuFileInputStream } from "./SuFileInputStream";
2+
3+
/**
4+
* Interface defining options for fetching a file stream.
5+
*/
6+
export interface FetchStreamOptions {
7+
/**
8+
* The size of each chunk to be read from the file, in bytes.
9+
* Default is 1 MB.
10+
*/
11+
chunkSize?: number;
12+
13+
/**
14+
* An AbortSignal to allow canceling the fetch operation.
15+
*/
16+
signal?: AbortSignal;
17+
}
18+
19+
/**
20+
* SuFile is a class designed to provide file-related operations such as reading, writing,
21+
* listing directories, checking file properties, and more. It interacts with a platform-specific
22+
* file interface discovered at runtime.
23+
*
24+
* @class
25+
*/
26+
export class SuFile {
27+
/**
28+
* Default options for fetching file streams.
29+
* @static
30+
* @type {FetchStreamOptions}
31+
*/
32+
public static defaultFetchStreamOptions: FetchStreamOptions = {
33+
chunkSize: 1024 * 1024,
34+
signal: undefined,
35+
};
36+
37+
/**
38+
* The platform-specific file interface object.
39+
* @private
40+
* @type {any}
41+
*/
42+
private _fileInterface: any;
43+
44+
/**
45+
* The dynamically discovered platform-specific file interface name.
46+
* @private
47+
* @type {string | undefined}
48+
*/
49+
private readonly _interface: string | undefined;
50+
51+
/**
52+
* Creates an instance of SuFile and initializes the platform-specific file interface.
53+
*
54+
* @param {string} path - The path to the file or directory.
55+
* @throws {ReferenceError} If the platform-specific interface cannot be found.
56+
*/
57+
public constructor(public path: string) {
58+
this._interface = Object.keys(window).find(key => key.match(/^\$(\w{2})FileInputStream$/m));
59+
60+
if (!this._interface) {
61+
throw new ReferenceError("Unable to find a interface SuFile");
62+
}
63+
64+
this._fileInterface = window[this._interface];
65+
}
66+
67+
/**
68+
* Tries to execute a function and provides a fallback value in case of failure.
69+
*
70+
* @private
71+
* @template T
72+
* @param {T} fallback - The fallback value to return in case of failure.
73+
* @param {() => T} fn - The function to execute.
74+
* @param {string} [errorMsg="Unknown error"] - A custom error message to log on failure.
75+
* @returns {T} The result of the function or the fallback value.
76+
*/
77+
private _try<T = any>(fallback: T, fn: () => T, errorMsg: string = "Unknown error"): T {
78+
try {
79+
return fn();
80+
} catch (error) {
81+
console.error(`${errorMsg}:`, error);
82+
return fallback;
83+
}
84+
}
85+
86+
/**
87+
* Reads the file and returns its content.
88+
*
89+
* @returns {string | null} The content of the file or null on failure.
90+
*/
91+
public read(): string | null {
92+
return this._try(null, () => this._fileInterface.read(this.path), `Error while reading from '${this.path}'`);
93+
}
94+
95+
/**
96+
* Writes data to the file.
97+
*
98+
* @param {string} data - The data to write to the file.
99+
* @returns {boolean | null} True if the operation succeeds, otherwise null.
100+
*/
101+
public write(data: string): boolean {
102+
return this._try(false, () => this._fileInterface.write(this.path, data), `Error while writing to '${this.path}'`);
103+
}
104+
105+
/**
106+
* Reads the file's content as a Base64-encoded string.
107+
*
108+
* @returns {string | null} The Base64-encoded content of the file or null on failure.
109+
*/
110+
public readAsBase64(): string | null {
111+
return this._try(null, () => this._fileInterface.readAsBase64(this.path), `Error while reading '${this.path}' as base64`);
112+
}
113+
114+
/**
115+
* Lists the contents of the directory.
116+
*
117+
* @param {string} [delimiter=","] - The delimiter for separating directory contents.
118+
* @returns {string} An array of directory contents or null on failure.
119+
*/
120+
public list(delimiter: string = ","): string {
121+
return this._try("[]", () => this._fileInterface.list(this.path, delimiter), `Error while listing '${this.path}'`);
122+
}
123+
124+
/**
125+
* Gets the size of the file or directory.
126+
*
127+
* @param {boolean} [recursive=false] - Whether to include subdirectories in the size calculation.
128+
* @returns {number} The size in bytes, or 0 on failure.
129+
*/
130+
public size(recursive: boolean = false): number {
131+
return this._try(0, () => this._fileInterface.size(this.path, recursive), `Error while getting size of '${this.path}'`);
132+
}
133+
134+
/**
135+
* Deletes the file or directory.
136+
*
137+
* @returns {boolean} True if the operation succeeds, otherwise false.
138+
*/
139+
public delete(): boolean {
140+
return this._try(false, () => this._fileInterface.delete(this.path), `Error while deleting '${this.path}'`);
141+
}
142+
143+
/**
144+
* Checks if the file or directory exists.
145+
*
146+
* @returns {boolean} True if the file or directory exists, otherwise false.
147+
*/
148+
public exists(): boolean {
149+
return this._try(false, () => this._fileInterface.exists(this.path), `Error while checking existence of '${this.path}'`);
150+
}
151+
152+
/**
153+
* Checks if the path is a directory.
154+
*
155+
* @returns {boolean} True if the path is a directory, otherwise false.
156+
*/
157+
public isDirectory(): boolean {
158+
return this._try(false, () => this._fileInterface.isDirectory(this.path), `Error while checking if '${this.path}' is a directory`);
159+
}
160+
161+
/**
162+
* Checks if the path is a file.
163+
*
164+
* @returns {boolean} True if the path is a file, otherwise false.
165+
*/
166+
public isFile(): boolean {
167+
return this._try(false, () => this._fileInterface.isFile(this.path), `Error while checking if '${this.path}' is a file`);
168+
}
169+
170+
/**
171+
* Checks if the path is a symbolic link.
172+
*
173+
* @returns {boolean} True if the path is a symbolic link, otherwise false.
174+
*/
175+
public isSymLink(): boolean {
176+
return this._try(false, () => this._fileInterface.isSymLink(this.path), `Error while checking if '${this.path}' is a symbolic link`);
177+
}
178+
179+
/**
180+
* Creates a directory at the given path.
181+
*
182+
* @returns {boolean} True if the operation succeeds, otherwise false.
183+
*/
184+
public mkdir(): boolean {
185+
return this._try(false, () => this._fileInterface.mkdir(this.path), `Error while creating directory '${this.path}'`);
186+
}
187+
188+
/**
189+
* Creates directories recursively along the given path.
190+
*
191+
* @returns {boolean} True if the operation succeeds, otherwise false.
192+
*/
193+
public mkdirs(): boolean {
194+
return this._try(false, () => this._fileInterface.mkdirs(this.path), `Error while creating directories '${this.path}'`);
195+
}
196+
197+
/**
198+
* Creates a new file at the given path.
199+
*
200+
* @returns {boolean} True if the operation succeeds, otherwise false.
201+
*/
202+
public createNewFile(): boolean {
203+
return this._try(false, () => this._fileInterface.createNewFile(this.path), `Error while creating file '${this.path}'`);
204+
}
205+
206+
/**
207+
* Renames the file or directory to the specified destination path.
208+
*
209+
* @param {string} destPath - The target path for renaming.
210+
* @returns {boolean} True if the operation succeeds, otherwise false.
211+
*/
212+
public renameTo(destPath: string): boolean {
213+
return this._try(false, () => this._fileInterface.renameTo(this.path, destPath), `Error while renaming '${this.path}' to '${destPath}'`);
214+
}
215+
216+
/**
217+
* Copies the file or directory to the target path.
218+
*
219+
* @param {string} targetPath - The target path for copying.
220+
* @param {boolean} [overwrite=false] - Whether to overwrite existing files at the target location.
221+
* @returns {boolean} True if the operation succeeds, otherwise false.
222+
*/
223+
public copyTo(targetPath: string, overwrite: boolean = false): boolean {
224+
return this._try(false, () => this._fileInterface.copyTo(this.path, targetPath, overwrite), `Error while copying '${this.path}' to '${targetPath}'`);
225+
}
226+
227+
/**
228+
* Checks if the file is executable.
229+
*
230+
* @returns {boolean} True if the file is executable, otherwise false.
231+
*/
232+
public canExecute(): boolean {
233+
return this._try(false, () => this._fileInterface.canExecute(this.path), `Error while checking if '${this.path}' can be executed`);
234+
}
235+
236+
/**
237+
* Checks if the file is writable.
238+
*
239+
* @returns {boolean} True if the file is writable, otherwise false.
240+
*/
241+
public canWrite(): boolean {
242+
return this._try(false, () => this._fileInterface.canWrite(this.path), `Error while checking if '${this.path}' can be written`);
243+
}
244+
245+
/**
246+
* Checks if the file is readable.
247+
*
248+
* @returns {boolean} True if the file is readable, otherwise false.
249+
*/
250+
public canRead(): boolean {
251+
return this._try(false, () => this._fileInterface.canRead(this.path), `Error while checking if '${this.path}' can be read`);
252+
}
253+
254+
/**
255+
* Checks if the file is hidden.
256+
*
257+
* @returns {boolean} True if the file is hidden, otherwise false.
258+
*/
259+
public isHidden(): boolean {
260+
return this._try(false, () => this._fileInterface.isHidden(this.path), `Error while checking if '${this.path}' is hidden`);
261+
}
262+
263+
/**
264+
* Fetches the file as a stream with specified options.
265+
*
266+
* @param {FetchStreamOptions} [options={}] - Options for fetching the file stream.
267+
* @returns {Promise<Response>} A promise that resolves with the file's stream response.
268+
*/
269+
public fetch(options = {}): Promise<Response> {
270+
const mergedOptions = { ...SuFile.defaultFetchStreamOptions, ...options };
271+
272+
return new Promise((resolve, reject) => {
273+
let input: SuFileInputStream | null = null;
274+
try {
275+
input = new SuFileInputStream(this.path);
276+
} catch (e) {
277+
const error = e as ReferenceError;
278+
279+
reject(
280+
new Error("Failed to open file at path '" + this.path + "': " + error.message)
281+
);
282+
283+
return;
284+
}
285+
286+
const abortHandler = () => {
287+
try {
288+
input?.close();
289+
} catch (error) {
290+
console.error("Error during abort cleanup:", error);
291+
}
292+
reject(new DOMException("The operation was aborted.", "AbortError"));
293+
};
294+
295+
if (mergedOptions.signal) {
296+
if (mergedOptions.signal.aborted) {
297+
abortHandler();
298+
return;
299+
}
300+
mergedOptions.signal.addEventListener("abort", abortHandler);
301+
}
302+
303+
const stream = new ReadableStream({
304+
async pull(controller) {
305+
try {
306+
const chunkData = input.readChunk(mergedOptions.chunkSize);
307+
if (!chunkData) {
308+
controller.close();
309+
cleanup();
310+
return;
311+
}
312+
313+
const chunk = JSON.parse(chunkData);
314+
if (chunk && chunk.length > 0) {
315+
controller.enqueue(new Uint8Array(chunk));
316+
} else {
317+
controller.close();
318+
cleanup();
319+
}
320+
} catch (e) {
321+
const error = e as Error;
322+
cleanup();
323+
controller.error(error);
324+
reject(new Error("Error reading file chunk: " + error.message));
325+
}
326+
},
327+
cancel() {
328+
cleanup();
329+
},
330+
});
331+
332+
function cleanup() {
333+
try {
334+
if (mergedOptions.signal) {
335+
mergedOptions.signal.removeEventListener("abort", abortHandler);
336+
}
337+
input?.close();
338+
} catch (error) {
339+
console.error("Error during cleanup:", error);
340+
}
341+
}
342+
343+
resolve(
344+
new Response(stream, {
345+
headers: { "Content-Type": "application/octet-stream" },
346+
})
347+
);
348+
});
349+
};
350+
}

0 commit comments

Comments
 (0)