diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 97a73b6..0000000 --- a/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": ["@babel/preset-env", "@babel/preset-react"], - "plugins": [ - ["@babel/transform-runtime"] -] -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1d35cd0..c66c6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,96 +1,28 @@ -electron-old +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock -.DS_Store - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm +# dependencies +/node_modules +/.pnp +.pnp.js -# Optional eslint cache -.eslintcache +# testing +/coverage -# Optional REPL history -.node_repl_history +# production +/build -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -# .env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Webpack -.webpack/ +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local -# Electron-Forge -out/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* -electron/src/env.json -# ts -dist +shared/dist +shared/node_modules +electron/renderer-frontend/ diff --git a/README.md b/README.md index 74cbd36..214d9da 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,130 @@ # taggr -Rediscover your **memories** while keeping your **privacy**. +> Rediscover your **memories** while keeping your **privacy**. -Powered by machine learning. +Powered by [TypeScript](https://www.typescriptlang.org/), [Electron](https://www.electronjs.org/), [React](https://reactjs.org/), [Redux](https://redux-toolkit.js.org/), [Node.js](https://nodejs.org/en/) and [TensorFlow.js](https://www.tensorflow.org/) 🚀 -## Architecture +![taggr screenshot](./test-images/screenshot.png "taggr") -`frontend` and `backend` are one. +

👉 Keep up to date with my next side-projects 👈

-**Modularized structure** for UI and backend, running on separated `BrowserWindow` processes: `renderer` for UI, `background` for backend. +## Motivation -This allows to perform the long and resource intensive backend operations without blocking the UI thread (in development only). In production, the backend BrowserWindow is hidden. +There is great software out there that provides image exploration capabilities using machine learning (Google Photos, iCloud), but generally is not build with privacy in mind. -**Message passing** interconnection betweeen modules, through [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API). +At the end of the day, you have to upload your pictures to a server (which perform the machine learning operations), so you have to trust a third party with your data. -Since the backend executes long running tasks, sync connections are not an option. The message passing acts as a the communication interfact between modules. Each module (FE/BE) implements a `message-handler`, which deal with the incomming (`message`) on a given topic. +What if we could run image classification and tagging machine learning operations 100% locally? +You dont have to trust a server if there is no server 😉 -Available messaging topics and action cretors are shared and centralized in `./shared/message-passing`. +👇 _Under this premise, **taggr** was born_ 👇 -## Environments +A photo explorer, which uses offline machine learning for enriched exploration. -3 app environments. `DEVELOP`, `BUILD_TEST` and `BUILD_PROD`. +Build with privacy in mind, all the image processing is performed locally, and no data ever leaves you computer 😊 -Manually set the value in `src/shared/active-env.js`. +## High-level architecture -## Publishing +This is my first electron project, so I iterated multiple times until I settled on a general strucutere I was happy with (at developer experience and performance levels). -Run: +In my case, I found the sweet spot by keeping as close to the web standard as possible, and leveraging the existing web / Node.js tooling that already exists. That mweant -```javascript -npm run publish -``` +**taggr** is composed by two main modules (`frontend`, `backend`), a `shared` module, and a `communication bus`. + +The app is split into two distinct and independent processes, the `frontend` and the `backend` (mapping to the main modules), for the sake of separation of concerns. Each process runs in an [independent Electron process](https://blog.logrocket.com/advanced-electron-js-architecture/). + +### Message bus + +Before we cover the modules, lets discuss the communication layer. + +The `frontend` and `backend` modules communicate through an asynchonous and bidirectional message bus. +Its implemented using Electron's [IPC module](https://www.electronjs.org/docs/latest/api/ipc-main/). + +The supported events are defined as types in the `shared` module, so type-safe handlers can be implemented in either side of the bus. For example in `frontend/src/message-bus/index.ts`. + +Since the message bus relies on the `BrowserWindow.id`, the Electron main process keeps a heartbeat with the render process ids. + +### Frontend → `./frontend` + +The 'face' of the app, this module takes care of all things UI. + +It does **not** hold business logic. It communicates with the `backend` for performing business logic operations (through the message bus). + +**Built with Typescript + React components**, following (loosely) the [Atomic Design Principles](https://bradfrost.com/blog/post/atomic-web-design/). I used [Storybook](https://storybook.js.org/) for that. + +The whole UI is **[controlled](https://www.robinwieruch.de/react-controlled-components)**, so it renders determinstically based on props, using [pure componets](https://www.geeksforgeeks.org/reactjs-pure-components/). Note that some state is kept local with Hooks, but thats UI state (ex. input contents before submission). + +Uses the **'smart' and 'dumb' component** [pattern](https://jaketrent.com/post/smart-dumb-components-react), only the `Page` component have side effects, passed as props by container components. The whole UI can be tested and migrated form Redux and Electron easily. Check `frontend/src/components/pages/**/WithStore.tsx` for examples. + +In order to deploy the app, the `frontend` gets build into static assets and copied over to the `backend` module. + +### Backend → `./electron` + +The 'brain' of the app, this module focuses on the business logic, processing and persistence of the data. + +The module also contains the Electron logic for bootstraping the render processes (one for `frontend` and another for the `backend`) and for packaging the app. -Generated prod buil and updates the `taggr-releases` repo. Generate build in windows and update it manually. Make sure that the `taggr` version in the package.json is updated. +**Written in TypeScript**, it operates as a Node.js backed. Runs withing a [Electron renderer process](https://www.electronjs.org/docs/latest/tutorial/process-model), with the Node.js APIs enabled. Source code available in `./electron/renderer-backend/src` -1. Execute `npm run publish` -2. Increate the version in `electron/package.json` -3. Build windows and upload manually. +The message bus handler triggers [transactional scripts](https://martinfowler.com/eaaCatalog/transactionScript.html), which composes the functionality provided by a service layer. -### Releases +The service layer uses dependency injection through [factory functions](https://www.javascripttutorial.net/javascript-factory-functions/), so it can be easily tested with unit tests. + +The machine-learning uses classification and object recognition for extracting searchable tags from images, through [Tensorflow](https://github.com/tensorflow/tfjs). + +The storage of extracted tags is managed using [electron-store](https://github.com/sindresorhus/electron-store). + +### Shared → `./shared` + +A type-only module, helps keep type consistency between `frontend` and `backend`. + +It keeps shared data such as the available frontend routes, the supported message bus messages and typed representations of the shared domain entities (such as `Image`). + +This enables compile-time checks on the touch points at the message bus level. Also, it helps keep domain entities consistently typed accross the app. + +### Environments + +The app can be configured to run in `development` and `production` environtments, by setting a variable in the `shared` module. + +- `development`: the frontend runs in a separate process and is loaded into electron as a url. + +- `production`: the frontend is loaded from static files, and the backend window is hidden. All the debugging tools and extensions are not mounted. + +## Run it + +Requires `"node": ">=14.0.0"` and `"yarn": "^1.22.0"`. + +```bash +# install dependencies +yarn + +# run unit test +yarn test:ci + +# start app +yarn app + +# build app +yarn build +``` -https://github.com/aperkaz/taggr-releases/releases +## Releases -## Future Features + -- Layout masonry: https://github.com/bvaughn/react-virtualized/issues/1366 -- Certificate trust increase: https://support.ksoftware.net/support/solutions/articles/215894-what-is-this-file-is-not-commonly-downloaded-and-could-harm-your-computer-message-smartscreen- -- Add github actions build: https://github.com/malept/electron-forge-demo123/actions/runs/116519042/workflow -- Some images are displayed rotated, example in thailand trip -- Replace gallery view with lazy loading: https://github.com/xiaolin/react-image-gallery -- Timeline with pictures https://github.com/rmariuzzo/react-chronos -- Timeline display of images per day http://tany.kim/quantify-your-year/#/ -- Add more ML: look into tensorflow alternatives: evaluate performance: with article https://learn.ml5js.org/docs/#/reference/face-api?id=demo -- Speed up app by paralelization. Example: https://github.com/aperkaz/tensorflow-playground -- Food classification: https://github.com/stratospark/food-101-keras/issues/14 -- File sharing options: - https://share.storewise.tech/upload - https://safenote.co/upload-file ?? +## Future -### Not so relevant +**taggr** has been a great side project for the past year, I learned plenty about how Electron works internally, how to structure controlled frontends and had lots of fun 🎉 -- micro animations: https://www.joshwcomeau.com/react/boop/ -- Add node_modules migration, to fix the known issues. -- Image editor: https://ui.toast.com/tui-image-editor/ +I have other ideas I want to develop, so I dont plan on working on taggr any time soon. -## Known Issues +Feel free to **fork** or open PRs!! -- **Windows / Mac build**: the tfjs bindings for windows are not located properly, they are built into `napi-v6`, it should be renamed to `napi-v5`. In `electron/node_modules/@tensorflow/tfjs-node/lib/napi-v6`, rename. +## Credit -## Resources of interes +Here are some of the great resources I have leveraged to build **taggr**, in no particular order: -- Modern electron apps: https://github.com/jlongster/electron-with-server-example -- High-level project structure: https://blog.axosoft.com/electron-things-to-know/ -- Window builds fail randomly due to problems with the cache. Try to clean cache and delete package-lock as in: https://github.com/cncjs/cncjs/issues/172 -- EU vat (local VAT up to 10.000E a year): https://europa.eu/youreurope/business/selling-in-eu/selling-goods-services/provide-services-abroad/index_es.htm#rules-annual-turnovers +- [Great electron boilerplate](https://github.com/sindresorhus/electron-boilerplate) +- [Modern electron apps](https://archive.jlongster.com/secret-of-good-electron-apps) +- [Loading the Frontend from a separate process](https://medium.com/@kitze/%EF%B8%8F-from-react-to-an-electron-app-ready-for-production-a0468ecb1da3?p=a0468ecb1da3) +- [awesome-electron](https://github.com/sindresorhus/awesome-electron) diff --git a/electron/.editorconfig b/electron/.editorconfig new file mode 100644 index 0000000..1c6314a --- /dev/null +++ b/electron/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/electron/.eslintrc.js b/electron/.eslintrc.js new file mode 100644 index 0000000..4cd7deb --- /dev/null +++ b/electron/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 13, + sourceType: "module", + }, + plugins: ["@typescript-eslint"], + rules: {}, + excludes: "/*.js", +}; diff --git a/electron/.gitattributes b/electron/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/electron/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/electron/.gitignore b/electron/.gitignore new file mode 100644 index 0000000..89a09e3 --- /dev/null +++ b/electron/.gitignore @@ -0,0 +1,6 @@ +node_modules +yarn.lock +/dist +/renderer-backend/transpiled/** +/renderer-frontend/** +noise.png diff --git a/electron/build/background.png b/electron/build/background.png new file mode 100644 index 0000000..21e9c9e Binary files /dev/null and b/electron/build/background.png differ diff --git a/electron/build/background@2x.png b/electron/build/background@2x.png new file mode 100644 index 0000000..6c79aee Binary files /dev/null and b/electron/build/background@2x.png differ diff --git a/electron/build/icon.png b/electron/build/icon.png new file mode 100644 index 0000000..10857fd Binary files /dev/null and b/electron/build/icon.png differ diff --git a/electron/index.js b/electron/index.js new file mode 100644 index 0000000..e2da752 --- /dev/null +++ b/electron/index.js @@ -0,0 +1,196 @@ +"use strict"; +const path = require("path"); +const { app, BrowserWindow, Menu } = require("electron"); +/// const {autoUpdater} = require('electron-updater'); +const { is } = require("electron-util"); +const unhandled = require("electron-unhandled"); +const debug = require("electron-debug"); +const envPaths = require("env-paths"); +const contextMenu = require("electron-context-menu"); +const { IS_DEV } = require("taggr-shared"); + +const menu = require("./menu.js"); + +try { + require("electron-reloader")(module, { + ignore: ["dist", "renderer-backend/src", "noise.png"], + // debug: true, + }); +} catch {} + +unhandled(); +debug(); +contextMenu(); + +// Note: Must match `build.appId` in package.json +app.setAppUserModelId("com.company.AppName"); + +// Uncomment this before publishing your first version. +// It's commented out as it throws an error if there are no published versions. +// if (!is.development) { +// const FOUR_HOURS = 1000 * 60 * 60 * 4; +// setInterval(() => { +// autoUpdater.checkForUpdates(); +// }, FOUR_HOURS); +// +// autoUpdater.checkForUpdates(); +// } + +// Prevent window from being garbage collected +let backendWindow = { id: 1 }; +let frontendWindow = { id: 2 }; + +const createBackendWindow = async () => { + const win = new BrowserWindow({ + title: app.name, + show: IS_DEV, + x: 0, + y: 0, + width: 900, + height: 500, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + webSecurity: false, + backgroundThrottling: false, + preload: path.join(__dirname, "preload.js"), + }, + }); + + win.on("ready-to-show", () => { + if (IS_DEV) win.show(); + }); + + win.on("closed", () => { + // Dereference the window + // For multiple windows store them in an array + backendWindow = undefined; + }); + + await win.loadFile(path.join(__dirname, "renderer-backend", "index.html")); + + if (IS_DEV) { + win.webContents.openDevTools(); + } + + return win; +}; +const createFrontendWindow = async () => { + const win = new BrowserWindow({ + title: app.name, + show: true, + x: 0, + y: 500, + width: 900, + height: 600, + webPreferences: { + nodeIntegration: true, + backgroundThrottling: false, + contextIsolation: false, + enableRemoteModule: true, + webSecurity: false, + preload: path.join(__dirname, "preload.js"), + }, + }); + + win.on("ready-to-show", () => { + win.show(); + if (!IS_DEV) win.maximize(); + }); + + win.on("closed", () => { + // Dereference the window + // For multiple windows store them in an array + frontendWindow = undefined; + }); + + await win.loadURL( + IS_DEV + ? "http://localhost:3001" + : `file://${path.join(__dirname, "renderer-frontend/index.html")}` + ); + + if (IS_DEV) { + win.webContents.openDevTools(); + } + + return win; +}; + +// Prevent multiple instances of the app +if (!app.requestSingleInstanceLock()) { + app.quit(); +} + +app.on("second-instance", () => { + if (frontendWindow) { + if (frontendWindow.isMinimized()) { + frontendWindow.restore(); + } + + frontendWindow.show(); + } +}); + +app.on("window-all-closed", () => { + if (!is.macos) { + app.quit(); + } +}); + +app.on("activate", async () => { + if (!backendWindow) { + backendWindow = await createBackendWindow(); + } + if (!frontendWindow) { + frontendWindow = await createFrontendWindow(); + } +}); + +// Add extensions: https://github.com/MarshallOfSound/electron-devtools-installer +app.whenReady().then(async () => { + if (IS_DEV) { + const { + default: installExtension, + REACT_DEVELOPER_TOOLS, + REDUX_DEVTOOLS, + } = require("electron-devtools-installer"); + + installExtension([REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log("An error occurred: ", err)); + } + + // create folder if it doesnt exist + const fs = require("fs"); + + if (!fs.existsSync(envPaths("taggr").data)) { + fs.mkdirSync(envPaths("taggr").data); + } + + // Add app menus + Menu.setApplicationMenu(menu); + backendWindow = await createBackendWindow(); + frontendWindow = await createFrontendWindow(); + + // send webContentId, so render-to-render ipc communication can happen + frontendWindow.webContents.send("taggr-ipc-setup", { + beWebContentId: backendWindow.id, + feWebContentId: frontendWindow.id, + }); + backendWindow.webContents.send("taggr-ipc-setup", { + beWebContentId: backendWindow.id, + feWebContentId: frontendWindow.id, + }); + // heartbeat with webContent ids + setInterval(() => { + frontendWindow.webContents.send("taggr-ipc-setup", { + beWebContentId: backendWindow.id, + feWebContentId: frontendWindow.id, + }); + backendWindow.webContents.send("taggr-ipc-setup", { + beWebContentId: backendWindow.id, + feWebContentId: frontendWindow.id, + }); + }, 1000); +}); diff --git a/electron/jest.config.js b/electron/jest.config.js new file mode 100644 index 0000000..b769cd6 --- /dev/null +++ b/electron/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["!**/renderer-backend/transpiled/**", "**/**/*.(spec).ts"], +}; diff --git a/electron/license b/electron/license new file mode 100644 index 0000000..76dcfda --- /dev/null +++ b/electron/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) <%= name %> <<%= email %>> (<%= website %>) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/electron/menu.js b/electron/menu.js new file mode 100644 index 0000000..b614447 --- /dev/null +++ b/electron/menu.js @@ -0,0 +1,178 @@ +"use strict"; +const path = require("path"); +const { app, Menu, shell } = require("electron"); +const { + is, + appMenu, + aboutMenuItem, + openUrlMenuItem, + openNewGitHubIssue, + debugInfo, +} = require("electron-util"); + +const showPreferences = () => { + // Show the app's preferences here +}; + +const helpSubmenu = [ + openUrlMenuItem({ + label: "Website", + url: "https://github.com/aperkaz/taggr", + }), + openUrlMenuItem({ + label: "Source Code", + url: "https://github.com/aperkaz/taggr", + }), + { + label: "Report an Issue…", + click() { + const body = ` + + + +--- + +${debugInfo()}`; + + openNewGitHubIssue({ + user: "aperkaz", + repo: "taggr", + body, + }); + }, + }, +]; + +if (!is.macos) { + helpSubmenu.push( + { + type: "separator", + }, + aboutMenuItem({ + icon: path.join(__dirname, "static", "icon.png"), + text: "Created by Alain Perkaz", + }) + ); +} + +const debugSubmenu = [ + { + label: "Show Settings", + click() { + config.openInEditor(); + }, + }, + { + label: "Show App Data", + click() { + shell.openItem(app.getPath("userData")); + }, + }, + { + type: "separator", + }, + { + label: "Delete Settings", + click() { + config.clear(); + app.relaunch(); + app.quit(); + }, + }, + { + label: "Delete App Data", + click() { + shell.moveItemToTrash(app.getPath("userData")); + app.relaunch(); + app.quit(); + }, + }, +]; + +const macosTemplate = [ + appMenu([ + { + label: "Preferences…", + accelerator: "Command+,", + click() { + showPreferences(); + }, + }, + ]), + { + role: "fileMenu", + submenu: [ + { + label: "Custom", + }, + { + type: "separator", + }, + { + role: "close", + }, + ], + }, + { + role: "editMenu", + }, + { + role: "viewMenu", + }, + { + role: "windowMenu", + }, + { + role: "help", + submenu: helpSubmenu, + }, +]; + +// Linux and Windows +const otherTemplate = [ + { + role: "fileMenu", + submenu: [ + { + label: "Custom", + }, + { + type: "separator", + }, + { + label: "Settings", + accelerator: "Control+,", + click() { + showPreferences(); + }, + }, + { + type: "separator", + }, + { + role: "quit", + }, + ], + }, + { + role: "editMenu", + }, + { + role: "viewMenu", + }, + { + role: "help", + submenu: helpSubmenu, + }, +]; + +const template = is.macos ? macosTemplate : otherTemplate; + +if (is.development) { + template.push({ + label: "Debug", + submenu: debugSubmenu, + }); +} + +module.exports = Menu.buildFromTemplate(template); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..0f79e7a --- /dev/null +++ b/electron/package.json @@ -0,0 +1,108 @@ +{ + "name": "taggr-electron", + "productName": "taggr", + "version": "0.0.4", + "description": "Rediscover your memories while keeping your privacy", + "license": "MIT", + "repository": "https://github.com/aperkaz/taggr", + "engines": { + "node": ">=14.0.0", + "yarn": "^1.22.0" + }, + "main": "index.js", + "author": { + "name": "Alain Perkaz", + "email": "alainperkaz@gmail.com", + "url": "https://alainperkaz.com/" + }, + "scripts": { + "postinstall": "electron-builder install-app-deps", + "lint": "eslint renderer-backend/src/* ", + "test": "jest --clearCache ; jest", + "test:ci": "jest --clearCache ; jest --forceExit --ci --runInBand", + "tsc": "rimraf renderer-backend/transpiled ; tsc", + "tsc:watch": "tsc -w --preserveWatchOutput", + "start": "ELECTRON_IS_DEV=1 concurrently -k -p \"[{name}]\" -n \"electron,tsc\" -c \"cyan.bold,green.bold\" \"wait-on http://localhost:3001 ; electron .\" \"yarn tsc:watch\"", + "build:frontend": "rimraf renderer-frontend/** ; mkdir renderer-frontend ; yarn --cwd ../frontend build ; cp -R ../frontend/build/ ./renderer-frontend", + "build:backend": "rimraf renderer-backend/transpiled/** ; mkdir renderer-backend/transpiled ; yarn tsc", + "build:pre": "concurrently \"yarn build:frontend\" \"yarn build:backend\"", + "build": "yarn build:pre ; rimraf -r dist ; yarn dist", + "dist": "electron-builder --macos", + "pack": "electron-builder --dir", + "release": "np" + }, + "dependencies": { + "@tensorflow-models/coco-ssd": "^2.0.2", + "@tensorflow-models/mobilenet": "^2.0.4", + "@tensorflow/tfjs-node": "^1.7.2", + "electron-better-ipc": "^2.0.1", + "electron-context-menu": "^3.0.0", + "electron-debug": "^3.2.0", + "electron-store": "^8.0.0", + "electron-unhandled": "^3.0.2", + "electron-updater": "^4.3.8", + "electron-util": "^0.15.1", + "exif": "^0.6.0", + "lodash.get": "^4.4.2", + "lodash.range": "^3.2.0", + "normalize-path": "^3.0.0", + "parse-dms": "^0.0.5", + "readdirp": "^3.6.0", + "rimraf": "^3.0.2", + "taggr-shared": "1.0.0", + "typescript": "^4.4.4", + "wait-on": "^6.0.0" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.15.0", + "@types/exif": "^0.6.3", + "@types/jest": "^27.0.2", + "@types/lodash.get": "^4.4.6", + "@types/lodash.range": "^3.2.6", + "@types/normalize-path": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^5.2.0", + "@typescript-eslint/parser": "^5.2.0", + "concurrently": "^6.3.0", + "electron": "12.2.2", + "electron-builder": "^22.10.5", + "electron-devtools-installer": "^3.2.0", + "electron-reloader": "^1.2.1", + "eslint": "^8.1.0", + "jest": "^27.3.1", + "np": "^7.5.0", + "ts-jest": "^27.0.7" + }, + "np": { + "publish": false, + "releaseDraft": false + }, + "build": { + "appId": "com.company.AppName", + "mac": { + "category": "public.app-category.social-networking", + "darkModeSupport": true + }, + "dmg": { + "iconSize": 160, + "contents": [ + { + "x": 180, + "y": 170 + }, + { + "x": 480, + "y": 170, + "type": "link", + "path": "/Applications" + } + ] + }, + "linux": { + "target": [ + "AppImage", + "deb" + ], + "category": "Network;Chat" + } + } +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..0c92064 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,2 @@ +const { ipcRenderer } = require("electron"); +window.ipcRenderer = ipcRenderer; diff --git a/electron/renderer-backend/index.html b/electron/renderer-backend/index.html new file mode 100644 index 0000000..33a25af --- /dev/null +++ b/electron/renderer-backend/index.html @@ -0,0 +1,18 @@ + + + + + Electron boilerplate + + + +
+
+

