Initial commit

Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com>
Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com>
Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com>
Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com>
Co-Authored-By: Johan  <4934465+joolsus@users.noreply.github.com>
Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com>
Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
This commit is contained in:
Michael Cartner
2024-01-26 11:52:55 +01:00
commit b9c60b07dc
2789 changed files with 868795 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
modulePathIgnorePatterns: ["tests/testfs"],
};

View File

@@ -0,0 +1,24 @@
{
"name": "@noodl/platform-node",
"version": "2.7.0",
"main": "src/index.ts",
"description": "Cross platform implementation of platform specific features.",
"author": "Noodl <info@noodl.net>",
"homepage": "https://noodl.net",
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@noodl/platform": "file:../noodl-platform",
"fs-extra": "^10.0.1",
"jszip": "^3.10.1",
"mkdirp-sync": "0.0.2",
"rimraf": "^3.0.2"
},
"devDependencies": {
"@types/jest": "^29.0.1",
"ts-jest": "^29.0.0",
"jest": "^29.0.3"
}
}

View File

@@ -0,0 +1,319 @@
import fs from 'fs';
import nodePath from 'path';
import fse, { mkdirp } from 'fs-extra';
import JSZip from 'jszip';
import { FileBlob, FileInfo, FileStat, IFileSystem, OpenDialogOptions } from '@noodl/platform';
export class FileSystemNode implements IFileSystem {
resolve(...paths: string[]): string {
return nodePath.resolve(...paths);
}
join(...paths: string[]): string {
return nodePath.join(...paths);
}
exists(path: string): boolean {
return fs.existsSync(path);
}
dirname(path: string): string {
return nodePath.dirname(path);
}
basename(path: string): string {
return nodePath.basename(path);
}
file(path: string): FileStat {
const stat = fs.lstatSync(path);
return { size: stat.size };
}
writeFile(path: string, blob: FileBlob): Promise<void> {
if (typeof blob === 'string') {
return fs.promises.writeFile(path, Buffer.from(blob));
}
return fs.promises.writeFile(path, blob);
}
async writeFileOverride(path: string, blob: FileBlob): Promise<void> {
try {
await this.removeFile(path);
} catch (error) {
// noop
}
await this.writeFile(path, blob);
}
/**
* Read file content, with utf-8 encoding.
*
* @param path
* @returns
*/
readFile(path: string): Promise<string> {
return fs.promises.readFile(path, 'utf8');
}
async readBinaryFile(path: string): Promise<Buffer> {
const content = await fs.promises.readFile(path, 'binary');
return Buffer.from(content, 'binary');
}
removeFile(path: string): Promise<void> {
return fs.promises.unlink(path);
}
renameFile(oldPath: string, newPath: string): Promise<void> {
return fs.promises.rename(oldPath, newPath);
}
copyFile(from: string, to: string): Promise<void> {
return fs.promises.copyFile(from, to);
}
copyFolder(from: string, to: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
fse.copy(from, to, { recursive: true }, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
/**
* Read a JSON file, with utf-8 encoding.
*
* @param path
* @returns
*/
async readJson<T = any>(path: string): Promise<T> {
const fileContent = await fs.promises.readFile(path, 'utf8');
return JSON.parse(fileContent) as T;
}
async writeJson(path: string, obj: any): Promise<void> {
const tmpFileName = path + '.tmp-' + Date.now();
let jsonText = '';
try {
jsonText = JSON.stringify(obj);
} catch (error) {
console.log('Error serializing json', error);
throw error;
}
try {
await fs.promises.writeFile(tmpFileName, jsonText);
await fs.promises.rename(tmpFileName, path);
} catch (error) {
await fs.promises.unlink(tmpFileName);
console.log('Error writing json file', error);
throw error;
}
}
/**
* Returns whether the folder is empty.
*
* @param path
* @returns Returns true, if the folder is empty; Otherwise, false.
*/
async isDirectoryEmpty(path: string): Promise<boolean> {
const files = await this.listDirectory(path);
return files.length === 0;
}
/**
* List all entries in the directory.
*
* @param path
* @returns A list of all entries.
*/
async listDirectory(path: string): Promise<FileInfo[]> {
const files = await fs.promises.readdir(path);
return files.map(function (f) {
return {
fullPath: path + '/' + f,
name: f,
isDirectory: fs.lstatSync(path + '/' + f).isDirectory()
};
});
}
/**
* Returns all the files including all sub folders.
*
* @param path
* @returns
*/
listDirectoryFiles(path: string): Promise<FileInfo[]> {
// https://stackoverflow.com/a/5827895
const walk = function (dir: string, done: (error: unknown, results?: string[]) => void) {
let results = [];
fs.readdir(dir, function (err, list) {
if (err) return done(err);
let pending = list.length;
if (!pending) return done(null, results);
list.forEach(function (file) {
file = nodePath.resolve(dir, file);
fs.stat(file, function (err, stat) {
if (stat && stat.isDirectory()) {
walk(file, function (err, res) {
results = results.concat(res);
if (!--pending) done(null, results);
});
} else {
results.push(file);
if (!--pending) done(null, results);
}
});
});
});
};
return new Promise<FileInfo[]>((resolve, reject) => {
walk(path, function (error, files) {
if (error) {
reject(error);
} else {
resolve(
files.map(function (fullPath) {
const isDirectory = (function () {
try {
return fs.lstatSync(fullPath).isDirectory();
} catch (_err) {
return false;
}
})();
return {
fullPath,
name: nodePath.basename(fullPath),
isDirectory
};
})
);
}
});
});
}
/**
* https://github.com/jprichardson/node-fs-extra/blob/HEAD/docs/ensureDir.md
* @param path
* @returns
*/
makeDirectory(path: string): Promise<void> {
if (path.length === 0 || fs.existsSync(path)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
mkdirp(path, function (err) {
if (err) reject({ result: 'failure', err: err });
else resolve();
});
});
}
removeDirRecursive(path: string): void {
fse.removeSync(path);
}
openDialog(args: OpenDialogOptions): Promise<string> {
throw new Error('Not Supported');
}
unzipUrl(url: string, to: string): Promise<void> {
const _this = this;
function unzipToFolder(path: string, blob: any, callback: (_: { result: 'success' | 'failure' }) => void) {
JSZip.loadAsync(blob)
.then(function (zip) {
let numFiles = Object.keys(zip.files).length;
let err = false;
function fileCompleted(_success?: boolean) {
numFiles--;
if (numFiles === 0) {
if (err) callback({ result: 'failure' });
else callback({ result: 'success' });
}
}
Object.keys(zip.files).forEach(function (filename) {
if (zip.files[filename].dir) {
fileCompleted();
return;
} // Ignore dirs
let dest, buffer;
zip
.file(filename)
.async('nodebuffer')
.then((_buffer) => {
dest = nodePath.join(path, filename);
buffer = _buffer;
return _this.makeDirectory(nodePath.dirname(dest));
})
.then(() => {
fs.writeFileSync(dest, buffer);
fileCompleted();
})
.catch((e) => {
err = e;
fileCompleted(false);
});
});
})
.catch(function (e) {
callback({ result: 'failure' });
});
}
return new Promise((resolve, reject) => {
// Make sure the folder is empty
const isEmpty = this.isDirectoryEmpty(to);
if (!isEmpty) {
reject({ result: 'failure', message: 'Folder must be empty' });
return;
}
// Load zip file from URL
// @ts-ignore XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onload = function (_e) {
unzipToFolder(to, this.response, function (r) {
if (r.result !== 'success') {
reject({ result: 'failure', message: 'Failed to extract' });
_this.removeDirRecursive(to);
return;
}
resolve();
});
};
xhr.send();
});
}
makeUniquePath(path: string): string {
let _path = path;
let count = 1;
while (fs.existsSync(_path)) {
_path = path + '-' + count;
count++;
}
return _path;
}
}

