diff --git a/.eslintrc.js b/.eslintrc.js index 5aafe75..013012b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { "tsconfigRootDir": __dirname, }, root: true, - ignorePatterns: ["webpack.*", ".eslintrc.js", "forge.config.ts"], + ignorePatterns: ["webpack.*", ".eslintrc.js", "forge.config.ts", "drizzle.config.ts", "util/*"], plugins: ["import"], settings: { "import/resolver": { diff --git a/README.md b/README.md index 4325aea..69a9757 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Scout is a cross platform File Browser. At the moment, functionality is pretty l * Navigate across directories and drives * View basic image, video, and text files * Use arrow keys (<- and ->) to navigate files while viewing in a directory +* Create and show icons for images, including heic files, and video files +* View heic files + ## Why? @@ -22,12 +25,42 @@ My goal is to organize them into folders like cats, weather, etc. But it takes a to generate thumbnails for all of the photos and videos. Worse yet, there's no way (afaik) to trigger the process for all files in a folder - so I have to scroll, wait. Scroll, wait. Directory Opus does a decent job, but the image cache size seemed to be not working when I tried it last, so the images generated then regenerated later. Maybe this is fixed now. -- [ ] Handle Why 2? +- [x] Handle Why 2? I want to be able to see images and navigate forward/back with arrow keys. Windows 10/11 kinda does this with the default photo app, but sometimes it has a hard time figuring next/previous images. I could swear it worked in Windows 7, but, who knows. I want to see if I can create an electron program to help me with these problems. +## Build + +The build produces different artifacts on different operating systems. +If you encounter an issue with packaging or building (possibly with ffmpeg) +try clearing the `node_modules` folder and running npm install again. + +See `Ffmpeg binary` below for more details. + +### Thumbnail Generation + +#### Ffmpeg binary + +To create video thumbnails, ffmpeg is used. This is bundled with the app. +The process is a bit round-a-bout right now. + +1. [Ffmpeg-static](https://github.com/eugeneware/ffmpeg-static), an NPM package, is used to download an appropriate version based on the OS +2. An environment variable, APP_MODE, is used to determine how to properly build for developoment vs packaging. +3. APP_MODE = dev: during the build process with Webpack, this binary is copied into the output. Then, the permissions are modified to make it executable. +4. APP_MODE != dev: the forge.config.js has `extraResources` set to copy ffmpeg +5. The app uses `src/configuration/constants.ts` to determine the proper path for the ffmpeg binary for the main app code, or for the workers. + +Ideally this could be done with a loader, and this mostly works with asset/resource, but I haven't found a nice way to add execution permissions. + +#### Fluent-ffmpeg + +To use Ffmpeg, [Fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) is used. +This doesn't play nicely with webpack as of `2.1.24` due to conditional imports in the `index.js` file. +It also seems to create issues with building on windows and loading other modules. For this reason, it +is currently copied over with `CopyPlugin` (see webpack.main.config.ts), and marked as external. + ## Sources * Icons are from [Bootstrap](https://icons.getbootstrap.com/). Thanks Bootstrap! @@ -41,7 +74,8 @@ I want to see if I can create an electron program to help me with these problems - [ ] Add a UI to indicate going forward/back when viewing a file - [ ] Add footer UI to indicate info: total files in directory, how many selected, total size selected - [ ] View hidden files correctly on windows -- [ ] Cache image icons +- [x] Cache image icons +- [x] Create video thumbnails - [ ] Set up left hand panel with favorites. Drag to change size. - [x] View basic files like txt and images - [ ] Move files diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..3464869 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './src/db/schema/*', + out: './drizzle', + driver: 'better-sqlite', +} satisfies Config; \ No newline at end of file diff --git a/drizzle/0000_brave_garia.sql b/drizzle/0000_brave_garia.sql new file mode 100644 index 0000000..03b6791 --- /dev/null +++ b/drizzle/0000_brave_garia.sql @@ -0,0 +1,9 @@ +CREATE TABLE `image_cache` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `key` text NOT NULL, + `last_modified_time_ms` integer NOT NULL, + `cache_path` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `image_cache_key_unique` ON `image_cache` (`key`);--> statement-breakpoint +CREATE INDEX `key_index` ON `image_cache` (`key`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8b8306c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,66 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "5075dd4b-d9a9-4feb-86f0-7a014e1df027", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "image_cache": { + "name": "image_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified_time_ms": { + "name": "last_modified_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_path": { + "name": "cache_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_cache_key_unique": { + "name": "image_cache_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "key_index": { + "name": "key_index", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..1a8be93 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1705690187768, + "tag": "0000_brave_garia", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/forge.config.ts b/forge.config.ts index a303ad5..512b74a 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -8,10 +8,18 @@ import { WebpackPlugin } from '@electron-forge/plugin-webpack'; import { mainConfig } from './webpack.main.config'; import { rendererConfig } from './webpack.renderer.config'; +import { join } from "node:path"; +import { platform } from "node:os"; + +const binExtension = platform() === "win32" ? ".exe" : ""; const config: ForgeConfig = { packagerConfig: { asar: true, + extraResource: [ + join(__dirname, "node_modules", "ffmpeg-static", "ffmpeg" + binExtension), // downloaded ffmpeg + join(__dirname, "drizzle") // db migrations + ] }, rebuildConfig: {}, makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})], diff --git a/package-lock.json b/package-lock.json index e3639cd..ccd7152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,12 @@ "@libsql/client": "^0.4.0-pre.5", "@radix-ui/react-toolbar": "^1.0.4", "@uidotdev/usehooks": "^2.4.1", + "better-sqlite3": "^9.2.2", "drivelist": "^11.1.0", "drizzle-orm": "^0.29.1", "electron-squirrel-startup": "^1.0.0", + "ffmpeg-static": "^5.2.0", + "fluent-ffmpeg": "^2.1.2", "fswin": "^3.23.311", "heic-decode": "^2.0.0", "rc-virtual-list": "^3.11.3", @@ -32,6 +35,8 @@ "@electron-forge/maker-zip": "^7.2.0", "@electron-forge/plugin-auto-unpack-natives": "^7.2.0", "@electron-forge/plugin-webpack": "^7.2.0", + "@types/better-sqlite3": "^7.6.8", + "@types/fluent-ffmpeg": "^2.1.24", "@types/heic-convert": "^1.2.3", "@types/heic-decode": "^1.1.2", "@types/jest": "^29.5.11", @@ -42,8 +47,10 @@ "@typescript-eslint/parser": "^5.0.0", "@vercel/webpack-asset-relocator-loader": "1.7.3", "autoprefixer": "^10.4.16", + "copy-webpack-plugin": "^12.0.2", + "cross-env": "^7.0.3", "css-loader": "^6.0.0", - "drizzle-kit": "^0.20.6", + "drizzle-kit": "^0.20.7", "electron": "27.1.3", "eslint": "^8.0.1", "eslint-import-resolver-typescript": "^3.6.1", @@ -911,6 +918,20 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@drizzle-team/studio": { "version": "0.0.35", "resolved": "https://registry.npmjs.org/@drizzle-team/studio/-/studio-0.0.35.tgz", @@ -4085,6 +4106,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz", + "integrity": "sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -4195,6 +4228,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz", + "integrity": "sha512-ASndM4rdGrzk7iXXqyNC4fbwt4UEjpK0i3j4q4FyeQrLAthfB6s7EF135ZJE0qQxtKIMFwmyT6x0switET7uIw==", + "devOptional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -4305,6 +4347,15 @@ "@types/send": "*" } }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz", + "integrity": "sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -5135,7 +5186,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -5188,6 +5238,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -5494,6 +5583,11 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5719,6 +5813,16 @@ "dev": true, "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.2.2.tgz", + "integrity": "sha512-qwjWB46il0lsDkeB4rSRI96HyDQr8sxeu1MkBVLMrwusq1KRu4Bpt1TMI+8zIJkDUtZ3umjAkaEjIlokZKWCQw==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -5963,7 +6067,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/bytes": { @@ -6153,6 +6256,11 @@ "tslib": "^2.2.0" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6580,6 +6688,20 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -6658,6 +6780,139 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz", + "integrity": "sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^1.0.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/copy-webpack-plugin/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6710,6 +6965,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7399,9 +7672,9 @@ } }, "node_modules/drizzle-kit": { - "version": "0.20.6", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.20.6.tgz", - "integrity": "sha512-+AYQY+tJUnfMJYIeh6aEjI21mpMCekqz0LEu2QdFdc/3zSmjyfEhH5dkXlRFME8v1rtisiHfp7bP+gVVKDPiUg==", + "version": "0.20.7", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.20.7.tgz", + "integrity": "sha512-3LjTvgVAI1jd3JHLG2tMW5ew49NuD7SMymRv+h9xUxb/geS+U/O1yENni0HhyjZH+Gc8hdStL9v1xY9Ob3s3/g==", "dev": true, "dependencies": { "@drizzle-team/studio": "^0.0.35", @@ -8083,7 +8356,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9145,6 +9417,21 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/ffmpeg-static": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz", + "integrity": "sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==", + "hasInstallScript": true, + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -9303,6 +9590,29 @@ "node": ">= 12" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz", + "integrity": "sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q==", + "dependencies": { + "async": ">=0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -10422,6 +10732,19 @@ } } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -10440,7 +10763,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -11096,7 +11418,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -13649,6 +13970,11 @@ "node": ">=0.10.0" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -14279,7 +14605,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -15411,11 +15736,10 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -16975,6 +17299,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { "version": "4.5.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", @@ -17023,6 +17352,18 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-filename": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", @@ -17345,24 +17686,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -17480,24 +17803,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpack-dev-server/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/webpack-dev-server/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", diff --git a/package.json b/package.json index 9ba2f81..402b81a 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "scout", "productName": "scout", - "version": "0.0.1", + "version": "0.0.2", "description": "Cross platform File Browser", "main": ".webpack/main", "scripts": { - "start": "electron-forge start", + "start": "cross-env APP_MODE=dev electron-forge start", "package": "electron-forge package", "make": "electron-forge make", "publish": "electron-forge publish", "lint": "eslint --ext .ts,.tsx .", "typecheck": "npx tsc --noEmit", + "make:migrations": "drizzle-kit generate:sqlite", "test": "jest" }, "keywords": [ @@ -31,6 +32,8 @@ "@electron-forge/maker-zip": "^7.2.0", "@electron-forge/plugin-auto-unpack-natives": "^7.2.0", "@electron-forge/plugin-webpack": "^7.2.0", + "@types/better-sqlite3": "^7.6.8", + "@types/fluent-ffmpeg": "^2.1.24", "@types/heic-convert": "^1.2.3", "@types/heic-decode": "^1.1.2", "@types/jest": "^29.5.11", @@ -41,8 +44,10 @@ "@typescript-eslint/parser": "^5.0.0", "@vercel/webpack-asset-relocator-loader": "1.7.3", "autoprefixer": "^10.4.16", + "copy-webpack-plugin": "^12.0.2", + "cross-env": "^7.0.3", "css-loader": "^6.0.0", - "drizzle-kit": "^0.20.6", + "drizzle-kit": "^0.20.7", "electron": "27.1.3", "eslint": "^8.0.1", "eslint-import-resolver-typescript": "^3.6.1", @@ -69,9 +74,12 @@ "@libsql/client": "^0.4.0-pre.5", "@radix-ui/react-toolbar": "^1.0.4", "@uidotdev/usehooks": "^2.4.1", + "better-sqlite3": "^9.2.2", "drivelist": "^11.1.0", "drizzle-orm": "^0.29.1", "electron-squirrel-startup": "^1.0.0", + "ffmpeg-static": "^5.2.0", + "fluent-ffmpeg": "^2.1.2", "fswin": "^3.23.311", "heic-decode": "^2.0.0", "rc-virtual-list": "^3.11.3", @@ -81,4 +89,4 @@ "react-virtualized-auto-sizer": "^1.0.20", "sharp": "^0.33.1" } -} +} \ No newline at end of file diff --git a/src/components/file-list/icon-list.tsx b/src/components/file-list/icon-list.tsx index 8c3e386..5f28faf 100644 --- a/src/components/file-list/icon-list.tsx +++ b/src/components/file-list/icon-list.tsx @@ -3,9 +3,8 @@ import List from "rc-virtual-list"; import { useMemo, useRef } from "react"; import { AppFile } from "../../types/filesystem"; import FileIcon from "../file-icon"; -import { isImageExtension, getExtension } from "../../utils/files"; +import { getExtension, canCreateImageIcon } from "../../utils/files"; import { partitionList } from "../../utils/collections"; -import ImageFile from "../file-view/image-file"; import IconImage from "./icon-image"; interface IconItemProps { @@ -29,11 +28,11 @@ function FileIconWrapper({ file, children, width, height, setPath }: IconItemPro function IconItem({ file, width, height, setPath }: IconItemProps) { const extension = getExtension(file.name) || ""; - const isImage = isImageExtension(extension); + const canCreateIcon = canCreateImageIcon(extension); const childHeight = height - 24; - if (isImage) { + if (canCreateIcon) { return ( >) { + } + + async findForKey(key: string): Promise { + const result = await this.db.select().from(schema).where(eq(schema.key, key)); + if (result.length === 0) { + return null; + } + + if (result.length !== 1) { + throw new Error("More than one result found for ImageCache.findForKey"); + } + + return result[0]; + } + + async insert(record: Omit): Promise { + await this.db.insert(schema).values(record); + } + + async updateForKey(key: string, update: Omit): Promise { + await this.db.update(schema).set({ + ...update + }); + } +} \ No newline at end of file diff --git a/src/db/manager.ts b/src/db/manager.ts new file mode 100644 index 0000000..748846d --- /dev/null +++ b/src/db/manager.ts @@ -0,0 +1,27 @@ +import { BetterSQLite3Database, drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate as sqliteMigrate } from "drizzle-orm/better-sqlite3/migrator"; +import SQLiteDatabase from 'better-sqlite3'; +import ImageCacheRepository from './image-cache-repository'; +import { Constants } from 'app/configuration/constants'; + +export default class Manager { + private db: BetterSQLite3Database>; + private imageCacheRepository: ImageCacheRepository; + + constructor(path: string) { + const sqlite = new SQLiteDatabase(path); + this.db = drizzle(sqlite); + + this.imageCacheRepository = new ImageCacheRepository(this.db); + } + + migrate(): void { + sqliteMigrate(this.db, { + migrationsFolder: Constants.instance.getDrizzleMigrationsDirectory() + }); + } + + get imageCache(): ImageCacheRepository { + return this.imageCacheRepository; + } +} \ No newline at end of file diff --git a/src/db/schema/image-cache.ts b/src/db/schema/image-cache.ts new file mode 100644 index 0000000..6343396 --- /dev/null +++ b/src/db/schema/image-cache.ts @@ -0,0 +1,14 @@ +import { text, integer, sqliteTable, index } from "drizzle-orm/sqlite-core"; + +export const imageCache = sqliteTable('image_cache', { + id: integer('id').primaryKey({ autoIncrement: true }), + key: text('key').notNull().unique(), + lastModifiedTimeMs: integer('last_modified_time_ms').notNull(), + cachePath: text('cache_path').notNull() + +}, (table) => ({ + pathIndex: index("key_index").on(table.key) +})); + +export type ImageCache = typeof imageCache.$inferSelect; +export type InsertImageCache = typeof imageCache.$inferInsert; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6a7bbdd..16782a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,17 @@ import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, protocol, net } from 'electron'; -import { readdir, stat, readFile, mkdir } from "node:fs/promises"; -import { basename, resolve, sep, extname } from "node:path"; -import { AppFile, HeicFileResponse, ReaddirOptions } from './types/filesystem'; +import { stat, readFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { AppFile, HeicFileResponse } from './types/filesystem'; import Store from './configuration/store'; -import * as fswin from "fswin"; -import { fileExists } from './server/filesystem'; import WorkerPool from './workers/worker-pool'; import os from 'node:os'; import { TaskAction } from './workers/types'; +import Manager from './db/manager'; +import { ConfigurationNames, Constants } from './configuration/constants'; +import FilesystemServer from './services/filesystem-server'; +import WindowsFilesystemServer from './services/windows-filesystem-server'; +import ImageCache from './services/image-cache'; + // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on @@ -15,7 +19,10 @@ import { TaskAction } from './workers/types'; declare const MAIN_WINDOW_WEBPACK_ENTRY: string; declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; -const pool = new WorkerPool(os.availableParallelism()); +let pool: WorkerPool; +let database: Manager; +let imageCache: ImageCache; +let filesystemServer: FilesystemServer; function formatWindowsAppURL(url: string): string { if (url.length === 1) { @@ -29,36 +36,8 @@ function formatIxAppURL(url: string) { return url; } -function formatWindowsDriveName(drive: string, label: string): string { - if (label === "") { - return drive + ":"; - } - - return `${label} (${drive}:)`; -} - -async function filesystemList(event: IpcMainInvokeEvent, path: string, options: ReaddirOptions) { - if (path === "/" && process.platform === "win32") { - const result: AppFile[] = []; - const drives = fswin.getLogicalDriveListSync(); - for (const drive of Object.keys(drives)) { - const volumeInfo = fswin.getVolumeInformationSync(drive + ":\\\\"); - result.push({ - name: formatWindowsDriveName(drive, volumeInfo.LABEL), - path: drive + ":\\", - isFile: false - }) - } - - return result; - } - const files = await readdir(path, { ...options, withFileTypes: true }); - - return files.map(file => ({ - ...file, - path: resolve(file.path, file.name), - isFile: file.isFile() - })); +async function filesystemList(event: IpcMainInvokeEvent, path: string) { + return await filesystemServer.listDirectory(path); } async function filesystemFileStat(event: IpcMainInvokeEvent, path: string): Promise { @@ -76,49 +55,17 @@ async function filesystemGetTextFileContext(event: IpcMainInvokeEvent, path: str return readFile(path, { encoding: "utf-8" }); } -async function filesystemGetImageIconPath(event: IpcMainInvokeEvent, path: string, width: number, height: number): Promise { - const home = app.getPath('userData'); - const imageCache = resolve(home, "image_cache"); - - const ext = extname(path); - const outputPath = path.slice(0, path.length - ext.length) + ".jpg" - const formattedPath = outputPath.replaceAll(sep, "_"); - - const cachedFilePath = resolve(imageCache, formattedPath); - - if (await fileExists(cachedFilePath)) { - return cachedFilePath; - } - - // Cached file does not exist, create it. - - return new Promise((resolve) => { - pool.runTask({ - type: TaskAction.CreateIcon, - inputPath: path, - outputPath: cachedFilePath, - width, - height - }, (err) => { - if (err) { - console.error(err); - return; - } - - resolve(cachedFilePath); - }); - }); +function filesystemGetImageIconPath(event: IpcMainInvokeEvent, path: string, width: number, height: number): Promise { + return imageCache.getOrCreate(path, width, height); } async function filesystemGetHeicFile(event: IpcMainInvokeEvent, path: string): Promise { - return new Promise((resolve) => { - pool.runTask({ - type: TaskAction.LoadHeicData, - path, - }, (err: any, result: any) => { - resolve(result); - }); + const result = await pool.runTaskPromise({ + type: TaskAction.LoadHeicData, + path, }); + + return result as HeicFileResponse; } // Handle creating/removing shortcuts on Windows when installing/uninstalling. @@ -144,8 +91,9 @@ const createWindow = (): void => { // and load the index.html of the app. mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); - // Open the DevTools. - mainWindow.webContents.openDevTools(); + if (process.env.APP_MODE === "dev") { + mainWindow.webContents.openDevTools(); + } }; // This method will be called when Electron has finished @@ -153,13 +101,34 @@ const createWindow = (): void => { // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { const userDataPath = app.getPath('userData'); - const store = new Store(resolve(userDataPath, "configuration.json")); - const imageCache = resolve(userDataPath, "image_cache"); - if (!await fileExists(imageCache)) { - await mkdir(imageCache) + const constantsArgs = { + appMode: process.env.APP_MODE || "prod", + resourcesPath: process.resourcesPath, + dirname: __dirname + }; + + Constants.init(constantsArgs); + pool = new WorkerPool(os.availableParallelism(), constantsArgs); + + const store = new Store(join(userDataPath, ConfigurationNames.AppSettings)); + + database = new Manager(join(userDataPath, ConfigurationNames.FileDatabase)); + await database.migrate() + + if (process.platform === "win32") { + filesystemServer = new WindowsFilesystemServer(); + } else { + filesystemServer = new FilesystemServer(); } + imageCache = new ImageCache( + join(userDataPath, ConfigurationNames.ImageCache), + database.imageCache, + pool + ); + await imageCache.initialize(); + ipcMain.handle('filesystem-list', filesystemList); ipcMain.handle('filesystem-get-text-file', filesystemGetTextFileContext); ipcMain.handle('filesystem-file-stat', filesystemFileStat); diff --git a/src/preload.ts b/src/preload.ts index 7df8d92..fc68c1f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,7 +1,7 @@ // See the Electron documentation for details on how to use preload scripts: // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts import { contextBridge, ipcRenderer } from "electron"; -import { AppFile, ReaddirOptions } from "./types/filesystem"; +import { AppFile } from "./types/filesystem"; import { ConfigurationOptions } from "./configuration/store"; import { PathLike } from "node:fs"; @@ -12,8 +12,8 @@ contextBridge.exposeInMainWorld('environment', { contextBridge.exposeInMainWorld('appFilesystem', { getTextFileContents: (path: PathLike): Promise => ipcRenderer.invoke('filesystem-get-text-file', path), stat: (path: PathLike): Promise => ipcRenderer.invoke('filesystem-file-stat', path), - readdir: (path: PathLike, options?: ReaddirOptions) - : Promise => ipcRenderer.invoke('filesystem-list', path, options), + readdir: (path: PathLike) + : Promise => ipcRenderer.invoke('filesystem-list', path), getUserHomeDirectory: () => ipcRenderer.invoke('filesystem-get-home-directory'), getImageIconPath: (path: PathLike, width: number, height: number): Promise => ipcRenderer.invoke('filesystem-get-image-icon-path', path, width, height), diff --git a/src/server/ffmpeg-utils.ts b/src/server/ffmpeg-utils.ts new file mode 100644 index 0000000..2ee25ed --- /dev/null +++ b/src/server/ffmpeg-utils.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line import/no-named-as-default +import FfmpegCommand from "fluent-ffmpeg"; +import { join, basename } from "node:path"; + +interface ExtractImageArgs { + inputPath: string; + outputPath: string; + offsetMs: number; + width: number; + height: number; +} + +export default class FfmpegUtils { + constructor(private readonly ffmpegPath: string) { + } + + extractFrame({ inputPath, outputPath, offsetMs, width, height }: ExtractImageArgs): Promise { + return new Promise((resolve, reject) => { + const command = FfmpegCommand({ source: inputPath }); + command.setFfmpegPath(this.ffmpegPath) + .on('error', (err) => reject(err)) + .on('end', () => resolve(outputPath)) + .takeScreenshots({ + count: 1, + timestamps: [offsetMs / 1000], + size: `${width}x${height}`, + folder: join(outputPath, ".."), + filename: basename(outputPath) + }) + .autoPad() + }); + } +} \ No newline at end of file diff --git a/src/server/image.ts b/src/server/image.ts index f15b51a..930ae82 100644 --- a/src/server/image.ts +++ b/src/server/image.ts @@ -4,21 +4,53 @@ import sharp from "sharp"; import decode from 'heic-decode'; import { readFile } from "node:fs/promises"; +import FfmpegUtils from "./ffmpeg-utils"; +import { Constants } from "app/configuration/constants"; + +const VIDEO_EXTENSIONS = new Set([".mov", ".webm", ".mp4"]); + +interface IconArgument { + inputPath: string; + outputPath: string; + width: number; + height: number; +} + +async function createHeicIcon({ inputPath, outputPath, width, height }: IconArgument) { + const inputBuffer = await readFile(inputPath); + const { data, width: imageWidth, height: imageHeight } = await decode({ buffer: inputBuffer }); + + // data is returned as type ImageData, which has 4 channels RGBA + // https://github.com/catdad-experiments/heic-decode + // "When the images are decoded, the return value is a plain object in the format of ImageData" + // https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data + await sharp(data, { + raw: { width: imageWidth, height: imageHeight, channels: 4 } + }).resize(width, height).jpeg().toFile(outputPath); +} + + +async function createVideoIcon({ inputPath, outputPath, width, height }: IconArgument) { + const ffmpeg = new FfmpegUtils(Constants.instance.getFfmpegBinaryPath()); + + await ffmpeg.extractFrame({ + inputPath: inputPath, + outputPath: outputPath, + offsetMs: 1000, + width, + height + }); +} + +export async function createIcon(args: IconArgument): Promise { + const { inputPath, outputPath, width, height } = args; -export async function createIcon(inputPath: string, outputPath: string, width: number, height: number): Promise { const ext = extname(inputPath).toLowerCase(); if (ext === ".heic") { - const inputBuffer = await readFile(inputPath); - const { data, width: imageWidth, height: imageHeight } = await decode({ buffer: inputBuffer }); - - // data is returned as type ImageData, which has 4 channels RGBA - // https://github.com/catdad-experiments/heic-decode - // "When the images are decoded, the return value is a plain object in the format of ImageData" - // https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data - await sharp(data, { - raw: { width: imageWidth, height: imageHeight, channels: 4 } - }).resize(width, height).jpeg().toFile(outputPath); + await createHeicIcon(args); + } else if (VIDEO_EXTENSIONS.has(ext)) { + await createVideoIcon(args); } else { await sharp(inputPath).resize(width, height).jpeg().toFile(outputPath); } diff --git a/src/services/filesystem-client.ts b/src/services/filesystem-client.ts index 27ca471..792d868 100644 --- a/src/services/filesystem-client.ts +++ b/src/services/filesystem-client.ts @@ -25,7 +25,7 @@ export default class FileSystemClient { } async listDir(path: string): Promise { - const results = await this.fileServer.readdir(path, { withFileTypes: false }); + const results = await this.fileServer.readdir(path); this.fileCache.clear(); for (const file of results) { diff --git a/src/services/filesystem-server.ts b/src/services/filesystem-server.ts new file mode 100644 index 0000000..4312c12 --- /dev/null +++ b/src/services/filesystem-server.ts @@ -0,0 +1,21 @@ +import { AppFile } from "app/types/filesystem"; +import { opendir, stat } from "node:fs/promises"; + +export default class FilesystemServer { + async listDirectory(path: string): Promise { + const files: AppFile[] = []; + + const dir = await opendir(path); + for await (const dirent of dir) { + const stats = await stat(dirent.path); + files.push({ + name: dirent.name, + path: dirent.path, + size: stats.size, + isFile: dirent.isFile() + }); + } + + return files; + } +} \ No newline at end of file diff --git a/src/services/image-cache.ts b/src/services/image-cache.ts new file mode 100644 index 0000000..a1fdcdb --- /dev/null +++ b/src/services/image-cache.ts @@ -0,0 +1,102 @@ +import ImageCacheRepository from "app/db/image-cache-repository"; +import { resolve } from "node:path"; +import { stat, mkdir, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import WorkerPool from "app/workers/worker-pool"; +import { fileExists } from "app/server/filesystem"; +import { TaskAction } from "app/workers/types"; +import { NodeJSErrorCode, isNodeJsError } from "app/utils/error"; +import sleep from "app/utils/sleep"; + +export default class ImageCache { + private pendingKeys = new Set(); + private sleepDurationMs = 2; + + constructor( + private cachePath: string, + private imageCacheRepository: ImageCacheRepository, + private workerPool: WorkerPool) { + } + + async initialize(): Promise { + if (!await fileExists(this.cachePath)) { + await mkdir(this.cachePath) + } + } + + private keyPath(path: string, width: number, height: number): string { + return `${path}_${width}x${height}`; + } + + set sleepDuration(ms: number) { + if (ms < 0) { + throw new Error(`Sleep duration must be >= 0. '${ms}' passed in.`); + } + + this.sleepDurationMs = ms; + } + + async getOrCreate(path: string, width: number, height: number): Promise { + const key = this.keyPath(path, width, height); + + while (this.pendingKeys.has(key)) { + await sleep(this.sleepDurationMs); + } + + this.pendingKeys.add(key); + + const [stats, dbRecord] = await Promise.all([stat(path), this.imageCacheRepository.findForKey(key)]); + + if (dbRecord !== null && Math.floor(dbRecord.lastModifiedTimeMs) === Math.floor(stats.mtimeMs)) { + if (await fileExists(dbRecord.cachePath)) { + this.pendingKeys.delete(key); + return dbRecord.cachePath; + } + } + + if (dbRecord !== null) { + try { + await unlink(path); + } catch (err: unknown) { + // Only throw error if it is not file does not exist + if (!isNodeJsError(err) || err.code !== NodeJSErrorCode.ENOENT) { + this.pendingKeys.delete(key); + throw err; + } + } + } + + const outputName = randomUUID() + ".jpg" + const cachedFilePath = resolve(this.cachePath, outputName); + + try { + await this.workerPool.runTaskPromise({ + type: TaskAction.CreateIcon, + inputPath: path, + outputPath: cachedFilePath, + width, + height + }); + } catch (err: unknown) { + this.pendingKeys.delete(key); + throw err; + } + + if (dbRecord === null) { + await this.imageCacheRepository.insert({ + key, + lastModifiedTimeMs: stats.mtimeMs, + cachePath: cachedFilePath + }); + } else { + await this.imageCacheRepository.updateForKey(key, { + lastModifiedTimeMs: stats.mtimeMs, + cachePath: cachedFilePath + }); + } + + this.pendingKeys.delete(key); + + return cachedFilePath; + } +} \ No newline at end of file diff --git a/src/services/windows-filesystem-server.ts b/src/services/windows-filesystem-server.ts new file mode 100644 index 0000000..3f94b5d --- /dev/null +++ b/src/services/windows-filesystem-server.ts @@ -0,0 +1,113 @@ +import * as fswin from "fswin"; +import FilesystemServer from "./filesystem-server"; +import { Dirent, Stats } from "node:fs"; +import { AppFile } from "app/types/filesystem"; + + +class WindowsDriveDirent extends Dirent { + static formatWindowsDriveName(drive: string, label: string): string { + if (label === "") { + return drive + ":"; + } + + return `${label} (${drive}:)`; + } + + constructor(drive: string) { + super(); + + const volumeInfo = fswin.getVolumeInformationSync(drive + ":\\\\"); + this.name = WindowsDriveDirent.formatWindowsDriveName(drive, volumeInfo.LABEL); + this.path = drive + ":\\" + volumeInfo.LABEL; + } + + isFile(): boolean { + return false; + } + + isDirectory(): boolean { + return true; + } + + isBlockDevice(): boolean { + return true; + } + + isCharacterDevice(): boolean { + return true; + } + + isSymbolicLink(): boolean { + return false; + } + + isFIFO(): boolean { + return false; + } + + isSocket(): boolean { + return false; + } +} + +class WindowsDriveStats extends Stats { + constructor(private drive: string) { + super(); + + const drivePath = drive + ":\\\\"; + + this.size = fswin.getVolumeSpaceSync(drivePath).TOTAL; + } + + isFile(): boolean { + return false; + } + + isDirectory(): boolean { + return true; + } + + isBlockDevice(): boolean { + return true; + } + + isCharacterDevice(): boolean { + return true; + } + + isSymbolicLink(): boolean { + return false; + } + + isFIFO(): boolean { + return false; + } + + isSocket(): boolean { + return false; + } +} + +export default class WindowsFilesystemServer extends FilesystemServer { + async listDirectory(path: string): Promise { + if (path === "/") { + const files: AppFile[] = []; + + const drives = fswin.getLogicalDriveListSync(); + for (const drive of Object.keys(drives)) { + const dirent = new WindowsDriveDirent(drive); + const stats = new WindowsDriveStats(drive); + files.push({ + name: dirent.name, + path: dirent.path, + size: stats.size, + isFile: dirent.isFile() + }) + } + + return files; + } + + return super.listDirectory(path); + } +} \ No newline at end of file diff --git a/src/types/filesystem.ts b/src/types/filesystem.ts index f683bf3..1b0d317 100644 --- a/src/types/filesystem.ts +++ b/src/types/filesystem.ts @@ -15,7 +15,7 @@ export type ReaddirOptions = ObjectEncodingOptions & { export interface IFilesystemAPI { getTextFileContents: (path: PathLike) => Promise; stat: (path: PathLike) => Promise>; - readdir: (path: PathLike, options?: ReaddirOptions) => Promise; + readdir: (path: PathLike) => Promise; getUserHomeDirectory: () => Promise; getImageIconPath: (path: PathLike, width: number, height: number) => Promise; getHeicFile: (path: PathLike) => Promise; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..66d7116 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,11 @@ +export enum NodeJSErrorCode { + ENOENT = 'ENOENT' +} + +export interface NodeJSError extends Error { + code: string; +} + +export function isNodeJsError(error: unknown): error is NodeJSError { + return (error as NodeJSError).code !== undefined; +} \ No newline at end of file diff --git a/src/utils/files.ts b/src/utils/files.ts index 184a54e..3266c5c 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -23,7 +23,12 @@ export function isVideoExtension(extension: string): boolean { const e = extension.toLowerCase(); return e === "mp4" || - e === "webm"; + e === "webm" || + e === "mov"; +} + +export function canCreateImageIcon(extension: string): boolean { + return isImageExtension(extension) || isVideoExtension(extension); } const fileSizes = ["B", "KB", "MB", "GB", "TB"]; diff --git a/src/workers/worker-pool.ts b/src/workers/worker-pool.ts index 6924ac6..7eb1810 100644 --- a/src/workers/worker-pool.ts +++ b/src/workers/worker-pool.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'; import { resolve } from 'node:path'; import { Worker } from 'node:worker_threads'; import { WorkerTask } from './types'; +import { ConstantsArguments } from 'app/configuration/constants'; export type TaskInfoCallback = (err: Error | null, result: unknown) => void; @@ -30,7 +31,7 @@ export default class WorkerPool extends EventEmitter { private tasks: WorkerPoolTask[] = []; private workerTasks = new Map(); - constructor(numThreads: number) { + constructor(numThreads: number, private constants: ConstantsArguments) { super(); for (let i = 0; i < numThreads; i++) { @@ -51,7 +52,7 @@ export default class WorkerPool extends EventEmitter { addNewWorker() { // TODO is there a nicer way to get this dependency instead of knowing it eventually becomes .js? - const worker = new Worker(resolve(__dirname, 'worker.js')); + const worker = new Worker(resolve(__dirname, 'worker.js'), { workerData: this.constants }); worker.on('message', (result) => { // In case of success: Call the callback that was passed to `runTask`, // remove the `TaskInfo` associated with the Worker, and mark it as free @@ -111,6 +112,19 @@ export default class WorkerPool extends EventEmitter { worker.postMessage(task); } + runTaskPromise(task: WorkerTask): Promise { + return new Promise((resolve, reject) => { + this.runTask(task, (err: Error, result: unknown) => { + if (err) { + reject(err); + return; + } + + resolve(result); + }); + }); + } + close() { for (const worker of this.workers) { worker.terminate(); diff --git a/src/workers/worker.ts b/src/workers/worker.ts index 2569ab2..dd27da2 100644 --- a/src/workers/worker.ts +++ b/src/workers/worker.ts @@ -4,16 +4,20 @@ import { TaskAction, WorkerTask } from "./types"; import { readFile } from "node:fs/promises"; import decode from 'heic-decode'; import { createIcon } from "app/server/image"; +import { workerData } from "node:worker_threads"; +import { Constants, ConstantsArguments } from "app/configuration/constants"; function main() { if (parentPort === null) { return; } + Constants.init(workerData as ConstantsArguments); + const parent = parentPort; parent.on('message', async (task: WorkerTask) => { if (task.type === TaskAction.CreateIcon) { - await createIcon(task.inputPath, task.outputPath, task.width, task.height); + await createIcon(task); parent.postMessage(task.inputPath); } else if (task.type === TaskAction.LoadHeicData) { diff --git a/util/CopyFileWebpackPlugin.js b/util/CopyFileWebpackPlugin.js new file mode 100644 index 0000000..d6d1ccd --- /dev/null +++ b/util/CopyFileWebpackPlugin.js @@ -0,0 +1,61 @@ +import { validate } from "schema-utils"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { chmodSync } from "node:fs"; + +const pluginName = 'CopyFileWebpackPlugin'; + +// schema for options object +const schema = { + type: 'object', + properties: { + sourcePath: { + type: 'string', + }, + permissions: { + type: 'number' + } + }, +}; + +export default class CopyFileWebpackPlugin { + constructor(options = {}) { + validate(schema, options, { + name: pluginName, + baseDataPath: 'options', + }); + + this.sourcePath = options.sourcePath; + this.permissions = options.permissions; + } + + apply(compiler) { + const { RawSource } = compiler.webpack.sources; + const fileName = basename(this.sourcePath); + + compiler.hooks.thisCompilation.tap(pluginName, (compilation) => { + compilation.hooks.processAssets.tapAsync({ + name: pluginName, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL + }, + async (unusedAssets, callback) => { + try { + const source = new RawSource(await readFile(this.sourcePath)); + compilation.emitAsset(fileName, source); + } catch (err) { + callback(err); + return; + } + + callback(); + } + ) + + compiler.hooks.assetEmitted.tap(pluginName, (file, { targetPath }) => { + if (file === fileName) { + chmodSync(targetPath, this.permissions); + } + }); + }); + } +} diff --git a/webpack.main.config.ts b/webpack.main.config.ts index 53c8926..112f7c8 100644 --- a/webpack.main.config.ts +++ b/webpack.main.config.ts @@ -1,10 +1,34 @@ import type { Configuration } from 'webpack'; -import { resolve } from "node:path"; - +import { join, resolve } from "node:path"; +import { WebpackPluginInstance } from "webpack"; +import { platform } from "node:os"; import { rules } from './webpack.rules'; import { plugins } from './webpack.plugins'; +import CopyPlugin from "copy-webpack-plugin"; +import CopyFileWebpackPlugin from "./util/CopyFileWebpackPlugin"; + +const mainPlugins: WebpackPluginInstance[] = plugins.slice(); +if (process.env.APP_MODE === "dev") { + const extension = platform() === "win32" ? ".exe" : ""; + mainPlugins.push( + new CopyFileWebpackPlugin({ + sourcePath: join(__dirname, "node_modules", "ffmpeg-static", "ffmpeg" + extension), + permissions: 0o755 + }) + ); +} + +mainPlugins.push(new CopyPlugin({ + patterns: [ + { + from: join(__dirname, "node_modules", "fluent-ffmpeg"), + to: "fluent-ffmpeg" + }, + ], +})) + export const mainConfig: Configuration = { /** * This is the main entry point for your application, it's the first file @@ -12,14 +36,14 @@ export const mainConfig: Configuration = { */ entry: { index: './src/index.ts', - "worker-pool": './src/workers/worker_pool.ts', + "worker-pool": './src/workers/worker-pool.ts', worker: './src/workers/worker.ts' }, // Put your normal webpack config below here module: { rules, }, - plugins, + plugins: mainPlugins, resolve: { extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'], alias: { @@ -28,5 +52,8 @@ export const mainConfig: Configuration = { }, output: { filename: '[name].js' + }, + externals: { + 'fluent-ffmpeg': 'fluent-ffmpeg' } }; diff --git a/webpack.plugins.ts b/webpack.plugins.ts index 846aa24..6912027 100644 --- a/webpack.plugins.ts +++ b/webpack.plugins.ts @@ -6,5 +6,5 @@ const ForkTsCheckerWebpackPlugin: typeof IForkTsCheckerWebpackPlugin = require(' export const plugins = [ new ForkTsCheckerWebpackPlugin({ logger: 'webpack-infrastructure', - }), + }) ]; diff --git a/yarn.lock b/yarn.lock index 299959a..4ffcf79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -333,6 +333,16 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@derhuerst/http-basic@^8.2.0": + version "8.2.4" + resolved "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz" + integrity sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw== + dependencies: + caseless "^0.12.0" + concat-stream "^2.0.0" + http-response-object "^3.0.1" + parse-cache-control "^1.0.1" + "@drizzle-team/studio@^0.0.35": version "0.0.35" resolved "https://registry.npmjs.org/@drizzle-team/studio/-/studio-0.0.35.tgz" @@ -1347,6 +1357,11 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz" integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== +"@sindresorhus/merge-streams@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz" + integrity sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw== + "@sinonjs/commons@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz" @@ -1426,6 +1441,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/better-sqlite3@*", "@types/better-sqlite3@^7.6.8": + version "7.6.8" + resolved "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz" + integrity sha512-ASndM4rdGrzk7iXXqyNC4fbwt4UEjpK0i3j4q4FyeQrLAthfB6s7EF135ZJE0qQxtKIMFwmyT6x0switET7uIw== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.19.5" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz" @@ -1507,6 +1529,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/fluent-ffmpeg@^2.1.24": + version "2.1.24" + resolved "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz" + integrity sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A== + dependencies: + "@types/node" "*" + "@types/fs-extra@^9.0.1": version "9.0.13" resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz" @@ -1642,6 +1671,11 @@ dependencies: undici-types "~5.26.4" +"@types/node@^10.0.3": + version "10.17.60" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" + integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== + "@types/node@^18.11.18": version "18.19.0" resolved "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz" @@ -2097,7 +2131,17 @@ ajv@^6.12.4, ajv@^6.12.5, ajv@^6.9.1: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.8.2, ajv@^8.9.0: +ajv@^8.0.0: + version "8.12.0" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ajv@^8.8.2, ajv@^8.9.0: version "8.12.0" resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== @@ -2293,6 +2337,11 @@ astral-regex@^2.0.0: resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async@>=0.2.9: + version "3.2.5" + resolved "https://registry.npmjs.org/async/-/async-3.2.5.tgz" + integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" @@ -2400,6 +2449,14 @@ batch@0.6.1: resolved "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +better-sqlite3@^9.2.2, better-sqlite3@>=7: + version "9.2.2" + resolved "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.2.2.tgz" + integrity sha512-qwjWB46il0lsDkeB4rSRI96HyDQr8sxeu1MkBVLMrwusq1KRu4Bpt1TMI+8zIJkDUtZ3umjAkaEjIlokZKWCQw== + dependencies: + bindings "^1.5.0" + prebuild-install "^7.1.1" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" @@ -2645,6 +2702,11 @@ capnp-ts@^0.7.0: debug "^4.3.1" tslib "^2.2.0" +caseless@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" + integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== + chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" @@ -2936,6 +2998,16 @@ concat-map@0.0.1: resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + connect-history-api-fallback@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz" @@ -2980,6 +3052,18 @@ copy-anything@^3.0.2: dependencies: is-what "^4.1.8" +copy-webpack-plugin@^12.0.2: + version "12.0.2" + resolved "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz" + integrity sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA== + dependencies: + fast-glob "^3.3.2" + glob-parent "^6.0.1" + globby "^14.0.0" + normalize-path "^3.0.0" + schema-utils "^4.2.0" + serialize-javascript "^6.0.2" + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" @@ -3024,6 +3108,13 @@ create-require@^1.1.0: resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + cross-spawn-windows-exe@^1.1.0, cross-spawn-windows-exe@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz" @@ -3394,10 +3485,10 @@ drivelist@^11.1.0: node-addon-api "^5.0.0" prebuild-install "^7.1.1" -drizzle-kit@^0.20.6: - version "0.20.6" - resolved "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.20.6.tgz" - integrity sha512-+AYQY+tJUnfMJYIeh6aEjI21mpMCekqz0LEu2QdFdc/3zSmjyfEhH5dkXlRFME8v1rtisiHfp7bP+gVVKDPiUg== +drizzle-kit@^0.20.7: + version "0.20.7" + resolved "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.20.7.tgz" + integrity sha512-3LjTvgVAI1jd3JHLG2tMW5ew49NuD7SMymRv+h9xUxb/geS+U/O1yENni0HhyjZH+Gc8hdStL9v1xY9Ob3s3/g== dependencies: "@drizzle-team/studio" "^0.0.35" "@esbuild-kit/esm-loader" "^2.5.5" @@ -4127,7 +4218,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.1: +fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.1, fast-glob@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -4184,6 +4275,16 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" +ffmpeg-static@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz" + integrity sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA== + dependencies: + "@derhuerst/http-basic" "^8.2.0" + env-paths "^2.2.0" + https-proxy-agent "^5.0.0" + progress "^2.0.3" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" @@ -4288,6 +4389,14 @@ flora-colossus@^2.0.0: debug "^4.3.4" fs-extra "^10.1.0" +fluent-ffmpeg@^2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz" + integrity sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q== + dependencies: + async ">=0.2.9" + which "^1.1.1" + follow-redirects@^1.0.0: version "1.15.3" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz" @@ -4598,6 +4707,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" @@ -4719,6 +4835,18 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +globby@^14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz" + integrity sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ== + dependencies: + "@sindresorhus/merge-streams" "^1.0.0" + fast-glob "^3.3.2" + ignore "^5.2.4" + path-type "^5.0.0" + slash "^5.1.0" + unicorn-magic "^0.1.0" + gopd@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz" @@ -4986,6 +5114,13 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-response-object@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz" + integrity sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA== + dependencies: + "@types/node" "^10.0.3" + http2-wrapper@^1.0.0-beta.5.2: version "1.0.3" resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz" @@ -5038,7 +5173,7 @@ ieee754@^1.1.13: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^5.2.0: +ignore@^5.2.0, ignore@^5.2.4: version "5.3.0" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== @@ -6906,6 +7041,11 @@ parse-author@^2.0.0: dependencies: author-regex "^1.0.0" +parse-cache-control@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz" + integrity sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg== + parse-json@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz" @@ -7005,6 +7145,11 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path-type@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz" + integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== + pend@~1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" @@ -7407,7 +7552,7 @@ readable-stream@^2.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -7715,6 +7860,16 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +schema-utils@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz" + integrity sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz" @@ -7791,10 +7946,10 @@ serialize-error@^7.0.1: dependencies: type-fest "^0.13.1" -serialize-javascript@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz" - integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w== +serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== dependencies: randombytes "^2.1.0" @@ -7965,6 +8120,11 @@ slash@^3.0.0: resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slash@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz" @@ -8677,6 +8837,11 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + typescript@*, typescript@>=2.7, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", "typescript@>=4.3 <6", typescript@>3.6.0, typescript@~4.5.4: version "4.5.5" resolved "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz" @@ -8709,6 +8874,11 @@ undici@^5.22.1: dependencies: "@fastify/busboy" "^2.0.0" +unicorn-magic@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz" + integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== + unique-filename@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz" @@ -8982,6 +9152,13 @@ which-typed-array@^1.1.11, which-typed-array@^1.1.13: gopd "^1.0.1" has-tostringtag "^1.0.0" +which@^1.1.1: + version "1.3.1" + resolved "https://registry.npmjs.org/which/-/which-1.3.1.tgz" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + which@^1.2.14: version "1.3.1" resolved "https://registry.npmjs.org/which/-/which-1.3.1.tgz"