This is a NODE.JS backend

+

+
+
+ + diff --git a/electron/renderer-backend/src/database/database.spec.ts b/electron/renderer-backend/src/database/database.spec.ts new file mode 100644 index 0000000..43537f5 --- /dev/null +++ b/electron/renderer-backend/src/database/database.spec.ts @@ -0,0 +1,77 @@ +import { types } from "taggr-shared"; +import dbFactory from "./database"; + +const IMAGES: types.Image[] = [ + { + hash: "10c483cc2ef59dcc2009ae662917e704", + path: + "file:///Users/alain/Library/Application Support/taggr-nodejs/10c483cc2ef59dcc2009ae662917e704.jpeg", + rawPath: "/Users/alain/temp/pictures/surface-aqdPtCtq3dY-unsplash.jpg", + tags: ["people"], + location: null, + creationDate: 1613300791762, + }, + { + hash: "1469690b94ff799038735e2813ea607f", + path: + "file:///Users/alain/Library/Application Support/taggr-nodejs/1469690b94ff799038735e2813ea607f.jpeg", + rawPath: "/Users/alain/temp/pictures/wexor-tmg-L-2p8fapOA8-unsplash.jpg", + tags: ["animals"], + location: null, + creationDate: 1613300789393, + }, + { + hash: "f3a868effff645384d46dabaf7d9dcaf", + path: + "file:///Users/alain/Library/Application Support/taggr-nodejs/f3a868effff645384d46dabaf7d9dcaf.jpeg", + rawPath: "/Users/alain/temp/pictures/will-norbury--aDYQJdETkA-unsplash.jpg", + tags: [], + location: null, + creationDate: 1616707235139, + }, +]; + +describe.skip("database module", () => { + // clean up dbs + beforeEach(() => { + dbFactory(true).clear(); + dbFactory(false).clear(); + }); + afterEach(() => { + dbFactory(true).clear(); + dbFactory(false).clear(); + }); + + it("should create db with default values", () => { + const db = dbFactory(true); + + expect(db.get("allImages")).toEqual({}); + expect(db.get("currentImageHashes")).toEqual([]); + }); + + it("should create db when in development mode", () => { + const db = dbFactory(true); + + const insertedImages = { + hash1: IMAGES[0], + }; + + db.set("allImages", insertedImages); + + const images = db.get("allImages"); + expect(images).toEqual(insertedImages); + }); + + it("should create db when in non-development mode", () => { + const db = dbFactory(false); + + const insertedImages = { + hash1: IMAGES[0], + }; + + db.set("allImages", insertedImages); + + const images = db.get("allImages"); + expect(images).toEqual(insertedImages); + }); +}); diff --git a/electron/renderer-backend/src/database/database.ts b/electron/renderer-backend/src/database/database.ts new file mode 100644 index 0000000..42c40c1 --- /dev/null +++ b/electron/renderer-backend/src/database/database.ts @@ -0,0 +1,37 @@ +import Store from "electron-store"; + +import { types } from "taggr-shared"; + +type SharedConfigType = { + clearInvalidConfig: boolean; + defaults: { allImages: types.ImageHashMap; currentImageHashes: string[] }; +}; + +const SHARED_CONFIG: SharedConfigType = { + clearInvalidConfig: true, + defaults: { + allImages: {} as types.ImageHashMap, + currentImageHashes: [] as string[], + }, +}; + +const DEV_CONFIG = { + ...SHARED_CONFIG, + cwd: "/Users/alain/Downloads/output", +}; + +const PROD_CONFIG = { + ...SHARED_CONFIG, + encryptionKey: "1234", // adds encryption in prod db +}; + +/** + * Configures, creates and returns instance of https://github.com/sindresorhus/electron-store + */ +const dbFactory = (isDev: boolean) => { + const db = new Store(isDev ? DEV_CONFIG : PROD_CONFIG); + + return db; +}; + +export default dbFactory; diff --git a/electron/renderer-backend/src/database/index.ts b/electron/renderer-backend/src/database/index.ts new file mode 100644 index 0000000..bc991ea --- /dev/null +++ b/electron/renderer-backend/src/database/index.ts @@ -0,0 +1,6 @@ +import { IS_DEV } from "taggr-shared"; +import dbFactory from "./database"; + +export type Type = ReturnType; + +export default dbFactory(IS_DEV); diff --git a/electron/renderer-backend/src/handlers/destroy/destroy.spec.ts b/electron/renderer-backend/src/handlers/destroy/destroy.spec.ts new file mode 100644 index 0000000..9a7a8d8 --- /dev/null +++ b/electron/renderer-backend/src/handlers/destroy/destroy.spec.ts @@ -0,0 +1,33 @@ +import path from "path"; +import fs from "fs"; +import { promisify } from "util"; +const rimraf = promisify(require("rimraf")); + +import destroy from "./destroy"; + +describe("handler - destroy", () => { + const preprocessImagesPath = path.join(__dirname, "test-path"); + const filePath = path.join(preprocessImagesPath, "dumb-file.ts"); + + beforeEach(() => { + // create temp directory and file + fs.mkdirSync(preprocessImagesPath); + fs.writeFileSync(filePath, "dummy file"); + }); + + afterEach(async () => { + await rimraf(preprocessImagesPath, { recursive: true }); + }); + + it("should destroy the existing db and emptry the preprocessImagesPath directory", async () => { + const db = { clear: jest.fn() } as any; + + const destroyHandler = destroy({ db, preprocessImagesPath }); + await destroyHandler(); + + expect(db.clear).toHaveBeenCalled(); + + expect(fs.existsSync(preprocessImagesPath)).toBe(true); + expect(!fs.existsSync(filePath)).toBe(true); // directory should be empty + }); +}); diff --git a/electron/renderer-backend/src/handlers/destroy/destroy.ts b/electron/renderer-backend/src/handlers/destroy/destroy.ts new file mode 100644 index 0000000..6c791b5 --- /dev/null +++ b/electron/renderer-backend/src/handlers/destroy/destroy.ts @@ -0,0 +1,31 @@ +import { promisify } from "util"; + +import { Type as DatabaseType } from "../../database"; + +const rimraf = promisify(require("rimraf")); + +/** + * Clean DB and remove all pre-processed images + */ +const destroy = ({ + db, + preprocessImagesPath, +}: { + db: DatabaseType; + preprocessImagesPath: string; +}) => async () => { + db.clear(); + + try { + await rimraf(preprocessImagesPath, { recursive: true }); + // re-create dir + const fs = require("fs"); + if (!fs.existsSync(preprocessImagesPath)) { + fs.mkdirSync(preprocessImagesPath); + } + } catch (error) { + console.error(error); + } +}; + +export default destroy; diff --git a/electron/renderer-backend/src/handlers/destroy/index.ts b/electron/renderer-backend/src/handlers/destroy/index.ts new file mode 100644 index 0000000..0b6ac03 --- /dev/null +++ b/electron/renderer-backend/src/handlers/destroy/index.ts @@ -0,0 +1,5 @@ +import destroy from "./destroy"; +import database from "../../database"; +import fileService from "../../services/file"; + +export default destroy; diff --git a/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.spec.ts b/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.spec.ts new file mode 100644 index 0000000..cb0247c --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.spec.ts @@ -0,0 +1,74 @@ +import { types } from "taggr-shared"; + +import filterImagesWithLocationFactory from "./filter-images-with-location"; +import { Type as DatabaseType } from "../../database"; +import { Type as imageServiceType } from "../../services/image"; + +const IMAGE_WITH_LOCATION: types.ImageWithLocation = { + hash: "hash-2", + path: "file:///Users/path/image2.jpeg", + rawPath: "Users/path/image2.jpeg", + tags: [], + location: { latitude: 1, longitude: 2 }, + creationDate: 2, +}; + +describe("handler - filter images with location", () => { + let db: DatabaseType, imageService: imageServiceType, sendToFrontend: any; + + beforeEach(() => { + db = { get: jest.fn() } as any; + + imageService = ({ + filterImagesWithLocation: jest.fn(() => []), + } as any) as imageServiceType; + + sendToFrontend = jest.fn(); + }); + + it("should send all images to FE, when DB images pass the filter", () => { + imageService = ({ + filterImagesWithLocation: () => [IMAGE_WITH_LOCATION], + } as any) as imageServiceType; + + const filterImages = filterImagesWithLocationFactory({ + db, + imageService, + sendToFrontend, + }); + + filterImages({ + fromDate: null, + toDate: null, + tags: [], + }); + + expect(sendToFrontend).toHaveBeenCalledWith({ + payload: [IMAGE_WITH_LOCATION], + type: "frontend_set-images-with-location", + }); + }); + + it("should not send images to FE, when DB images dont pass filter", () => { + imageService = ({ + filterImagesWithLocation: () => [], + } as any) as imageServiceType; + + const filterImages = filterImagesWithLocationFactory({ + db, + imageService, + sendToFrontend, + }); + + filterImages({ + fromDate: null, + toDate: null, + tags: [], + }); + + expect(sendToFrontend).toHaveBeenCalledWith({ + payload: [], // no image passed the filter + type: "frontend_set-images-with-location", + }); + }); +}); diff --git a/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.ts b/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.ts new file mode 100644 index 0000000..646a03d --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images-with-location/filter-images-with-location.ts @@ -0,0 +1,35 @@ +import { types } from "taggr-shared"; +import { sendToFrontendType } from "../../message-bus"; +import { Type as DatabaseType } from "../../database"; +import { Type as imageServiceType } from "../../services/image"; + +type Deps = { + db: DatabaseType; + imageService: imageServiceType; + sendToFrontend: sendToFrontendType; +}; + +/** + * Filter images with location and send them to the FE + */ +const filterImagesWithLocation = ({ + db, + imageService, + sendToFrontend, +}: Deps) => (filters: types.Filters) => { + const imageMap = db.get("allImages"); + const currentImageHashes = db.get("currentImageHashes"); + + const filtered = imageService.filterImagesWithLocation({ + imageMap, + currentImageHashes, + filters, + }); + + sendToFrontend({ + type: "frontend_set-images-with-location", + payload: filtered, + }); +}; + +export default filterImagesWithLocation; diff --git a/electron/renderer-backend/src/handlers/filter-images-with-location/index.ts b/electron/renderer-backend/src/handlers/filter-images-with-location/index.ts new file mode 100644 index 0000000..525353f --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images-with-location/index.ts @@ -0,0 +1,3 @@ +import filterImagesWithLocation from "./filter-images-with-location"; + +export default filterImagesWithLocation; diff --git a/electron/renderer-backend/src/handlers/filter-images/filter-images.spec.ts b/electron/renderer-backend/src/handlers/filter-images/filter-images.spec.ts new file mode 100644 index 0000000..9275baa --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images/filter-images.spec.ts @@ -0,0 +1,82 @@ +import { types } from "taggr-shared"; + +import filterImagesFactory from "./filter-images"; +import { Type as DatabaseType } from "../../database"; +import { Type as imageServiceType } from "../../services/image"; + +const IMAGE: types.Image = { + hash: "hash-1", + path: "file:///Users/path/image1.jpeg", + rawPath: "Users/path/image1.jpeg", + tags: [], + location: null, + creationDate: 1, +}; +const IMAGE_WITH_LOCATION: types.ImageWithLocation = { + hash: "hash-2", + path: "file:///Users/path/image2.jpeg", + rawPath: "Users/path/image2.jpeg", + tags: [], + location: { latitude: 1, longitude: 2 }, + creationDate: 2, +}; + +describe("handler - filter images", () => { + let db: DatabaseType, imageService: imageServiceType, sendToFrontend: any; + + beforeEach(() => { + db = { get: jest.fn() } as any; + + imageService = ({ + filterImages: jest.fn(() => []), + } as any) as imageServiceType; + + sendToFrontend = jest.fn(); + }); + + it("should send all images to FE, when DB images pass the filter", () => { + imageService = ({ + filterImages: () => [IMAGE, IMAGE_WITH_LOCATION], + } as any) as imageServiceType; + + const filterImages = filterImagesFactory({ + db, + imageService, + sendToFrontend, + }); + + filterImages({ + fromDate: null, + toDate: null, + tags: [], + }); + + expect(sendToFrontend).toHaveBeenCalledWith({ + payload: [IMAGE, IMAGE_WITH_LOCATION], + type: "frontend_set-images", + }); + }); + + it("should not send images to FE, when DB images dont pass filter", () => { + imageService = ({ + filterImages: () => [], + } as any) as imageServiceType; + + const filterImages = filterImagesFactory({ + db, + imageService, + sendToFrontend, + }); + + filterImages({ + fromDate: null, + toDate: null, + tags: [], + }); + + expect(sendToFrontend).toHaveBeenCalledWith({ + payload: [], // no image passed the filter + type: "frontend_set-images", + }); + }); +}); diff --git a/electron/renderer-backend/src/handlers/filter-images/filter-images.ts b/electron/renderer-backend/src/handlers/filter-images/filter-images.ts new file mode 100644 index 0000000..44fa7aa --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images/filter-images.ts @@ -0,0 +1,33 @@ +import { types } from "taggr-shared"; +import { sendToFrontendType } from "../../message-bus"; +import { Type as DatabaseType } from "../../database"; +import { Type as imageServiceType } from "../../services/image"; + +type Deps = { + db: DatabaseType; + imageService: imageServiceType; + sendToFrontend: sendToFrontendType; +}; + +/** + * Filter images and send them to the FE + */ +const filterImages = ({ db, imageService, sendToFrontend }: Deps) => ( + filters: types.Filters +) => { + const imageMap = db.get("allImages"); + const currentImageHashes = db.get("currentImageHashes"); + + const filtered = imageService.filterImages({ + imageMap, + currentImageHashes, + filters, + }); + + sendToFrontend({ + type: "frontend_set-images", + payload: filtered, + }); +}; + +export default filterImages; diff --git a/electron/renderer-backend/src/handlers/filter-images/index.ts b/electron/renderer-backend/src/handlers/filter-images/index.ts new file mode 100644 index 0000000..3e84d98 --- /dev/null +++ b/electron/renderer-backend/src/handlers/filter-images/index.ts @@ -0,0 +1,3 @@ +import filterImages from "./filter-images"; + +export default filterImages; diff --git a/electron/renderer-backend/src/handlers/index.ts b/electron/renderer-backend/src/handlers/index.ts new file mode 100644 index 0000000..047fbe4 --- /dev/null +++ b/electron/renderer-backend/src/handlers/index.ts @@ -0,0 +1,17 @@ +import destroy from "./destroy"; +import filterImages from "./filter-images"; +import filterImagesWithLocation from "./filter-images-with-location"; +import initializeProject from "./initialize-project"; +import reset from "./reset"; + +/** + * Exposes the message-handler factories. + * For testing purposes, the dependencies must be passes down to the handlers before usage. + */ +export default { + destroy, + filterImages, + filterImagesWithLocation, + initializeProject, + reset, +}; diff --git a/electron/renderer-backend/src/handlers/initialize-project/index.ts b/electron/renderer-backend/src/handlers/initialize-project/index.ts new file mode 100644 index 0000000..563dd38 --- /dev/null +++ b/electron/renderer-backend/src/handlers/initialize-project/index.ts @@ -0,0 +1,3 @@ +import initializeProject from "./initialize-project"; + +export default initializeProject; diff --git a/electron/renderer-backend/src/handlers/initialize-project/initialize-project.ts b/electron/renderer-backend/src/handlers/initialize-project/initialize-project.ts new file mode 100644 index 0000000..c00536c --- /dev/null +++ b/electron/renderer-backend/src/handlers/initialize-project/initialize-project.ts @@ -0,0 +1,140 @@ +import path from "path"; + +import { types } from "taggr-shared"; +import { sendToFrontendType } from "../../message-bus"; +import { Type as dbType } from "../../database"; +import { Type as fileServiceType } from "../../services/file"; +import { Type as imageServiceType } from "../../services/image"; +import { Type as machineLearningServiceType } from "../../services/machine-learning"; + +type Deps = { + db: dbType; + fileService: fileServiceType; + imageService: imageServiceType; + machineLearningService: machineLearningServiceType; + sendToFrontend: sendToFrontendType; +}; + +/** + * Init project, preprocess images and populate DB + */ +const initializeProject = ({ + db, + fileService, + imageService, + machineLearningService, + sendToFrontend, +}: Deps) => async (rootPath: string) => { + console.log("[BE] initialized project in: ", rootPath); + + // 0. update FE route to pre-processing, send progress and supporter status + sendToFrontend({ + type: "frontend_set-route", + payload: "PRE_PROCESSING_PAGE", + }); + + // 1. Locate image paths in project + const imagePaths = await fileService.recursivelyFindImages(rootPath); + + // 2. Generate in memory structure, and calculate the file hashes + const temporaryImageMap: types.ImageHashMap = {}; + for (const imagePath of imagePaths) { + const hash = await fileService.generateFileHash(imagePath); + temporaryImageMap[hash] = { + hash, + path: fileService.normalizePath(imagePath), + rawPath: imagePath, + location: null, + tags: [], + creationDate: new Date().getTime(), + }; + } + + // 3. Process images (only the new ones, when the hash is not stored in DB) + const storedImageMap = db.get("allImages"); + const newImageHashes = Object.keys(temporaryImageMap); + + // only re-calculate new images (non-existing hashes) + let count = 0; + for (const hash of newImageHashes) { + count++; + + sendToFrontend({ + type: "frontend_set-progress", + payload: { + current: count, + total: newImageHashes.length, + }, + }); + + const image = temporaryImageMap[hash]; + + // if exists, update temp map and update DB + if (storedImageMap[hash]) { + temporaryImageMap[hash] = { + ...temporaryImageMap[hash], // update location, as it may have changed + tags: storedImageMap[hash].tags, + location: storedImageMap[hash].location, + creationDate: storedImageMap[hash].creationDate, + }; + + db.set(`allImages.${hash}`, { + ...temporaryImageMap[hash], // update location, as it may have changed + tags: storedImageMap[hash].tags, + location: storedImageMap[hash].location, + creationDate: storedImageMap[hash].creationDate, + }); + } else { + // if doenst exists, process, update temp map and persist in DB + try { + const tags = (await machineLearningService.generateImageTags( + await imageService.loadImageFile(image.rawPath) + )) as any; + const location = await imageService.getLocation(image.rawPath); + const creationDate = + (await imageService.getCreationDate(image.rawPath)) || 0; + + temporaryImageMap[hash] = { + ...temporaryImageMap[hash], // update location, as it may have changed + tags, + location, + creationDate, + }; + + db.set(`allImages.${hash}`, { + ...temporaryImageMap[hash], + tags, + location, + creationDate, + }); + } catch (error) { + // Images which cant be processed, are ommitted + console.log(`Error processing: ${image.path}`); + console.error(error); + } + } + } + + // 5. Update DB with current image hashes + db.set("currentImageHashes", Object.keys(temporaryImageMap)); + + // 6. Send images to FE + sendToFrontend({ + type: "frontend_set-images", + payload: imageService.imageHashMapToImageList(temporaryImageMap), + }); + sendToFrontend({ + type: "frontend_set-images-with-location", + payload: imageService.imageHashMapToImageListWithLocation( + temporaryImageMap + ), + }); + + // 7. Update FE route + sendToFrontend({ + type: "frontend_set-route", + payload: "DASHBOARD_PAGE", + }); +}; + +export default initializeProject; diff --git a/electron/renderer-backend/src/handlers/reset/index.ts b/electron/renderer-backend/src/handlers/reset/index.ts new file mode 100644 index 0000000..b5b5169 --- /dev/null +++ b/electron/renderer-backend/src/handlers/reset/index.ts @@ -0,0 +1,3 @@ +import reset from "./reset"; + +export default reset; diff --git a/electron/renderer-backend/src/handlers/reset/reset.spec.ts b/electron/renderer-backend/src/handlers/reset/reset.spec.ts new file mode 100644 index 0000000..3d30cfe --- /dev/null +++ b/electron/renderer-backend/src/handlers/reset/reset.spec.ts @@ -0,0 +1,12 @@ +import reset from "./reset"; + +describe("handler - reset", () => { + it("should reset the currentImageHashes in the db", () => { + const db = { set: jest.fn() } as any; + + const resetHandler = reset({ db }); + resetHandler(); + + expect(db.set).toHaveBeenCalledWith("currentImageHashes", []); + }); +}); diff --git a/electron/renderer-backend/src/handlers/reset/reset.ts b/electron/renderer-backend/src/handlers/reset/reset.ts new file mode 100644 index 0000000..64668ea --- /dev/null +++ b/electron/renderer-backend/src/handlers/reset/reset.ts @@ -0,0 +1,10 @@ +import { Type as DatabaseType } from "../../database"; + +/** + * Reset current project + */ +const reset = ({ db }: { db: DatabaseType }) => () => { + db.set("currentImageHashes", []); +}; + +export default reset; diff --git a/electron/renderer-backend/src/index.ts b/electron/renderer-backend/src/index.ts new file mode 100644 index 0000000..5f46503 --- /dev/null +++ b/electron/renderer-backend/src/index.ts @@ -0,0 +1,4 @@ +import { sendToFrontend } from "./message-bus"; // initialize the message bus + +// setup the FE route +sendToFrontend({ type: "frontend_set-route", payload: "START_PAGE" }); diff --git a/electron/renderer-backend/src/message-bus/index.spec.ts b/electron/renderer-backend/src/message-bus/index.spec.ts new file mode 100644 index 0000000..8543b6d --- /dev/null +++ b/electron/renderer-backend/src/message-bus/index.spec.ts @@ -0,0 +1,5 @@ +describe("example test", () => { + it("something happens", () => { + expect(1).toEqual(1); + }); +}); diff --git a/electron/renderer-backend/src/message-bus/index.ts b/electron/renderer-backend/src/message-bus/index.ts new file mode 100644 index 0000000..792fab3 --- /dev/null +++ b/electron/renderer-backend/src/message-bus/index.ts @@ -0,0 +1,95 @@ +import { messageBus, utils } from "taggr-shared"; +import handlerFactory from "../handlers"; +import db from "../database"; +import fileService from "../services/file"; +import imageService from "../services/image"; +import machineLearningService from "../services/machine-learning"; + +let feWebContentId: number | undefined = 2; + +/** + * Send ipc message directly to the BE. + */ +export const sendToFrontend = (message: messageBus.FE_MESSAGES): void => { + if (!feWebContentId || isNaN(feWebContentId)) + throw new Error( + "[BE] ipc can not send message, is missing the feWebContentId" + ); + + console.log(`[BE] sending: ${JSON.stringify(message)}`); + window.ipcRenderer.sendTo(feWebContentId, "taggr-ipc-main", message); +}; + +/** + * Handlers are initialized, by passing down the dependencies. + */ +const handlers = { + destroy: handlerFactory.destroy({ + db, + preprocessImagesPath: fileService.getDataDirectory(), + }), + filterImages: handlerFactory.filterImages({ + db, + imageService, + sendToFrontend, + }), + filterImagesWithLocation: handlerFactory.filterImagesWithLocation({ + db, + imageService, + sendToFrontend, + }), + initializeProject: handlerFactory.initializeProject({ + db, + fileService, + imageService, + machineLearningService, + sendToFrontend, + }), + reset: handlerFactory.reset({ db }), +}; + +// setup channel callback +window.ipcRenderer.on( + "taggr-ipc-setup", + (_event: any, message: messageBus.SETUP_MESSAGE) => { + feWebContentId = message.feWebContentId; + console.log(`[BE] message bus online, feWebContentId: ${feWebContentId}`); + } +); + +// main channel callback +window.ipcRenderer.on( + "taggr-ipc-main", + async (_event: any, message?: messageBus.BE_MESSAGES) => { + if (!message || !message.type.startsWith(messageBus.BE_MESSAGE_NAMESPACE)) { + return console.error( + `[BE] can not process message: ${JSON.stringify(message)}` + ); + } + + console.log(`[BE] received:${JSON.stringify(message)}}`); + + // the structure in ../handlers mimics this switch + switch (message.type) { + case "backend_destroy": + await handlers.destroy(); + break; + case "backend_filter-images": + handlers.filterImages(message.payload); + break; + case "backend_filter-images-with-location": + handlers.filterImagesWithLocation(message.payload); + break; + case "backend_initialize-project": + await handlers.initializeProject(message.payload); + break; + case "backend_reset": + handlers.reset(); + break; + default: + throw new utils.UnreachableCaseError(message); + } + } +); + +export type sendToFrontendType = typeof sendToFrontend; diff --git a/electron/renderer-backend/src/services/date/index.spec.ts b/electron/renderer-backend/src/services/date/index.spec.ts new file mode 100644 index 0000000..8c13d07 --- /dev/null +++ b/electron/renderer-backend/src/services/date/index.spec.ts @@ -0,0 +1,112 @@ +import dateService from "./index"; + +describe("services - date", () => { + describe("isDateInRange", () => { + it("should return false when `date` is not set", () => { + const isDateInRange = dateService.isDateInRange({ + date: null, + fromDate: 100, + toDate: 999, + }); + + expect(isDateInRange).toBe(false); + }); + + it("should return true when `fromDate` is not set and `date` is smaller than `toDate`", () => { + const isDateInRange = dateService.isDateInRange({ + date: 100, + fromDate: null, + toDate: 999, + }); + + expect(isDateInRange).toBe(true); + }); + + it("should return true when `toDate` is not set and `date` is bigger than `fromDate`", () => { + const isDateInRange = dateService.isDateInRange({ + date: 100, + fromDate: 50, + toDate: null, + }); + + expect(isDateInRange).toBe(true); + }); + + it("should return true when `fromDate` and `toDate` are not set", () => { + let isDateInRange = dateService.isDateInRange({ + date: 50, + fromDate: null, + toDate: null, + }); + + expect(isDateInRange).toBe(true); + isDateInRange = dateService.isDateInRange({ + date: null, + fromDate: null, + toDate: null, + }); + + expect(isDateInRange).toBe(true); + }); + + it("should return false when `date` is not between `fromDate` and `toDate`", () => { + let isDateInRange = dateService.isDateInRange({ + date: 50, + fromDate: 10, + toDate: 0, + }); + + expect(isDateInRange).toBe(false); + + isDateInRange = dateService.isDateInRange({ + date: 50, + fromDate: 100, + toDate: 200, + }); + + expect(isDateInRange).toBe(false); + + isDateInRange = dateService.isDateInRange({ + date: 300, + fromDate: 100, + toDate: 200, + }); + + expect(isDateInRange).toBe(false); + }); + + it("should return true when `date` is between `fromDate` and `toDate`", () => { + let isDateInRange = dateService.isDateInRange({ + date: -50, + fromDate: -100, + toDate: 0, + }); + + expect(isDateInRange).toBe(true); + + isDateInRange = dateService.isDateInRange({ + date: 150, + fromDate: 100, + toDate: 200, + }); + + expect(isDateInRange).toBe(true); + }); + }); + + describe("exifDateStringToDate", () => { + it("should return null when the param is undefined", () => { + expect(dateService.exifDateStringToDate(undefined)).toBe(null); + }); + + it("should return the epoch time when the param is a valid date string", () => { + expect(dateService.exifDateStringToDate("2013:01:01 01:01:01")).toBe( + 1356998461000 + ); + }); + + it("should return null when the param is not a valid date string", () => { + expect(dateService.exifDateStringToDate("2013:0101 01:01:01")).toBe(null); + }); + }); +}); diff --git a/electron/renderer-backend/src/services/date/index.ts b/electron/renderer-backend/src/services/date/index.ts new file mode 100644 index 0000000..ac6b4ad --- /dev/null +++ b/electron/renderer-backend/src/services/date/index.ts @@ -0,0 +1,65 @@ +/** + * All dates are in Unix Epoch format. + * They may be changed in the UI, but not in the backend. + */ + +class DateService { + /** + * Determine if date is in range. + * All dates are in Unix Epoch. + */ + isDateInRange({ + date, + fromDate, + toDate, + }: { + date: number | null; + fromDate: number | null; + toDate: number | null; + }): boolean { + if (date === null) { + return !fromDate && !toDate; + } + + if (fromDate === null && toDate === null) return true; + + if (fromDate === null) { + return toDate ? date <= toDate : true; + } + + if (toDate === null) { + return fromDate ? fromDate <= date : true; + } + + return fromDate <= date && date <= toDate; + } + + /** + * Transform string to Unix EPOCH time. Uses local timezone for conversion. + * Returns null if conversion fails. + * + * @param exifDateString ex. "2013:01:01 01:01:01" + */ + exifDateStringToDate(exifDateString?: string): number | null { + if (!exifDateString) return null; + + var str = exifDateString.split(" "); + //get date part and replace ':' with '-' + var dateStr = str[0].replace(/:/g, "-"); + //concat the strings (date and time part) + var properDateStr = dateStr + " " + str[1]; + //pass to Date + var date = new Date(properDateStr); + const epochTime = date.getTime(); + if (Number.isNaN(epochTime)) { + console.error(`${properDateStr} is not a valid date`); + return null; + } + + return epochTime; + } +} + +export type Type = DateService; + +export default new DateService(); diff --git a/electron/renderer-backend/src/services/file/index.spec.ts b/electron/renderer-backend/src/services/file/index.spec.ts new file mode 100644 index 0000000..6dc5fa3 --- /dev/null +++ b/electron/renderer-backend/src/services/file/index.spec.ts @@ -0,0 +1,129 @@ +import fileService from "./index"; + +import path from "path"; +import fs from "fs"; +import { promisify } from "util"; +const rimraf = promisify(require("rimraf")); + +describe("services - file", () => { + describe("doesFileExist", () => { + const noImagePath = path.join(__dirname, "no-images"); + + beforeEach(() => { + // create temp directories and files + fs.mkdirSync(noImagePath); + fs.writeFileSync(path.join(noImagePath, "dumb-file.ts"), "dummy file"); + }); + + afterEach(async () => { + await rimraf(noImagePath, { recursive: true }); + }); + + it("should return true when a file exists", () => { + const realPath = path.join(noImagePath, "dumb-file.ts"); + expect(fileService.doesFileExist(realPath)).toBe(true); + }); + + it("should return false when a file exists", () => { + const fakePath = path.join(noImagePath, "non-exisitng-file.ts"); + + expect(fileService.doesFileExist(fakePath)).toBe(false); + }); + }); + + describe("recursivelyFindImages", () => { + const noImagePath = path.join(__dirname, "no-images"); + const imagePath = path.join(__dirname, "images"); + + beforeEach(() => { + // create temp directories and files + fs.mkdirSync(noImagePath); + fs.writeFileSync(path.join(noImagePath, "dumb-file.ts"), "dummy file"); + + fs.mkdirSync(imagePath); + fs.writeFileSync(path.join(imagePath, "dumb-file.ts"), "dummy file"); + fs.writeFileSync(path.join(imagePath, "image1.png"), "image file"); + fs.writeFileSync(path.join(imagePath, "image2.PNG"), "image file"); + fs.writeFileSync(path.join(imagePath, "image3.jpg"), "image file"); + fs.writeFileSync(path.join(imagePath, "image4.JPG"), "image file"); + fs.writeFileSync(path.join(imagePath, "image5.jpeg"), "image file"); + fs.writeFileSync(path.join(imagePath, "image6.JPEG"), "image file"); + }); + + afterEach(async () => { + await rimraf(noImagePath, { recursive: true }); + await rimraf(imagePath, { recursive: true }); + }); + + it("should return emply array when there are no image files", async () => { + expect(await fileService.recursivelyFindImages(noImagePath)).toEqual([]); + }); + + it("should return array with paths when there are image files", async () => { + expect( + (await fileService.recursivelyFindImages(imagePath)).length + ).toEqual(6); + }); + }); + + describe("generateFileHash", () => { + const folderPath = path.join(__dirname, "no-images"); + const filePath1 = path.join(folderPath, "dumb-file1.ts"); + const filePath2 = path.join(folderPath, "dumb-file2.ts"); + + beforeEach(() => { + // create temp directories and files + fs.mkdirSync(folderPath); + fs.writeFileSync(filePath1, "dummy file 1"); + fs.writeFileSync(filePath2, "dummy file 2"); + }); + + afterEach(async () => { + await rimraf(folderPath, { recursive: true }); + }); + + it("should return the file hash", async () => { + expect(await fileService.generateFileHash(filePath1)).toBe( + "b7a41665d3c1bb41cc434218664e0964" + ); + + expect(await fileService.generateFileHash(filePath2)).toBe( + "ad9924197dc1d7f9d80bdecd50d1c046" + ); + }); + }); + + describe("normalizePath", () => { + it("should append the file:// prefix when non http path", () => { + expect(fileService.normalizePath("/user/path/image.jpeg")).toBe( + "file:///user/path/image.jpeg" + ); + }); + + it("should not append the file:// prefix when http path", () => { + expect(fileService.normalizePath("http:/user/path/image.jpeg")).toBe( + "http:/user/path/image.jpeg" + ); + }); + }); + + describe("loadEXIFData", () => { + const imagePath = path.join(__dirname, "test-data", "image-with-exif.jpg"); + + it("should return the exif data when there is a file", async () => { + expect(JSON.stringify(await fileService.loadEXIFData(imagePath))).toEqual( + '{"image":{"ImageDescription":" ","Make":"NIKON","Model":"COOLPIX P6000","Orientation":1,"XResolution":300,"YResolution":300,"ResolutionUnit":2,"Software":"Nikon Transfer 1.1 W","ModifyDate":"2008:11:01 21:15:07","YCbCrPositioning":1,"ExifOffset":268,"GPSInfo":926},"thumbnail":{"Compression":6,"XResolution":72,"YResolution":72,"ResolutionUnit":2,"ThumbnailOffset":4548,"ThumbnailLength":6339},"exif":{"ExposureTime":0.00560852,"FNumber":4.5,"ExposureProgram":2,"ISO":64,"ExifVersion":{"type":"Buffer","data":[48,50,50,48]},"DateTimeOriginal":"2008:10:22 16:29:49","CreateDate":"2008:10:22 16:29:49","ComponentsConfiguration":{"type":"Buffer","data":[1,2,3,0]},"ExposureCompensation":0,"MaxApertureValue":2.9,"MeteringMode":5,"LightSource":0,"Flash":16,"FocalLength":6,"MakerNote":{"type":"Buffer","data":[78,105,107,111,110,0,2,0,0,0,73,73,42,0,8,0,0,0,35,0,1,0,7,0,4,0,0,0,48,50,49,48,2,0,3,0,2,0,0,0,0,0,0,0,3,0,2,0,6,0,0,0,178,1,0,0,4,0,2,0,8,0,0,0,184,1,0,0,5,0,2,0,13,0,0,0,192,1,0,0,6,0,2,0,7,0,0,0,206,1,0,0,7,0,2,0,7,0,0,0,214,1,0,0,8,0,2,0,8,0,0,0,222,1,0,0,10,0,5,0,1,0,0,0,230,1,0,0,15,0,2,0,7,0,0,0,238,1,0,0,16,0,7,0,238,9,0,0,246,1,0,0,33,0,7,0,8,0,0,0,228,11,0,0,34,0,3,0,1,0,0,0,0,0,0,0,41,0,3,0,2,0,0,0,0,0,0,0,47,0,3,0,1,0,0,0,0,0,0,0,128,0,2,0,14,0,0,0,236,11,0,0,129,0,2,0,9,0,0,0,250,11,0,0,130,0,2,0,13,0,0,0,4,12,0,0,134,0,5,0,1,0,0,0,18,12,0,0,136,0,7,0,4,0,0,0,0,0,0,0,143,0,2,0,16,0,0,0,26,12,0,0,145,0,7,0,18,0,0,0,42,12,0,0,148,0,8,0,1,0,0,0,0,0,0,0,149,0,2,0,5,0,0,0,60,12,0,0,155,0,3,0,2,0,0,0,0,0,0,0,156,0,2,0,20,0,0,0,66,12,0,0,157,0,3,0,1,0,0,0,0,0,0,0,158,0,3,0,6,0,0,0,86,12,0,0,170,0,2,0,7,0,0,0,98,12,0,0,172,0,2,0,6,0,0,0,106,12,0,0,178,0,2,0,10,0,0,0,112,12,0,0,189,0,7,0,58,0,0,0,122,12,0,0,9,14,2,0,32,0,0,0,180,12,0,0,16,14,4,0,1,0,0,0,220,12,0,0,34,14,3,0,4,0,0,0,212,12,0,0,0,0,0,0,67,79,76,79,82,0,70,73,78,69,32,32,32,0,65,85,84,79,32,32,32,32,32,32,32,32,0,0,78,79,82,77,65,76,0,0,65,70,45,83,32,32,0,44,32,32,32,32,32,32,32,0,105,36,0,0,232,3,0,0,65,85,84,79,32,32,0,0,5,2,0,0,0,0,0,0,0,0,255,1,0,0,0,49,46,48,0,0,0,0,0,0,0,25,97,18,49,0,0,13,165,0,0,48,104,0,0,0,228,0,0,7,32,0,0,20,36,0,0,2,121,0,0,21,232,0,132,0,243,0,64,45,47,0,0,0,0,0,0,0,0,0,0,46,230,0,0,0,0,0,0,64,0,0,0,0,0,0,32,0,0,0,0,0,0,46,254,37,52,51,12,17,0,0,0,0,0,0,0,0,0,74,0,0,160,0,200,2,132,40,9,50,196,34,34,34,34,0,6,255,188,255,255,255,251,21,54,3,254,3,229,0,0,17,17,17,17,1,214,3,143,3,176,1,94,2,0,1,215,1,253,2,1,1,1,112,70,0,2,3,82,0,60,0,118,0,60,0,118,0,60,0,72,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,177,1,86,1,253,2,162,3,65,0,0,1,58,2,1,3,51,255,255,19,148,21,54,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,136,136,240,0,3,254,0,0,0,32,8,13,0,0,3,229,0,0,1,135,0,0,4,39,0,0,0,3,4,16,3,201,4,39,0,5,3,252,3,242,0,139,36,201,0,0,2,188,0,0,0,0,0,0,0,33,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,19,165,20,133,20,175,20,175,19,170,18,226,0,0,0,0,51,248,54,15,55,6,54,0,50,173,47,180,0,0,0,0,119,119,119,119,1,51,1,90,1,24,1,80,112,0,30,163,0,0,90,0,0,33,0,0,1,208,1,242,1,153,2,129,1,215,1,253,114,0,0,0,0,4,21,48,1,0,68,0,0,0,0,0,0,210,1,44,0,180,0,235,1,24,1,114,52,21,22,24,0,35,0,0,1,45,1,90,1,45,1,71,1,45,1,71,16,91,0,0,0,89,10,0,0,28,35,33,20,16,10,0,16,144,12,101,4,248,19,65,0,0,0,0,0,0,0,0,0,0,0,0,153,144,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,131,242,0,0,0,0,0,0,0,0,0,0,0,0,175,219,0,3,255,255,255,255,255,255,255,255,15,255,255,255,255,255,255,255,255,255,255,255,19,165,20,133,20,175,20,175,19,170,18,226,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,0,0,170,170,170,170,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,106,0,106,0,47,0,106,0,132,0,0,0,0,39,112,0,0,36,37,0,0,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,187,187,187,187,0,9,7,210,8,27,8,25,8,27,7,224,7,158,0,0,0,0,0,0,0,0,0,0,0,0,3,175,3,205,4,14,3,228,3,177,3,138,0,0,0,0,0,0,0,0,0,0,0,0,1,251,2,4,2,7,1,250,1,228,1,218,0,0,0,0,0,0,0,0,0,0,0,0,1,133,1,145,1,156,1,159,1,132,1,119,0,0,0,0,0,0,0,0,0,0,0,0,5,147,5,197,5,238,5,219,5,173,5,91,0,0,0,0,0,0,0,0,0,0,0,0,1,232,1,229,1,216,1,205,1,194,1,177,0,0,0,0,0,0,0,0,0,0,0,0,1,66,1,75,1,65,1,61,1,57,1,53,0,0,0,0,0,0,0,0,0,0,0,0,9,191,10,20,10,56,10,1,9,42,8,109,0,0,0,0,0,0,0,0,0,0,0,0,3,190,3,226,3,219,3,214,3,168,3,122,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,204,204,204,204,0,170,1,30,1,15,1,79,0,246,1,60,0,236,1,57,59,254,5,223,8,225,9,118,97,0,0,0,0,0,0,0,0,159,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,221,221,221,221,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,238,238,238,238,0,4,0,6,255,255,0,2,255,253,255,254,255,251,255,252,255,232,255,242,255,213,255,223,255,197,255,205,0,0,255,188,0,26,1,210,19,207,19,148,20,58,20,10,20,155,20,108,20,246,20,202,21,54,21,21,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,16,0,0,32,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,0,18,0,0,0,0,2,24,0,1,2,0,0,0,128,0,0,48,2,16,0,2,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,112,101,111,156,0,176,0,170,0,182,0,182,0,193,0,195,0,232,0,199,1,49,1,67,1,7,1,4,0,162,0,166,1,15,0,242,0,176,0,170,0,182,0,182,0,193,0,195,0,232,0,199,1,49,1,67,1,7,1,4,0,162,0,166,1,15,0,242,0,175,0,173,0,185,0,177,0,199,0,186,0,232,0,233,1,23,1,16,1,1,1,45,0,187,0,210,1,10,0,198,0,174,0,169,0,179,0,176,0,192,0,189,0,227,0,200,1,16,1,3,1,2,1,53,0,202,1,7,1,6,0,192,0,176,0,172,0,177,0,176,0,185,0,181,1,23,0,207,1,57,1,9,1,12,1,54,0,248,1,3,0,253,0,212,0,180,0,178,0,181,0,179,0,185,0,181,1,74,0,234,1,70,1,48,1,28,1,73,1,40,1,44,1,9,1,14,0,216,0,226,0,196,0,190,0,241,0,202,1,67,1,32,1,51,1,39,1,37,1,57,1,20,1,42,1,15,1,13,1,18,1,31,0,220,1,2,1,18,1,5,1,45,1,23,1,25,1,22,1,17,1,13,1,8,1,13,1,19,1,19,1,43,1,72,1,7,1,29,1,12,1,23,1,62,1,46,1,71,1,66,1,59,1,57,1,36,1,42,1,22,1,25,0,246,0,236,1,1,0,254,1,47,1,49,1,67,1,65,1,67,1,71,1,38,1,60,1,43,1,35,1,42,1,40,1,5,1,4,1,18,1,13,1,25,1,38,1,54,1,34,1,21,1,27,1,6,1,12,0,254,1,2,1,3,1,1,1,24,1,5,1,17,1,27,1,0,1,14,1,9,1,6,0,250,0,251,0,250,0,250,0,240,0,247,0,240,0,239,1,20,1,31,1,9,1,15,1,5,1,22,0,255,1,3,0,251,0,251,0,249,0,250,0,239,0,240,0,239,0,241,1,18,1,10,1,11,1,11,1,3,1,4,0,254,1,1,0,246,0,246,0,253,0,251,0,241,0,243,0,244,0,242,0,253,0,255,1,8,1,5,0,254,1,6,0,255,1,1,0,249,0,249,0,244,0,242,0,240,0,238,0,246,0,241,1,4,0,255,1,11,1,7,1,20,1,20,1,1,1,25,0,246,1,2,0,240,0,243,0,238,0,247,0,236,0,236,1,32,1,30,1,33,1,33,1,40,1,39,1,52,1,41,1,103,1,98,1,86,1,88,1,26,1,29,1,79,1,68,1,32,1,30,1,33,1,33,1,40,1,39,1,52,1,41,1,103,1,98,1,86,1,88,1,26,1,29,1,79,1,68,1,32,1,32,1,35,1,31,1,42,1,36,1,49,1,58,1,89,1,77,1,84,1,117,1,14,1,53,1,66,1,42,1,32,1,31,1,33,1,33,1,38,1,35,1,55,1,41,1,78,1,69,1,79,1,119,1,20,1,85,1,61,1,0,1,33,1,32,1,34,1,33,1,38,1,35,1,83,1,45,1,104,1,74,1,91,1,124,1,81,1,85,1,58,1,42,1,35,1,35,1,37,1,35,1,41,1,36,1,104,1,63,1,120,1,92,1,105,1,125,1,120,1,122,1,69,1,74,1,52,1,61,1,43,1,41,1,76,1,47,1,107,1,106,1,117,1,98,1,120,1,126,1,95,1,129,1,70,1,73,1,103,1,92,1,60,1,87,1,105,1,86,1,90,1,104,1,98,1,95,1,104,1,84,1,85,1,93,1,66,1,70,1,102,1,80,1,105,1,105,1,98,1,108,1,85,1,89,1,73,1,78,1,73,1,71,1,71,1,70,1,70,1,68,1,75,1,79,1,69,1,67,1,85,1,83,1,79,1,78,1,74,1,75,1,70,1,72,1,76,1,70,1,81,1,81,1,70,1,69,1,71,1,71,1,69,1,72,1,67,1,73,1,68,1,68,1,64,1,67,1,61,1,63,1,69,1,65,1,71,1,73,1,63,1,67,1,63,1,63,1,64,1,64,1,61,1,62,1,60,1,62,1,58,1,59,1,56,1,57,1,63,1,65,1,63,1,63,1,63,1,62,1,63,1,63,1,60,1,62,1,59,1,59,1,58,1,58,1,58,1,58,1,65,1,67,1,62,1,64,1,63,1,63,1,62,1,63,1,60,1,61,1,60,1,60,1,57,1,58,1,57,1,57,1,62,1,62,1,63,1,62,1,62,1,63,1,61,1,62,1,60,1,60,1,59,1,60,1,58,1,58,1,60,1,58,1,65,1,64,1,66,1,65,1,62,1,64,1,60,1,62,1,57,1,60,1,58,1,59,1,59,1,60,1,57,1,58,0,1,0,64,1,240,0,0,0,78,79,82,77,65,76,32,32,32,32,32,32,32,0,78,79,82,77,65,76,32,32,0,32,79,70,70,32,32,32,32,32,32,32,32,32,0,32,1,0,0,0,1,0,0,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,68,67,0,0,79,70,70,32,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,0,0,0,0,0,0,0,0,0,0,0,78,79,82,77,65,76,0,32,86,82,45,79,78,0,78,79,82,77,65,76,32,32,0,0,48,49,48,48,83,84,65,78,68,65,82,68,32,32,32,0,0,0,0,0,0,0,0,0,83,84,65,78,68,65,82,68,32,32,32,0,0,0,0,0,0,0,0,0,1,0,0,0,0,128,0,0,255,128,255,255,255,255,67,79,79,76,80,73,88,32,80,54,48,48,48,86,49,46,48,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,0,0,0,0]},"UserComment":{"type":"Buffer","data":[65,83,67,73,73,0,0,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0]},"FlashpixVersion":{"type":"Buffer","data":[48,49,48,48]},"ColorSpace":1,"ExifImageWidth":640,"ExifImageHeight":480,"InteropOffset":896,"FileSource":{"type":"Buffer","data":[3]},"SceneType":{"type":"Buffer","data":[1]},"CustomRendered":0,"ExposureMode":0,"WhiteBalance":0,"DigitalZoomRatio":0,"FocalLengthIn35mmFormat":28,"SceneCaptureType":0,"GainControl":0,"Contrast":0,"Saturation":0,"Sharpness":0,"SubjectDistanceRange":0},"gps":{"GPSLatitudeRef":"N","GPSLatitude":[43,28,1.76399999],"GPSLongitudeRef":"E","GPSLongitude":[11,53,7.42199999],"GPSAltitudeRef":0,"GPSTimeStamp":[14,28,17.24],"GPSSatellites":"06","GPSImgDirectionRef":"\\u0000","GPSMapDatum":"WGS-84 ","GPSDateStamp":"2008:10:23"},"interoperability":{"InteropIndex":"R98","InteropVersion":{"type":"Buffer","data":[48,49,48,48]}},"makernote":{"error":"Unable to extract Makernote information as it is in an unsupported or unrecognized format."}}' + ); + }); + + it("should return undefined when there is no valid file", async () => { + expect(await fileService.loadEXIFData("random/path")).toBe(undefined); + }); + }); + + describe("getDataDirectory", () => { + it("should return the data directory (differnet in each OS)", () => { + expect(fileService.getDataDirectory().length > 0).toBe(true); + }); + }); +}); diff --git a/electron/renderer-backend/src/services/file/index.ts b/electron/renderer-backend/src/services/file/index.ts new file mode 100644 index 0000000..40d212e --- /dev/null +++ b/electron/renderer-backend/src/services/file/index.ts @@ -0,0 +1,104 @@ +import readdirp from "readdirp"; +import { promisify } from "util"; +import crypto from "crypto"; +import fs from "fs"; +import envPaths from "env-paths"; +import normalize from "normalize-path"; +import ExifImage from "exif"; +import { types } from "taggr-shared"; + +const openFile = promisify(fs.open); +const readFile = promisify(fs.read); +const closeFile = promisify(fs.close); + +class FileService { + /** + * Checks if a given file exists + */ + doesFileExist(filePath: string) { + return fs.existsSync(filePath); + } + + /** + * Recursively find all the image paths inside the folderPath + */ + async recursivelyFindImages(folderPath: string): Promise { + let imagePathsList = []; + + const settings = { + // Filter files with png and jpeg extension + fileFilter: ["*.png", "*.PNG", "*.jpg", "*.JPG", "*.jpeg", "*.JPEG"], + // Filter by directory + directoryFilter: ["!.git", "!*modules", "!.cache", "!.*"], + alwaysStat: true, + }; + + try { + for await (const entry of readdirp(folderPath, settings)) { + const { path } = entry; + + imagePathsList.push(`${folderPath}/${path}`); + } + } catch (e) { + console.error(e); + } + + return imagePathsList; + } + + /** + * Generate md5 hash from file. Use the initial 4k only. + */ + async generateFileHash(filePath: string): Promise { + const len = 4096, + pos = 0, + offset = 0, + buff = Buffer.alloc(len); + + const fd = await openFile(filePath, "r"); + const tempBuff = await readFile(fd, buff, offset, len, pos); + const hash = crypto.createHash("md5").update(tempBuff.buffer).digest("hex"); + await closeFile(fd); + + return hash; + } + + /** + * Normalize a file path. Adds `file://` prefix to local images. + * Fixes the linux / windows compatibility issues. + */ + normalizePath(filePath: string): string { + let normalizedImagePath; + + try { + normalizedImagePath = normalize(filePath); + return normalizedImagePath.startsWith("http") + ? normalizedImagePath + : `file://${normalizedImagePath}`; + } catch (e) { + console.error(e); + return ""; + } + } + + /** + * Load EXIF data from path. + */ + loadEXIFData(filePath: string): Promise { + return new Promise((resolve) => { + ExifImage(filePath, (err: any, data: any) => resolve(data)); + }); + } + + /** + * Returns the app directory depending on the OS. + * https://github.com/sindresorhus/env-paths + */ + getDataDirectory(): string { + return envPaths("taggr").data; + } +} + +export type Type = FileService; + +export default new FileService(); diff --git a/electron/renderer-backend/src/services/file/test-data/image-with-exif.jpg b/electron/renderer-backend/src/services/file/test-data/image-with-exif.jpg new file mode 100644 index 0000000..cbc9594 Binary files /dev/null and b/electron/renderer-backend/src/services/file/test-data/image-with-exif.jpg differ diff --git a/electron/renderer-backend/src/services/image/index.spec.ts b/electron/renderer-backend/src/services/image/index.spec.ts new file mode 100644 index 0000000..13a01fa --- /dev/null +++ b/electron/renderer-backend/src/services/image/index.spec.ts @@ -0,0 +1,326 @@ +import imageService from "./index"; + +import path from "path"; +import { promisify } from "util"; +import { types } from "taggr-shared"; + +const baseImageProps = { + hash: "imageHash", + path: "./path", + rawPath: "./raw-path", +}; + +const IMAGE_MAP: types.ImageHashMap = { + image1: { + ...baseImageProps, + tags: ["animals", "vehicles"], + creationDate: 100, + location: null, + }, + image2: { + ...baseImageProps, + tags: ["vehicles"], + creationDate: 200, + location: { latitude: 1, longitude: 2 }, + }, + image3: { + ...baseImageProps, + tags: [], + creationDate: 300, + location: { latitude: 1, longitude: 2 }, + }, +}; + +describe("services - image", () => { + describe("imageHashMapToImageList", () => { + it("should return an empty list of images from an empty imageHashMap", () => { + expect(imageService.imageHashMapToImageList({})).toEqual([]); + }); + + it("should return the list of images from the imageHashMap", () => { + expect(imageService.imageHashMapToImageList(IMAGE_MAP)).toEqual([ + IMAGE_MAP.image1, + IMAGE_MAP.image2, + IMAGE_MAP.image3, + ]); + }); + }); + + describe("imageHashMapToImageListWithLocation", () => { + it("should return an empty list of images from an empty imageHashMap", () => { + expect(imageService.imageHashMapToImageListWithLocation({})).toEqual([]); + }); + + it("should return the list of images from the imageHashMap", () => { + expect( + imageService.imageHashMapToImageListWithLocation(IMAGE_MAP) + ).toEqual([ + // IMAGE_MAP.image1, <- has no location, filtered out + IMAGE_MAP.image2, + IMAGE_MAP.image3, + ]); + }); + }); + + describe("getCreationDate", () => { + it("should return the creation date from exif metadata", async () => { + const imagePath = path.join(__dirname, "test-data", "image.jpg"); + + expect(await imageService.getCreationDate(imagePath)).toBe(1224685789000); + }); + }); + + describe("getLocation", () => { + const imagePath = path.join(__dirname, "test-data", "image.jpg"); + + it("should return the location from exif metadata", async () => { + expect(await imageService.getLocation(imagePath)).toEqual({ + latitude: 43.46715666666389, + longitude: 11.885394999997223, + }); + }); + it("should return null when there is no exif metadata", async () => { + expect(await imageService.getLocation("randon/fake/path.jpg")).toEqual( + null + ); + }); + }); + + describe("doesImagePassFilter", () => { + it("should return true when the image passes a filter with empty tags", () => { + const image: types.Image = { + hash: "imageHash", + path: "./path", + rawPath: "./raw-path", + location: { latitude: 1, longitude: 2 }, + tags: ["animals", "vehicles"], + creationDate: 100, + }; + + const filters: types.Filters = { fromDate: 50, toDate: 150, tags: [] }; + + expect(imageService.doesImagePassFilter(image, filters)).toEqual(true); + }); + + it("should return true when the image passes a filter with tags", () => { + const image: types.Image = { + hash: "imageHash", + path: "./path", + rawPath: "./raw-path", + location: { latitude: 1, longitude: 2 }, + tags: ["animals", "vehicles"], + creationDate: 100, + }; + + const filters: types.Filters = { + fromDate: 50, + toDate: 150, + tags: ["animals"], + }; + + expect(imageService.doesImagePassFilter(image, filters)).toEqual(true); + }); + + it("should return false when the image does not match the filter tags", () => { + const image: types.Image = { + hash: "imageHash", + path: "./path", + rawPath: "./raw-path", + location: { latitude: 1, longitude: 2 }, + tags: ["animals", "vehicles"], + creationDate: 100, + }; + + const filters: types.Filters = { + fromDate: 50, + toDate: 150, + tags: ["drinks"], // not present in the image list + }; + + expect(imageService.doesImagePassFilter(image, filters)).toEqual(false); + }); + + it("should return false when the image does not match the filter date", () => { + const image: types.Image = { + hash: "imageHash", + path: "./path", + rawPath: "./raw-path", + location: { latitude: 1, longitude: 2 }, + tags: ["animals", "vehicles"], + creationDate: 200, // out of range, more than `toDate` + }; + + const filters: types.Filters = { + fromDate: 50, + toDate: 150, + tags: ["animals"], + }; + + expect(imageService.doesImagePassFilter(image, filters)).toEqual(false); + }); + }); + + describe("filterImages", () => { + it("should return an empty array if no image matches the filters", async () => { + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 350, + tags: ["drinks"], + }, + }) + ).toEqual([]); + }); + + it("should return an empty array if no images are provided", async () => { + expect( + imageService.filterImages({ + imageMap: {}, + currentImageHashes: Object.keys({}), + filters: { + fromDate: 50, + toDate: 350, + tags: [], + }, + }) + ).toEqual([]); + }); + + it("should return all images if the filter tag is an empty array, still filtered by date", async () => { + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 350, + tags: [], + }, + }).length + ).toBe(3); + + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 250, // image3 is out of the date range + tags: [], + }, + }).length + ).toBe(2); + }); + + it("should return an array of images matching by tag and date", async () => { + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: ["image1", "image2", "image3"], + filters: { + fromDate: 50, + toDate: 150, + tags: ["animals"], + }, + }).length + ).toBe(1); + + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: ["image1", "image2", "image3"], + filters: { + fromDate: 0, + toDate: 1, + tags: ["animals"], + }, + }).length + ).toBe(0); // date changed, leaving image1 out of range + }); + }); + + describe("filterImagesWithLocation", () => { + it("should return an empty array if no image matches the filters", async () => { + expect( + imageService.filterImagesWithLocation({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 350, + tags: ["drinks"], + }, + }) + ).toEqual([]); + }); + + it("should return an empty array if no images are provided", async () => { + expect( + imageService.filterImages({ + imageMap: {}, + currentImageHashes: Object.keys({}), + filters: { + fromDate: 50, + toDate: 350, + tags: [], + }, + }) + ).toEqual([]); + }); + + it("should return all images with location if the filter tag is an empty array, still filtered by date", async () => { + expect( + imageService.filterImagesWithLocation({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 350, + tags: [], + }, + }).length + ).toBe(2); + + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: Object.keys(IMAGE_MAP), + filters: { + fromDate: 50, + toDate: 150, // image3 is out of the date range + tags: [], + }, + }).length + ).toBe(1); + }); + + it("should return an array of images matching by tag and date", async () => { + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: ["image1", "image2", "image3"], + filters: { + fromDate: 50, + toDate: 150, + tags: ["animals"], + }, + }).length + ).toBe(1); + + expect( + imageService.filterImages({ + imageMap: IMAGE_MAP, + currentImageHashes: ["image1", "image2", "image3"], + filters: { + fromDate: 0, + toDate: 1, + tags: ["animals"], + }, + }).length + ).toBe(0); // date changed, leaving image1 out of range + }); + }); +}); diff --git a/electron/renderer-backend/src/services/image/index.ts b/electron/renderer-backend/src/services/image/index.ts new file mode 100644 index 0000000..b93d2e8 --- /dev/null +++ b/electron/renderer-backend/src/services/image/index.ts @@ -0,0 +1,191 @@ +import get from "lodash.get"; +import fs from "fs"; +import { promisify } from "util"; + +import { types } from "taggr-shared"; +import dateService from "../date"; +import fileService from "../file"; + +const readStats = promisify(fs.stat); + +import { isArrayContained, toDecimal } from "../../utils"; + +type dateServiceType = typeof dateService; +type fileServiceType = typeof fileService; + +class ImageService { + private dateService: dateServiceType; + private fileService: fileServiceType; + + constructor(dateService: dateServiceType, fileService: fileServiceType) { + this.dateService = dateService; + this.fileService = fileService; + } + + /** + * Load HTMLImageELement from src. Cant be tested in Jest, as it depends on browser's `Image`. + */ + loadImageFile(imagePath: string): Promise { + return new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = imagePath; + }); + } + + /** + * Transfrom the imageHashMap to imageList + */ + imageHashMapToImageList(imageHashMap: types.ImageHashMap): types.Image[] { + return Object.keys(imageHashMap).map((key) => ({ + ...imageHashMap[key], + })); + } + + /** + * Transfrom the imageHashMap to imageList, for images with location + */ + imageHashMapToImageListWithLocation( + imageHashMap: types.ImageHashMap + ): types.ImageWithLocation[] { + return Object.keys(imageHashMap) + .map((key) => ({ + ...imageHashMap[key], + })) + .filter( + (image) => image.location && image.location !== null + ) as types.ImageWithLocation[]; + } + + /** + * Get image-file creation date in UNIX EPOCH + */ + async getCreationDate(imagePath: string): Promise { + const exifData = await this.fileService.loadEXIFData(imagePath); + const exifDateTimeOriginal = get(exifData, "exif.DateTimeOriginal", null); + const exifCreateDate = get(exifData, "exif.CreateDate", null); + const exifModifyDate = get(exifData, "image.ModifyDate", null); + + if (exifDateTimeOriginal) { + return this.dateService.exifDateStringToDate(exifDateTimeOriginal); + } + + if (exifCreateDate) { + return this.dateService.exifDateStringToDate(exifCreateDate); + } + + if (exifModifyDate) { + return this.dateService.exifDateStringToDate(exifModifyDate); + } + + const fsStats = await readStats(imagePath); + const birthtime = get(fsStats, "birthtime", null); + // the birthtime can be epoch 0, then check the mtime + if (birthtime) return birthtime.getTime(); + + const mtime = get(fsStats, "mtime", null); + if (mtime) return mtime.getTime(); + + return null; + } + + /** + * Get the location info for an image + * @param imagePath without file:// prefix + */ + async getLocation(imagePath: string): Promise { + try { + let exifData: any = await this.fileService.loadEXIFData(imagePath); + + // check if gps is contained + const latitude = get(exifData, "gps.GPSLatitude", null); + const longitude = get(exifData, "gps.GPSLongitude", null); + + if (!latitude || !longitude) return null; + + const latDMS = exifData.gps.GPSLatitude; + const longDMS = exifData.gps.GPSLongitude; + + const geoString = `${get(exifData, "gps.GPSLatitudeRef", "")}${ + latDMS[0] + }° ${latDMS[1]}' ${latDMS[2]}" ${get( + exifData, + "gps.GPSLongitudeRef", + "" + )}${longDMS[0]}° ${longDMS[1]}' ${longDMS[2]}"`; + + const { lat, lon } = toDecimal(geoString); + + return { latitude: lat, longitude: lon }; + } catch (e) { + console.error(e); + } + + return null; + } + + /** + * Calculates if an image passes a given filter + */ + doesImagePassFilter(image: types.Image, filters: types.Filters): boolean { + const { fromDate, toDate, tags: filterTags } = filters; + const { creationDate, tags: imageTags } = image; + + return ( + this.dateService.isDateInRange({ + date: creationDate, + fromDate, + toDate, + }) && isArrayContained(imageTags, filterTags) + ); + } + + /** + * Filter images + */ + filterImages({ + imageMap, + currentImageHashes, + filters, + }: { + imageMap: types.ImageHashMap; + currentImageHashes: string[]; + filters: types.Filters; + }): types.Image[] { + let images: types.Image[] = []; + + currentImageHashes.forEach((hash) => { + const image = imageMap[hash]; + if (image && this.doesImagePassFilter(image, filters)) images.push(image); + }); + + return images; + } + /** + * Filter images with location + */ + filterImagesWithLocation({ + imageMap, + currentImageHashes, + filters, + }: { + imageMap: types.ImageHashMap; + currentImageHashes: string[]; + filters: types.Filters; + }): types.ImageWithLocation[] { + let images: types.ImageWithLocation[] = []; + + currentImageHashes.forEach((hash) => { + const image = imageMap[hash]; + if (image && image.location && this.doesImagePassFilter(image, filters)) + images.push(image as types.ImageWithLocation); + }); + + return images; + } +} + +export type Type = ImageService; + +export default new ImageService(dateService, fileService); diff --git a/electron/renderer-backend/src/services/image/test-data/image.jpg b/electron/renderer-backend/src/services/image/test-data/image.jpg new file mode 100644 index 0000000..cbc9594 Binary files /dev/null and b/electron/renderer-backend/src/services/image/test-data/image.jpg differ diff --git a/electron/renderer-backend/src/services/machine-learning/index.ts b/electron/renderer-backend/src/services/machine-learning/index.ts new file mode 100644 index 0000000..31648c1 --- /dev/null +++ b/electron/renderer-backend/src/services/machine-learning/index.ts @@ -0,0 +1,9 @@ +import { generateImageTags } from "./tag-mapping"; + +class MachineLearningService { + generateImageTags = generateImageTags; +} + +export type Type = MachineLearningService; + +export default new MachineLearningService(); diff --git a/electron/renderer-backend/src/services/machine-learning/methods/classification.ts b/electron/renderer-backend/src/services/machine-learning/methods/classification.ts new file mode 100644 index 0000000..4762aa1 --- /dev/null +++ b/electron/renderer-backend/src/services/machine-learning/methods/classification.ts @@ -0,0 +1,214 @@ +// @ts-nocheck + +const PROBABILITY_THRESHOLD = 0.75; + +let net; + +const loadModel = async () => { + const mobilenet = require("@tensorflow-models/mobilenet"); + + if (net) return; + + console.time("loadModel classification"); + net = await mobilenet.load(); + console.timeEnd("loadModel classification"); +}; + +loadModel(); + +/** + * Generate the image classification ids for a given image. + * Returns the imagenet class ids. + */ +export const getClassificationIds = async ( + img: ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement +): Promise => { + if (!net) await loadModel(); + + let imageNetClassNumbers = []; + + try { + const logits = await net.infer(img); + imageNetClassNumbers = await getTopKImagenetClassNumbers(logits); + } catch (e) { + console.error(e); + } + + return imageNetClassNumbers; +}; + +// helpers + +const __awaiter = + (this && this.__awaiter) || + function (thisArg, _arguments, P, generator) { + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + } + function rejected(value) { + try { + step(generator["throw"](value)); + } catch (e) { + reject(e); + } + } + function step(result) { + result.done + ? resolve(result.value) + : new P(function (resolve) { + resolve(result.value); + }).then(fulfilled, rejected); + } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); + }; +const __generator = + (this && this.__generator) || + function (thisArg, body) { + let _ = { + label: 0, + sent: function () { + if (t[0] & 1) throw t[1]; + return t[1]; + }, + trys: [], + ops: [], + }, + f, + y, + t, + g; + return ( + (g = { next: verb(0), throw: verb(1), return: verb(2) }), + typeof Symbol === "function" && + (g[Symbol.iterator] = function () { + return this; + }), + g + ); + function verb(n) { + return function (v) { + return step([n, v]); + }; + } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (_) + try { + if ( + ((f = 1), + y && + (t = + op[0] & 2 + ? y["return"] + : op[0] + ? y["throw"] || ((t = y["return"]) && t.call(y), 0) + : y.next) && + !(t = t.call(y, op[1])).done) + ) + return t; + if (((y = 0), t)) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: + case 1: + t = op; + break; + case 4: + _.label++; + return { value: op[1], done: false }; + case 5: + _.label++; + y = op[1]; + op = [0]; + continue; + case 7: + op = _.ops.pop(); + _.trys.pop(); + continue; + default: + if ( + !((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && + (op[0] === 6 || op[0] === 2) + ) { + _ = 0; + continue; + } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { + _.label = op[1]; + break; + } + if (op[0] === 6 && _.label < t[1]) { + _.label = t[1]; + t = op; + break; + } + if (t && _.label < t[2]) { + _.label = t[2]; + _.ops.push(op); + break; + } + if (t[2]) _.ops.pop(); + _.trys.pop(); + continue; + } + op = body.call(thisArg, _); + } catch (e) { + op = [6, e]; + y = 0; + } finally { + f = t = 0; + } + if (op[0] & 5) throw op[1]; + return { value: op[0] ? op[1] : void 0, done: true }; + } + }; + +function getTopKImagenetClassNumbers(logits, topK = 5) { + return __awaiter(this, void 0, void 0, function () { + var softmax, + values, + valuesAndIndices, + i, + topkValues, + topkIndices, + i, + topImagenetClassIds, + i; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + softmax = logits.softmax(); + return [4, softmax.data()]; + case 1: + values = _a.sent(); + softmax.dispose(); + valuesAndIndices = []; + for (i = 0; i < values.length; i++) { + valuesAndIndices.push({ value: values[i], index: i }); + } + valuesAndIndices.sort(function (a, b) { + return b.value - a.value; + }); + topkValues = new Float32Array(topK); + topkIndices = new Int32Array(topK); + for (i = 0; i < topK; i++) { + topkValues[i] = valuesAndIndices[i].value; + topkIndices[i] = valuesAndIndices[i].index; + } + topImagenetClassIds = []; + for (i = 0; i < topkIndices.length; i++) { + if (topkValues[i] >= PROBABILITY_THRESHOLD) { + topImagenetClassIds.push(topkIndices[i]); + } + } + + return [2, topImagenetClassIds]; + } + }); + }); +} diff --git a/electron/renderer-backend/src/services/machine-learning/methods/objectRecognition.ts b/electron/renderer-backend/src/services/machine-learning/methods/objectRecognition.ts new file mode 100644 index 0000000..dcf9494 --- /dev/null +++ b/electron/renderer-backend/src/services/machine-learning/methods/objectRecognition.ts @@ -0,0 +1,44 @@ +// @ts-nocheck + +const MIN_SCORE = 0.5; + +let net; + +// initialize model +loadModel(); + +async function loadModel() { + const cocoSsd = require("@tensorflow-models/coco-ssd"); + + console.time("loadModel object recognition"); + net = await cocoSsd.load(); + console.timeEnd("loadModel object recognition"); +} + +/** + * Get coco-ssd class ids for an image + * Returns array with coco-ssd class names + */ +export const getObjectRecognitionClassNames = async ( + img: ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement +): Promise => { + if (!net) await loadModel(); + + let cocoSsdClassNames = []; + + try { + let predictions = await net.detect(img); + + predictions.forEach((prediction) => { + const score = prediction.score; + const predictedClass = prediction.class; + if (score > MIN_SCORE) { + cocoSsdClassNames.push(predictedClass); + } + }); + } catch (e) { + console.error(e); + } + + return cocoSsdClassNames; +}; diff --git a/electron/renderer-backend/src/services/machine-learning/tag-mapping.ts b/electron/renderer-backend/src/services/machine-learning/tag-mapping.ts new file mode 100644 index 0000000..ac4a25b --- /dev/null +++ b/electron/renderer-backend/src/services/machine-learning/tag-mapping.ts @@ -0,0 +1,269 @@ +import get from "lodash.get"; +import range from "lodash.range"; +import { types } from "taggr-shared"; + +import { getClassificationIds } from "./methods/classification"; +import { getObjectRecognitionClassNames } from "./methods/objectRecognition"; + +/** + * Custom mapping from classification ids and object recognition classes, to our own tags. + */ +const CUSTOM_TAG_MAPPING: { + [key in types.Tag]: { + name: string; + imageNetClassIds?: number[]; + cocoSsdClassNames?: string[]; + }; +} = { + // WHAT + people: { + name: "people", + cocoSsdClassNames: ["person"], + }, + animals: { + name: "animals", + imageNetClassIds: [...range(0, 398), 537], + cocoSsdClassNames: [ + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + ], + }, + vehicles: { + name: "vehicles", + imageNetClassIds: [ + 403, + 404, + 407, + 408, + 436, + 444, + 468, + 475, + 479, + 511, + 555, + 565, + 569, + 573, + 574, + 575, + 603, + 627, + 654, + 656, + 665, + 670, + 671, + 675, + 705, + 734, + 751, + 779, + 785, + 817, + 820, + 847, + 864, + 866, + 867, + 870, + 874, + 880, + 895, + // boats: + 472, + 484, + 510, + 554, + 576, + 625, + 628, + 724, + 780, + 814, + 833, + 871, + ], + cocoSsdClassNames: [ + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + ], + }, + food: { + name: "food", + imageNetClassIds: [ + 567, + 659, + 762, + 766, + 777, + 809, + 813, + 827, + 828, + 859, + 891, + 909, + ...range(923, 966), + ], + cocoSsdClassNames: [ + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "dining table", + ], + }, + drinks: { + name: "drinks", + imageNetClassIds: [ + 503, + 504, + 505, + 550, + 647, + 653, + 737, + 810, + 849, + 898, + 899, + 901, + 907, + ...range(966, 970), + ], + cocoSsdClassNames: ["bottle", "wine glass", "cup"], + }, + sports: { + name: "sports", + imageNetClassIds: [ + 701, + 722, + 736, + 747, + 768, + 770, + 795, + 796, + 801, + 802, + 805, + 852, + 981, + 983, + ], + cocoSsdClassNames: [ + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + ], + }, +}; + +/** + * Return true is an image classifies as the given tagName. + */ +const calculateTag = ( + imageNetClassIds: number[], + cocoSsdClassNames: string[], + tagName: types.Tag +): boolean => { + const tagImageNetClassIds = get( + CUSTOM_TAG_MAPPING[tagName], + "imageNetClassIds", + null + ); + const tagCocoSsdClassNames = get( + CUSTOM_TAG_MAPPING[tagName], + "cocoSsdClassNames", + null + ); + + if ( + imageNetClassIds && + tagImageNetClassIds && + imageNetClassIds.some((id) => tagImageNetClassIds.includes(id)) + ) + return true; + + if ( + cocoSsdClassNames && + tagCocoSsdClassNames && + cocoSsdClassNames.some((name) => tagCocoSsdClassNames.includes(name)) + ) + return true; + + return false; +}; + +/** + * Calculates custom tags from classification ids and object recognition class names. + * NOT to be used directly!! + */ +export const calculateTags = ( + imageNetClassIds: number[], + cocoSsdClassNames: string[] +): types.Tag[] => { + const tags: types.Tag[] = []; + + Object.keys(CUSTOM_TAG_MAPPING).forEach((tagName) => { + if ( + calculateTag(imageNetClassIds, cocoSsdClassNames, tagName as types.Tag) + ) { + tags.push(tagName as types.Tag); + } + }); + + return tags; +}; + +/** + * Extract the custom tags from a given image. + * Uses machine learning models for classification and object recognition. + */ +export const generateImageTags = async ( + image: ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement +): Promise => { + // ML classification + console.time("classify"); + const imageNetClassIds = await getClassificationIds(image); + console.timeEnd("classify"); + + // ML object recognition + console.time("object"); + const cocoSsdClassNames = await getObjectRecognitionClassNames(image); + console.timeEnd("object"); + + return calculateTags(imageNetClassIds, cocoSsdClassNames); +}; diff --git a/electron/renderer-backend/src/types.d.ts b/electron/renderer-backend/src/types.d.ts new file mode 100644 index 0000000..76d96c1 --- /dev/null +++ b/electron/renderer-backend/src/types.d.ts @@ -0,0 +1,7 @@ +import { types } from "taggr-shared"; + +declare global { + interface Window { + ipcRenderer: types.IpcRendererBE; + } +} diff --git a/electron/renderer-backend/src/utils.ts b/electron/renderer-backend/src/utils.ts new file mode 100644 index 0000000..ea5da0a --- /dev/null +++ b/electron/renderer-backend/src/utils.ts @@ -0,0 +1,43 @@ +/** + * Check if array A contains all the elements of array B + */ +export const isArrayContained = ( + arrayA: string[], + arrayB: string[] +): boolean => { + if (arrayB.length === 0) return true; + + return arrayB.every((bItem) => arrayA.includes(bItem)); +}; + +/** + * Translate latlong string to {lat, lon} object + */ +export const toDecimal = (latLongString: string) => { + var parseDMS = require("parse-dms"); + return parseDMS(latLongString); +}; + +type AnyFunction = (...args: any[]) => any; + +/** + * Log execution performance of function + */ +export const logFunctionPerf = ( + func: Func, + name?: string +): ((...args: Parameters) => ReturnType) => { + const funcName = name ? name : func.name; + + const wrappedFn = async ( + ...args: Parameters + ): Promise> => { + console.time(funcName); + const ret = await func(...args); + console.timeEnd(funcName); + + return ret; + }; + + return wrappedFn as any; +}; diff --git a/electron/static/Icon.png b/electron/static/Icon.png new file mode 100644 index 0000000..f2c4320 Binary files /dev/null and b/electron/static/Icon.png differ diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 0000000..dcbe7a7 --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "outDir": "./renderer-backend/transpiled" /* Specify an output folder for all emitted files. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "moduleResolution": "node" + } +} diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..e665c48 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,4 @@ +GENERATE_SOURCEMAP=false +PORT=3001 +BROWSER=none +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js new file mode 100644 index 0000000..cd74759 --- /dev/null +++ b/frontend/.storybook/main.js @@ -0,0 +1,11 @@ +module.exports = { + "stories": [ + "../src/**/*.stories.mdx", + "../src/**/*.stories.@(js|jsx|ts|tsx)" + ], + "addons": [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/preset-create-react-app" + ] +} \ No newline at end of file diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html new file mode 100644 index 0000000..fee5db6 --- /dev/null +++ b/frontend/.storybook/preview-body.html @@ -0,0 +1,55 @@ + + + + + + diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js new file mode 100644 index 0000000..48afd56 --- /dev/null +++ b/frontend/.storybook/preview.js @@ -0,0 +1,9 @@ +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +} \ No newline at end of file diff --git a/frontend/craco.config.js b/frontend/craco.config.js new file mode 100644 index 0000000..82489cf --- /dev/null +++ b/frontend/craco.config.js @@ -0,0 +1,8 @@ +// craco.config.js +module.exports = { + babel: { + loaderOptions: { + ignore: ["./node_modules/mapbox-gl/dist/mapbox-gl.js"], + }, + }, +}; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4a46a71 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,88 @@ +{ + "name": "taggr-frontend", + "version": "0.1.0", + "private": true, + "homepage": "./", + "scripts": { + "start": "npx @craco/craco start", + "build": "npx @craco/craco build", + "test": "npx @craco/craco test", + "test:ci": "yarn test --ci --watchAll=false", + "eject": "react-scripts eject", + "storybook": "start-storybook -p 6006 -s public", + "build-storybook": "build-storybook -s public" + }, + "dependencies": { + "@craco/craco": "^6.4.0", + "@date-io/date-fns": "^2.11.0", + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/icons-material": "^5.0.5", + "@mui/lab": "^5.0.0-alpha.53", + "@mui/material": "^5.0.6", + "@mui/styles": "^5.0.2", + "@reduxjs/toolkit": "^1.6.2", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "@types/jest": "^26.0.15", + "@types/node": "^12.0.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@types/react-redux": "^7.1.20", + "env-paths": "^3.0.0", + "fslightbox-react": "^1.6.2-2", + "jest-watch-typeahead": "0.6.5", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-map-gl": "5.2.11", + "react-redux": "^7.2.6", + "react-scripts": "4.0.3", + "react-window": "^1.8.6", + "styled-components": "^5.3.3", + "taggr-shared": "1.0.0", + "typescript": "^4.1.2", + "web-vitals": "^1.0.1" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "overrides": [ + { + "files": [ + "**/*.stories.*" + ], + "rules": { + "import/no-anonymous-default-export": "off" + } + } + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@storybook/addon-actions": "^6.3.12", + "@storybook/addon-essentials": "^6.3.12", + "@storybook/addon-links": "^6.3.12", + "@storybook/node-logger": "^6.3.12", + "@storybook/preset-create-react-app": "^3.2.0", + "@storybook/react": "^6.3.12", + "@types/fslightbox-react": "^1.4.2", + "@types/lodash.debounce": "^4.0.6", + "@types/react-map-gl": "^6.1.1", + "@types/react-window": "^1.8.5", + "@types/styled-components": "^5.1.15" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..1c56565 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + taggr + + + +
+ + + diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/frontend/public/logo512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..5a5bd21 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "taggr", + "name": "taggr", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx new file mode 100644 index 0000000..cd6581f --- /dev/null +++ b/frontend/src/components/App.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import styled from "styled-components"; + +import StartPage from "./pages/start"; +import PreProcessingPage from "./pages/preprocessing"; +import DashboardPage from "./pages/dashboard"; +import SettingsPage from "./pages/settings"; + +import { useAppSelector } from "../store/hooks"; +import { types, utils } from "taggr-shared"; + +const Wrapper = styled.div` + height: 100%; + display: flex; + flex-direction: column; +`; + +const PageWrapper = styled.div` + height: 100%; +`; + +const App = () => { + const activeRoute = useAppSelector((s) => s.activeRoute); + + return ( + + {renderRoute(activeRoute)} + + ); +}; + +const renderRoute = (activeRoute: types.FrontendRoutes) => { + switch (activeRoute) { + case "START_PAGE": + return ; + case "PRE_PROCESSING_PAGE": + return ; + case "DASHBOARD_PAGE": + return ; + case "SETTINGS_PAGE": + return ; + default: + throw new utils.UnreachableCaseError(activeRoute); + } +}; + +export default App; diff --git a/src/FE/components/atoms/Typography.tsx b/frontend/src/components/atoms/Typography.tsx similarity index 63% rename from src/FE/components/atoms/Typography.tsx rename to frontend/src/components/atoms/Typography.tsx index 3d52619..7d36b81 100644 --- a/src/FE/components/atoms/Typography.tsx +++ b/frontend/src/components/atoms/Typography.tsx @@ -1,6 +1,5 @@ -// @ts-nocheck import React from "react"; -import MaterialTypography from "@material-ui/core/Typography"; +import MaterialTypography from "@mui/material/Typography"; const styles = { h1: { fontFamily: "Poppins, sans-serif" }, @@ -9,22 +8,23 @@ const styles = { h4: { fontFamily: "Poppins, sans-serif" }, h5: { fontFamily: "Poppins, sans-serif" }, h6: { fontFamily: "Poppins, sans-serif" }, - subtitle1: { fontFamily: "Poppins, sans-serif" }, - subtitle2: { fontFamily: "Poppins, sans-serif" }, + subtitle1: { fontFamily: "Open Sans" }, + subtitle2: { fontFamily: "Open Sans" }, body1: { fontFamily: "Open Sans" }, body2: { fontFamily: "Open Sans" }, - button: { fontFamily: "Poppins, sans-serif", fontWeight: 600 }, + button: { fontFamily: "Open Sans", fontWeight: 600 }, caption: { fontFamily: "Open Sans, sans-serif" }, overline: { fontFamily: "Open Sans, sans-serif" }, + inherit: { fontFamily: "Open Sans, sans-serif" }, }; -const Typography = (props) => { +const Typography = (props: React.ComponentProps) => { const { variant, style: propStyles = {} } = props; return ( {props.children} diff --git a/frontend/src/components/atoms/_atoms.stories.js b/frontend/src/components/atoms/_atoms.stories.js new file mode 100644 index 0000000..5f180ff --- /dev/null +++ b/frontend/src/components/atoms/_atoms.stories.js @@ -0,0 +1,53 @@ +import React from "react"; + +import Component from "./Typography"; + +export default { + title: "Atoms", + component: Component, +}; + +export const Typography = () => ( +
+ h1. taggr typography + h2. taggr typography + h3. taggr typography + h4. taggr typography + h5. taggr typography + h6. taggr typography + subtitle1. taggr typography + subtitle2. taggr typography + body1. taggr typography + body2. taggr typography + button. taggr typography +
+ caption. taggr typography +
+ overline. taggr typography +
+); +export const TypographyColors = () => ( +
+ + h1. taggr typography + + + h2. taggr typography + + + h3. taggr typography + + h4. taggr typography + h5. taggr typography + h6. taggr typography + subtitle1. taggr typography + subtitle2. taggr typography + body1. taggr typography + body2. taggr typography + button. taggr typography +
+ caption. taggr typography +
+ overline. taggr typography +
+); diff --git a/src/FE/components/molecules/ButtonFancy.tsx b/frontend/src/components/molecules/ButtonFancy.tsx similarity index 82% rename from src/FE/components/molecules/ButtonFancy.tsx rename to frontend/src/components/molecules/ButtonFancy.tsx index 4e40fe5..131647f 100644 --- a/src/FE/components/molecules/ButtonFancy.tsx +++ b/frontend/src/components/molecules/ButtonFancy.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { withStyles } from "@material-ui/core/styles"; -import Button from "@material-ui/core/Button"; +import { withStyles } from "@mui/styles"; +import Button from "@mui/material/Button"; import Typography from "../atoms/Typography"; @@ -14,6 +14,7 @@ const CustomButton = withStyles({ "&:hover": { transform: "scale(1.05)", }, + boxShadow: "4px 4px 8px #1f1f1f", }, label: { color: "white", @@ -28,7 +29,7 @@ const CustomButton = withStyles({ type Props = { text: string; - style?: Object; + style?: React.ComponentProps & React.CSSProperties; onClick: () => void; }; diff --git a/src/FE/components/molecules/ButtonFilter.tsx b/frontend/src/components/molecules/ButtonFilter.tsx similarity index 67% rename from src/FE/components/molecules/ButtonFilter.tsx rename to frontend/src/components/molecules/ButtonFilter.tsx index d09f959..5c87e33 100644 --- a/src/FE/components/molecules/ButtonFilter.tsx +++ b/frontend/src/components/molecules/ButtonFilter.tsx @@ -1,19 +1,25 @@ import React from "react"; -import Button from "@material-ui/core/Button"; +import Button from "@mui/material/Button"; import Typography from "../atoms/Typography"; +type Props = { + text: string; + styles?: React.ComponentProps; + disabled?: boolean; + active?: boolean; + onClick: () => void; +}; + const ButtonFilter = ({ - icon = "", - text = "", - active = false, + text, + styles, disabled = false, - onClick = () => 1, - style: styles = {}, -}) => ( + active = false, + onClick, +}: Props) => (