View File

@@ -0,0 +1,14 @@
import { PlatformOS } from "@noodl/platform";
export function processPlatformToPlatformOS() {
switch (process.platform) {
default:
return PlatformOS.Unknown;
case "darwin":
return PlatformOS.MacOS;
case "win32":
return PlatformOS.Windows;
case "linux":
return PlatformOS.Linux;
}
}

View File

@@ -0,0 +1,8 @@
import { setFileSystem, setPlatform, setStorage } from "@noodl/platform";
import { FileSystemNode } from "./filesystem-node";
import { PlatformNode } from "./platform-node";
import { StorageNode } from "./storage-node";
setPlatform(new PlatformNode());
setFileSystem(new FileSystemNode());
setStorage(new StorageNode());

View File

@@ -0,0 +1,101 @@
import fs from 'fs';
import path from 'path';
import open from 'open';
import { addTrailingSlash, IPlatform, PlatformOS } from '@noodl/platform';
import { processPlatformToPlatformOS } from './helper';
function getAppPath() {
if (fs.existsSync(path.join(process.cwd(), 'package.json'))) {
return process.cwd();
}
if (fs.existsSync(path.join(__dirname, 'package.json'))) {
return __dirname;
}
throw `[@noodl/platform] Cannot find package.json, to get the build version. (${__dirname})`;
}
export class PlatformNode implements IPlatform {
get name(): string {
return 'Node';
}
get os(): PlatformOS {
return this._os;
}
private _os: PlatformOS;
private _userDataPath: string;
private _tempPath: string;
private _appPath: string;
private _buildNumber: string;
private _version: string;
private _versionTag: string;
private _versionId: string;
constructor() {
const os = require('os');
const roamingPath =
process.env.APPDATA ||
(process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + '/.local/share');
this._userDataPath = path.join(roamingPath, 'Noodl');
this._tempPath = addTrailingSlash(os.tmpdir());
this._appPath = addTrailingSlash(getAppPath());
const packagePath = path.join(this._appPath, 'package.json');
// Double check, just to be sure
if (!fs.existsSync(packagePath)) {
throw `[@noodl/platform] Cannot find package.json, to get the build version. ('${packagePath}')`;
}
const packageJson = fs.readFileSync(packagePath, 'utf8');
const packageContent = JSON.parse(packageJson);
this._buildNumber = packageContent.buildNumber || 1;
this._version = packageContent.version;
this._versionId = packageContent.fullVersion;
this._versionTag = packageContent.versionTag;
this._os = processPlatformToPlatformOS();
}
getBuildNumber(): string | undefined {
return this._buildNumber;
}
getFullVersion(): string {
return this._versionId;
}
getVersion(): string {
return this._version;
}
getVersionWithTag(): string {
return this._versionTag ? `${this._version}-${this._versionTag}` : this._version;
}
getUserDataPath(): string {
return this._userDataPath;
}
getDocumentsPath(): string {
throw new Error('Method not supported.');
}
getTempPath(): string {
return this._tempPath;
}
getAppPath(): string {
return this._appPath;
}
async openExternal(url: string): Promise<void> {
await open(url);
}
copyToClipboard(value: string): Promise<void> {
// I really don't want to install more "dependencies" when I don't think
// they will be used...
//
// https://github.com/sindresorhus/clipboardy
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,50 @@
import { platform, filesystem, IStorage } from "@noodl/platform";
import path from "path";
import mkdir from "mkdirp-sync";
import rimraf from "rimraf";
function fileNameForKey(key: string) {
const keyFileName = path.basename(key, ".json") + ".json";
// Prevent ENOENT and other similar errors when using
// reserved characters in Windows filenames.
// See: https://en.wikipedia.org/wiki/Filename#Reserved%5Fcharacters%5Fand%5Fwords
const escapedFileName = encodeURIComponent(keyFileName);
const userDataPath = platform.getUserDataPath();
return path.join(userDataPath, escapedFileName);
}
export class StorageNode implements IStorage {
async get(key: string): Promise<any> {
try {
const filename = fileNameForKey(key);
const fileContent = await filesystem.readJson(filename);
return fileContent;
} catch (error) {
// In some cases there is no json file
// so we just return an empty object.
return {};
}
}
async set(key: string, data: { [key: string]: any }): Promise<void> {
const filename = fileNameForKey(key);
mkdir(path.dirname(filename));
await filesystem.writeJson(filename, data);
}
async remove(key: string): Promise<void> {
var filename = fileNameForKey(key);
return new Promise<void>((resolve, reject) => {
rimraf(filename, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
}

View File

@@ -0,0 +1,83 @@
import { describe, expect } from "@jest/globals";
import { FileSystemNode } from "../src/filesystem-node";
describe("File System", function () {
it("isDirectoryEmpty: false", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/list_tests/folder1"
);
// act
const isEmpty = await filesystem.isDirectoryEmpty(sourcePath);
// assert
expect(isEmpty).toBeFalsy();
});
it("isDirectoryEmpty: true", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/empty_folder"
);
// We cant save empty folders in git
await filesystem.makeDirectory(sourcePath);
// act
const isEmpty = await filesystem.isDirectoryEmpty(sourcePath);
// assert
expect(isEmpty).toBeTruthy();
});
it("listDirectory", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/list_tests"
);
// act
const items = await filesystem.listDirectory(sourcePath);
// assert
expect(items.length).toBe(3);
expect(items[0].name).toBe("file2.txt");
expect(items[0].isDirectory).toBe(false);
expect(items[1].name).toBe("folder1");
expect(items[1].isDirectory).toBe(true);
expect(items[2].name).toBe("folder2");
expect(items[2].isDirectory).toBe(true);
});
it("listDirectoryFiles", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/list_tests"
);
// act
let items = await filesystem.listDirectoryFiles(sourcePath);
items = items.sort((a, b) => (a.name > b.name ? 1 : -1));
// assert
expect(items.length).toBe(4);
expect(items[0].name).toBe("file1.txt");
expect(items[0].isDirectory).toBe(false);
expect(items[1].name).toBe("file2.txt");
expect(items[1].isDirectory).toBe(false);
expect(items[2].name).toBe("file3.txt");
expect(items[2].isDirectory).toBe(false);
expect(items[3].name).toBe("file4.txt");
expect(items[3].isDirectory).toBe(false);
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect } from "@jest/globals";
import { FileSystemNode } from "../src/filesystem-node";
describe("File System", function () {
it("readFile: text.txt", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/file_types/text.txt"
);
// act
const content = await filesystem.readFile(sourcePath);
// assert
expect(content).toBe("Hello World");
});
it("readFile not found", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/file_types/not_found.txt"
);
// act & assert
await expect(() => filesystem.readFile(sourcePath)).rejects.toThrow();
});
it("readJson: json.json", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/file_types/json.json"
);
// act
const content = await filesystem.readJson(sourcePath);
// assert
expect(content).toEqual({ hello: "world" });
});
it("readJson not found", async function () {
// arrange
const filesystem = new FileSystemNode();
const sourcePath = filesystem.join(
process.cwd(),
"/tests/testfs/file_types/not_found.json"
);
// act & assert
await expect(() => filesystem.readJson(sourcePath)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,104 @@
import { describe, expect } from '@jest/globals';
import { FileSystemNode } from '../src/filesystem-node';
import { PlatformNode } from '../src/platform-node';
describe('File System', function () {
// TODO: Skipped because the folder contained package.json, which was picked up by lerna.
xit('can sync dirs', async function () {
const platform = new PlatformNode();
const filesystem = new FileSystemNode();
const tempDir = filesystem.join(platform.getTempPath(), 'noodlunittests-filesystem-' + Date.now());
const destPath = filesystem.join(tempDir, 'dst1');
const sourcePath = filesystem.join(process.cwd(), '/tests/testfs/fs_sync_dir_tests/dst1');
await filesystem.makeDirectory(tempDir);
await filesystem.copyFolder(sourcePath, destPath);
const files = {
'/dst1/popout-will-be-removed.svg': true,
'/dst1/should-be-removed/test.js': true,
'/dst1/project.json': true,
'/dst1/loginsplash.jpg': false,
'/dst1/test.js': false,
'/dst1/test/ajax-loader.gif': false,
'/dst1/one/delete-me/Roboto-Black.ttf': true,
'/dst1/one/two/loginsplash2.jpg': false
};
Object.keys(files).forEach((file) => {
const filePath = filesystem.join(tempDir, file);
const fileExists = filesystem.exists(filePath);
expect(fileExists).toBe(files[file]);
});
});
// TODO: Skipped because the folder contained package.json, which was picked up by lerna.
xit('can remove dirs without a slash ending', async function () {
const platform = new PlatformNode();
const filesystem = new FileSystemNode();
const tempDir = filesystem.join(platform.getTempPath(), 'noodlunittests-filesystem-' + Date.now());
const sourcePath = filesystem.join(process.cwd(), '/tests/testfs/fs_sync_dir_tests/dst1');
await filesystem.makeDirectory(tempDir);
await filesystem.copyFolder(sourcePath, tempDir);
filesystem.removeDirRecursive(tempDir);
expect(filesystem.exists(tempDir)).toBe(false);
});
// TODO: Skipped because the folder contained package.json, which was picked up by lerna.
xit('can remove dirs with a slash ending', async function () {
const platform = new PlatformNode();
const filesystem = new FileSystemNode();
const tempDir = filesystem.join(platform.getTempPath(), 'noodlunittests-filesystem-' + Date.now()) + '/';
const sourcePath = filesystem.join(process.cwd(), '/tests/testfs/fs_sync_dir_tests/dst1');
await filesystem.makeDirectory(tempDir);
await filesystem.copyFolder(sourcePath, tempDir);
filesystem.removeDirRecursive(tempDir);
expect(filesystem.exists(tempDir)).toBe(false);
});
// it("can copy dirs and ignore specific files", async function () {
// const platform = new PlatformNode();
// const filesystem = new FileSystemNode();
//
// const tempDir = filesystem.join(
// platform.getTempPath(),
// "noodlunittests-filesystem-" + Date.now()
// );
// const sourcePath = filesystem.join(
// process.cwd(),
// "/tests/testfs/fs_sync_dir_tests/dst1"
// );
//
// await filesystem.makeDirectory(tempDir);
// await filesystem.copyFolder(sourcePath, tempDir, {
// filter(src) {
// return !src.includes(path.sep + "test" + path.sep);
// },
// });
//
// expect(
// filesystem.exists(filesystem.resolve(tempDir, "test/ajax-loader.gif"))
// ).toBe(false);
// expect(
// filesystem.exists(filesystem.resolve(tempDir, "loginsplash.jpg"))
// ).toBe(true);
// expect(filesystem.exists(filesystem.resolve(tempDir, "project.json"))).toBe(
// true
// );
// expect(filesystem.exists(filesystem.resolve(tempDir, "test.js"))).toBe(
// true
// );
// });
});

View File

@@ -0,0 +1,3 @@
{
"hello": "world"
}

View File

@@ -0,0 +1 @@
Hello World

View File

@@ -0,0 +1 @@
Hello World

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}