diff --git a/Procfile b/Procfile index d91a71ae..b26f0b6c 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ -react: npm run react-start \ No newline at end of file +react: yarn react-start +electron: yarn electron-start diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 459d6394..6c644ecc 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -330,3 +330,81 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========== + +foreman + +The MIT License + +Copyright (c) IBM Corp. 2012,2016. All Rights Reserved. + +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. + +========== + +file-type + +The MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +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. + +========== + +electron + +The MIT License + +Copyright (c) 2013-2020 GitHub Inc. + +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/app-icons/Tag.png b/app-icons/Tag.png new file mode 100644 index 00000000..dece4eaf Binary files /dev/null and b/app-icons/Tag.png differ diff --git a/docs/manual_testing/manual-test-runbook.md b/docs/manual_testing/manual-test-runbook.md index 41bb2ee8..6e2be0d6 100644 --- a/docs/manual_testing/manual-test-runbook.md +++ b/docs/manual_testing/manual-test-runbook.md @@ -1,5 +1,49 @@ # Test Runbook +## **Feat: support Electron for on premise solution** + +> ### Feature description ### +- Support FoTT's existing features in Electon +- Support local file system provider in Electron + +> ### Use Case ### + +**`As`** a user +**`I want`** to use FoTT's existing features through a desktop app +**`So`** I don't have to use a browser to use FoTT + +**`As`** a user +**`I want`** to use files in my local file system +**`So`** I can keep all files on premise + +> ### Acceptance criteria ### + +#### Scenario One #### + +**`Given`** I've installed new dependencies and started FoTT in Electron.\ +**`When`** I click a command item in the title bar.\ +**`Then`** FoTT should perform the command as expected.\ + +#### **Scenario Two** #### + +**`Given`** I've installed new dependencies and started FoTT in Electron.\ +**`When`** I perform an action for any existing feature.\ +**`Then`** FoTT should perform as expected (the same as through a browser).\ + +#### ***Scenario Three*** #### + +**`Given`** I've installed new dependencies and started FoTT in Electron.\ +**`When`** I create a new connection with local file system as the provider.\ +**`Then`** I should be able to create a project with the created connection.\ + +#### ***Scenario Four*** #### + +**`Given`** I've installed new dependencies and started FoTT in Electron. And, I have an existing project in my local file system.\ +**`When`** I click "Open local project" on the home page and select the existing project.\ +**`Then`** FoTT should load the project as expected.\ + +___ + ## **Fix: enable to reorder tags quickly** > ### Feature description ### @@ -20,7 +64,6 @@ Enable reordering tags quickly **`When`** I clicking fast on tags buttons `'Move tag up'` or `'Move tag down'`\ **`Then`** it moves without visible jittering. -___ ___ ## **Enable rerun OCR for current or all documents** @@ -51,8 +94,6 @@ Adding the following buttons to the canvas command bar: **`When`** I click "Run OCR on all documents" in the canvas command bar\ **`Then`** I should see "Running OCR..." for all documents. When running OCR finishes for each document, I should be ale to view each document's updated OCR JSON file. - -___ ___ ## **Feat: enable compose model and add model name when training a new model** @@ -87,7 +128,6 @@ ___ **`When`** I type customerized model name in input field and click compose button on modal\ **`Then`** I should see "Model is composing, please wait...". After that the list shows up again, new composed model with given name will be on the top of the list. The new composed model also has a "combine" icon. - #### ***Scenario Three*** #### **`Given`** I've opened a project containing documents and I'm on the Model Compose page.\ diff --git a/package.json b/package.json index c52f4820..e5a2e219 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "private": true, "main": "build/main.js", "dependencies": { + "file-type": "^14.6.2", "@azure/storage-blob": "10.3.0", "@fluentui/react": "^7.117.2", "axios": "^0.19.0", @@ -55,9 +56,9 @@ "webpack:prod": "webpack --config ./config/webpack.prod.js", "electron:run:dev": "yarn webpack:dev && electron . --remote-debugging-port=9223", "electron:run:prod": "yarn webpack:prod && electron . --remote-debugging-port=9223", - "electron:start:dev": "yarn webpack:dev && yarn electron-start", + "electron:start:dev": "yarn electron-start", "electron:start:prod": "yarn webpack:prod && yarn electron-start", - "electron-start": "node src/electron/start", + "electron-start": "yarn webpack:dev && node src/electron/start", "tslint": "./node_modules/.bin/tslint 'src/**/*.ts*'", "tslintfix": "./node_modules/.bin/tslint 'src/**/*.ts*' --fix" }, @@ -92,6 +93,7 @@ "@types/reactstrap": "^8.2.0", "@types/redux-logger": "^3.0.7", "acorn": "^7.1.1", + "foreman": "^3.0.1", "electron": "^8.3.0", "electron-builder": "^22.6.1", "enzyme": "^3.10.0", diff --git a/src/assets/sass/fabric-icons-inline.scss b/src/assets/sass/fabric-icons-inline.scss index 8efdeda6..1c879ad0 100644 --- a/src/assets/sass/fabric-icons-inline.scss +++ b/src/assets/sass/fabric-icons-inline.scss @@ -3,7 +3,7 @@ */ @font-face { font-family: 'FabricMDL2Icons'; - src: url('data:application/octet-stream;base64,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') format('truetype'); + src: url('data:application/octet-stream;base64,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') format('truetype'); } .ms-Icon { @@ -72,7 +72,10 @@ @mixin ms-Icon--MachineLearning { content: "\E3B8"; } @mixin ms-Icon--TagGroup { content: "\E3F6"; } @mixin ms-Icon--BookAnswers { content: "\F8A4"; } - +@mixin ms-Icon--ChromeRestore { content: "\E923"; } +@mixin ms-Icon--ChromeMinimize { content: "\E921"; } +@mixin ms-Icon--System { content: "\E770"; } +@mixin ms-Icon--SquareShape { content: "\F1A6"; } // Classes .ms-Icon--SortUp:before { @include ms-Icon--SortUp } @@ -130,4 +133,7 @@ .ms-Icon--MachineLearning:before { @include ms-Icon--MachineLearning } .ms-Icon--TagGroup:before { @include ms-Icon--TagGroup } .ms-Icon--BookAnswers:before { @include ms-Icon--BookAnswers } - +.ms-Icon--ChromeRestore:before { @include ms-Icon--ChromeRestore } +.ms-Icon--ChromeMinimize:before { @include ms-Icon--ChromeMinimize } +.ms-Icon--System:before { @include ms-Icon--System } +.ms-Icon--SquareShape:before { @include ms-Icon--SquareShape } diff --git a/src/common/crypto.ts b/src/common/crypto.ts index d498d135..8782bc9e 100644 --- a/src/common/crypto.ts +++ b/src/common/crypto.ts @@ -96,9 +96,14 @@ export async function decryptObject(encodedMessage: string, secret: str return JSON.parse(json) as T; } -export async function sha256Hash(message: string) { - const buffer = await crypto.subtle.digest("SHA-256", encodeUtf8(message)); - return encodeHex(buffer); +export async function sha256Hash(message: string, nodejsMode?: boolean) { + if (nodejsMode) { + const nodejsCrypto = await require('crypto'); + return await nodejsCrypto.createHash('sha256').update(message).digest("hex"); + } else { + const buffer = await crypto.subtle.digest("SHA-256", encodeUtf8(message)); + return encodeHex(buffer); + } } async function importKey(secretBytes: ArrayBuffer) { diff --git a/src/common/localization/en-us.ts b/src/common/localization/en-us.ts index 13cb1548..f8333da0 100644 --- a/src/common/localization/en-us.ts +++ b/src/common/localization/en-us.ts @@ -117,12 +117,15 @@ export const english: IAppStrings = { }, }, train: { + modelNameTitle: "Model name", + labelFolderTitle: "Label folder URI", + defaultLabelFolderURL: "https://example.com/folder", title: "Train", training: "Training", pleaseWait: "Please wait", notTrainedYet: "Not trained yet", backEndNotAvailable: "Checkbox feature will work in future version of Form Recognizer service, please stay tuned.", - addName: "Add model name...", + addName: "Add a model name...", }, modelCompose: { title: "Model compose", @@ -287,10 +290,10 @@ export const english: IAppStrings = { }, }, local: { - title: "Local File System", - folderPath: "Folder Path", - selectFolder: "Select Folder", - chooseFolder: "Choose Folder", + title: "Local file system", + folderPath: "Browse", + selectFolder: "Select folder", + chooseFolder: "Choose folder", }, }, }, diff --git a/src/common/localization/es-cl.ts b/src/common/localization/es-cl.ts index e61e6b01..899a72b7 100644 --- a/src/common/localization/es-cl.ts +++ b/src/common/localization/es-cl.ts @@ -118,6 +118,9 @@ export const spanish: IAppStrings = { }, }, train: { + modelNameTitle: "Nombre del modelo", + labelFolderTitle: "URI de carpeta de etiquetas", + defaultLabelFolderURL: "https://example.com/folder", title: "Entrenar", training: "Entrenamiento", pleaseWait: "Por favor espera", diff --git a/src/common/strings.ts b/src/common/strings.ts index 2bd7b8df..8c3e6139 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -117,6 +117,9 @@ export interface IAppStrings { }, }; train: { + modelNameTitle: string; + labelFolderTitle: string; + defaultLabelFolderURL: string; title: string; training: string; pleaseWait: string; diff --git a/src/common/themes.ts b/src/common/themes.ts index d312e34b..585907c5 100644 --- a/src/common/themes.ts +++ b/src/common/themes.ts @@ -226,7 +226,58 @@ const DarkDefaultPalette: Partial = { neutralLighterAlt: "#201f1e", white: "#1b1a19", redDark: "#F1707B", - }; +}; + +const lightGreyPalette = { + themePrimary: "#B8B8B9", + themeLighterAlt: "#070707", + themeLighter: "#1d1d1e", + themeLight: "#373738", + themeTertiary: "#6f6f70", + themeSecondary: "#a2a2a4", + themeDarkAlt: "#bfbfc1", + themeDark: "#c9c9cb", + themeDarker: "#d7d7d8", + neutralLighterAlt: "#4e5257", + neutralLighter: "#55595d", + neutralLight: "#606469", + neutralQuaternaryAlt: "#666b6f", + neutralQuaternary: "#6c7075", + neutralTertiaryAlt: "#83888c", + neutralTertiary: "#373738", + neutralSecondary: "#6f6f70", + neutralPrimaryAlt: "#a2a2a4", + neutralPrimary: "#B8B8B9", + neutralDark: "#c9c9cb", + black: "#d7d7d8", + white: "#474B4F" +} + +const subMenuPalette = { + themePrimary: "#f5f5f5", + themeLighterAlt: "#dadada", + themeLighter: "#bfbfbf", + themeLight: "#a4a4a4", + themeTertiary: "#898989", + themeSecondary: "#6e6e6e", + themeDarkAlt: "#535353", + themeDark: "#383838", + themeDarker: "#1d1d1d", + neutralLighterAlt: "#3f4246", + neutralLighter: "#464a4d", + neutralLight: "#525559", + neutralQuaternaryAlt: "#595d61", + neutralQuaternary: "#5f6367", + neutralTertiaryAlt: "#787d81", + neutralTertiary: "#e9e9e9", + neutralSecondary: "#ececec", + neutralPrimaryAlt: "#f0f0f0", + neutralPrimary: "#dedede", + neutralDark: "#f7f7f7", + black: "#fbfbfb", + white: "#373a3d" +} + const defaultDarkTheme = createTheme({palette: DarkDefaultPalette}); const whiteTheme = createTheme({palette: whiteButtonPalette}); @@ -237,6 +288,8 @@ const blueTheme = createTheme({palette: blueButtonPalette}); const darkTheme = createTheme({palette: darkThemePalette}); const darkGreyTheme = createTheme({palette: darkGreyPalette}); const greenWithWhiteBackgroundTheme = createTheme({palette: greenWithWhiteBackgroundPalette}); +const lightGreyTheme = createTheme({palette: lightGreyPalette}); +const subMenuTheme = createTheme({palette: subMenuPalette}) export function getPrimaryWhiteTheme() { return whiteTheme; @@ -273,3 +326,11 @@ export function getGreenWithWhiteBackgroundTheme() { export function getDefaultDarkTheme() { return defaultDarkTheme; } + +export function getSubMenuTheme() { + return subMenuTheme; +} + +export function getLightGreyTheme() { + return lightGreyTheme; +} diff --git a/src/config/fabric-icons.json b/src/config/fabric-icons.json index 8237d0b7..5d9a41ab 100644 --- a/src/config/fabric-icons.json +++ b/src/config/fabric-icons.json @@ -225,6 +225,22 @@ { "name": "BookAnswers", "unicode": "F8A4" + }, + { + "name": "ChromeRestore", + "unicode": "E923" + }, + { + "name": "ChromeMinimize", + "unicode": "E921" + }, + { + "name": "System", + "unicode": "E770" + }, + { + "name": "SquareShape", + "unicode": "F1A6" } ] } \ No newline at end of file diff --git a/src/electron/main.ts b/src/electron/main.ts index 30418515..9df254c4 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { app, ipcMain, BrowserWindow, BrowserWindowConstructorOptions } from "electron"; +import { + app, ipcMain, BrowserWindow, BrowserWindowConstructorOptions, + Menu, MenuItemConstructorOptions, +} from "electron"; import { IpcMainProxy } from "./common/ipcMainProxy"; import LocalFileSystem from "./providers/storage/localFileSystem"; @@ -14,10 +17,13 @@ async function createWindow() { const windowOptions: BrowserWindowConstructorOptions = { width: 1024, height: 768, + minWidth: 450, + minHeight: 100, frame: process.platform === "linux", titleBarStyle: "hidden", backgroundColor: "#272B30", show: false, + icon: "app-icons/Tag.png" }; const staticUrl = process.env.ELECTRON_START_URL || `file:///${__dirname}/index.html`; @@ -37,19 +43,82 @@ async function createWindow() { ipcMainProxy.unregisterAll(); }); - mainWindow.on("ready-to-show", () => { - mainWindow!.show(); + mainWindow.once("ready-to-show", () => { + mainWindow.show(); }); - if (!ipcMainProxy) { - ipcMainProxy = new IpcMainProxy(ipcMain, mainWindow); - - } + ipcMainProxy = new IpcMainProxy(ipcMain, mainWindow); + registerContextMenu(mainWindow); const localFileSystem = new LocalFileSystem(mainWindow); ipcMainProxy.registerProxy("LocalFileSystem", localFileSystem); } +/** + * Adds standard cut/copy/paste/etc context menu comments when right clicking input elements + * @param browserWindow The browser window to apply the context-menu items + */ +function registerContextMenu(browserWindow: BrowserWindow): void { + const selectionMenu = Menu.buildFromTemplate([ + { role: "copy", accelerator: "CmdOrCtrl+C" }, + { type: "separator" }, + { role: "selectAll", accelerator: "CmdOrCtrl+A" }, + ]); + + const inputMenu = Menu.buildFromTemplate([ + { role: "undo", accelerator: "CmdOrCtrl+Z" }, + { role: "redo", accelerator: "CmdOrCtrl+Shift+Z" }, + { type: "separator", label: "separator1"}, + { role: "cut", accelerator: "CmdOrCtrl+X" }, + { role: "copy", accelerator: "CmdOrCtrl+C" }, + { role: "paste", accelerator: "CmdOrCtrl+V" }, + { type: "separator", label: "separator2"}, + { role: "selectAll", accelerator: "CmdOrCtrl+A" }, + ]); + + browserWindow.webContents.on("context-menu", (e, props) => { + const { selectionText, isEditable } = props; + if (isEditable) { + inputMenu.popup({ + window: browserWindow, + }); + } else if (selectionText && selectionText.trim() !== "") { + selectionMenu.popup({ + window: browserWindow, + }); + } + }); + + const menuItems: MenuItemConstructorOptions[] = [ + { + label: "File", submenu: [ + { role: "quit" }, + ], + }, + // { role: "editMenu" }, + { + label: "View", submenu: [ + { role: "reload" }, + { type: "separator", label: "separator1" }, + { role: "toggleDevTools" }, + { role: "togglefullscreen" }, + { type: "separator", label: "separator2" }, + { role: "resetZoom", label: "Reset Zoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + ], + }, + { + label: "Window", submenu: [ + { role: "minimize" }, + { role: "close" }, + ] + }, + ]; + const menu = Menu.buildFromTemplate(menuItems); + Menu.setApplicationMenu(menu); +} + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. diff --git a/src/electron/providers/storage/localFileSystem.ts b/src/electron/providers/storage/localFileSystem.ts index dea4da18..e174feb5 100644 --- a/src/electron/providers/storage/localFileSystem.ts +++ b/src/electron/providers/storage/localFileSystem.ts @@ -11,6 +11,9 @@ import { AssetService } from "../../../services/assetService"; import { constants } from "../../../common/constants"; import { strings } from "../../../common/strings"; +// tslint:disable-next-line:no-var-requires +const FileType = require('file-type'); + export default class LocalFileSystem implements IStorageProvider { public storageType: StorageType.Local; @@ -46,6 +49,10 @@ export default class LocalFileSystem implements IStorageProvider { }); } + public getFileType(filePath: string): Promise { + return FileType.fromFile(filePath); + } + public readBinary(filePath: string): Promise { return new Promise((resolve, reject) => { fs.readFile(path.normalize(filePath), (err: NodeJS.ErrnoException, data: Buffer) => { @@ -146,11 +153,10 @@ export default class LocalFileSystem implements IStorageProvider { const result: IAsset[] = []; const files = await this.listFiles(path.normalize(folderPath)); for (const file of files) { - const asset = await AssetService.createAssetFromFilePath(file); + const asset = await AssetService.createAssetFromFilePath(file, undefined, true); if (this.isSupportedAssetType(asset.type)) { - const labelFileName = decodeURIComponent(`${asset.name}${constants.labelFileExtension}`); - const ocrFileName = decodeURIComponent(`${asset.name}${constants.ocrFileExtension}`); - + const labelFileName = decodeURIComponent(`${file}${constants.labelFileExtension}`); + const ocrFileName = decodeURIComponent(`${file}${constants.ocrFileExtension}`); if (files.find((str) => str === labelFileName)) { asset.state = AssetState.Tagged; } else if (files.find((str) => str === ocrFileName)) { diff --git a/src/models/applicationState.ts b/src/models/applicationState.ts index 88675ed4..b1748db3 100644 --- a/src/models/applicationState.ts +++ b/src/models/applicationState.ts @@ -303,6 +303,7 @@ export enum ErrorCode { HttpStatusNotFound = "notFound", HttpStatusTooManyRequests = "tooManyRequests", RequestSendError = "requestSendError", + ProjectUploadError = "ProjectUploadError", } /** diff --git a/src/providers/storage/localFileSystemProxy.json b/src/providers/storage/localFileSystemProxy.json new file mode 100644 index 00000000..3cc8ff38 --- /dev/null +++ b/src/providers/storage/localFileSystemProxy.json @@ -0,0 +1,13 @@ +{ + "title": "${strings.connections.providers.local.title}", + "required": [ + "folderPath" + ], + "type": "object", + "properties": { + "folderPath": { + "title": "${strings.connections.providers.local.folderPath}", + "type": "string" + } + } +} diff --git a/src/providers/storage/localFileSystemProxy.ts b/src/providers/storage/localFileSystemProxy.ts index 6cf4bb5d..c1f82d67 100644 --- a/src/providers/storage/localFileSystemProxy.ts +++ b/src/providers/storage/localFileSystemProxy.ts @@ -49,6 +49,11 @@ export class LocalFileSystemProxy implements IStorageProvider, IAssetProvider { return IpcRendererProxy.send(`${PROXY_NAME}:readText`, [filePath]); } + public getFileType(fileName: string): Promise { + const filePath = [this.options.folderPath, fileName].join("/"); + return IpcRendererProxy.send(`${PROXY_NAME}:getFileType`, [filePath]); + } + /** * Read buffer from file * @param fileName Name of file from which to read buffer diff --git a/src/providers/storage/localFileSystemProxy.ui.json b/src/providers/storage/localFileSystemProxy.ui.json new file mode 100644 index 00000000..b8e79aa9 --- /dev/null +++ b/src/providers/storage/localFileSystemProxy.ui.json @@ -0,0 +1,5 @@ +{ + "folderPath": { + "ui:widget": "localFolderPicker" + } +} diff --git a/src/providers/storage/storageProviderFactory.ts b/src/providers/storage/storageProviderFactory.ts index 4c869330..cc150c85 100644 --- a/src/providers/storage/storageProviderFactory.ts +++ b/src/providers/storage/storageProviderFactory.ts @@ -24,6 +24,7 @@ export interface IStorageProvider extends IAssetProvider { storageType: StorageType; readText(filePath: string, ignoreNotFound?: boolean): Promise; + getFileType?(filePath: string): Promise; readBinary(filePath: string): Promise; deleteFile(filePath: string, ignoreNotFound?: boolean, ignoreForbidden?: boolean): Promise; diff --git a/src/react/components/common/common.scss b/src/react/components/common/common.scss index 4f5d84ad..f1a71627 100644 --- a/src/react/components/common/common.scss +++ b/src/react/components/common/common.scss @@ -176,3 +176,9 @@ .align-self-end { align-self: flex-end; } + +.flex-textbox { + flex: 1; + cursor: pointer; + width: 100%; +} diff --git a/src/react/components/common/filePicker/filePicker.tsx b/src/react/components/common/filePicker/filePicker.tsx new file mode 100644 index 00000000..7f2b8a49 --- /dev/null +++ b/src/react/components/common/filePicker/filePicker.tsx @@ -0,0 +1,52 @@ +import React, { SyntheticEvent } from "react"; +import shortid from "shortid"; +import HtmlFileReader from "../../../../common/htmlFileReader"; +import { IFileInfo } from "../../../../models/applicationState"; + +/** + * Properties for File Picker + * @member onChange - Function to call on change of file selection + * @member onError - Function to call on file picking error + */ +export interface IFilePickerProps { + onChange: (sender: SyntheticEvent, fileText: IFileInfo) => void; + onError: (sender: SyntheticEvent, error: any) => void; +} + +/** + * @name - File Picker + * @description - Pick file from local file system + */ +export default class FilePicker extends React.Component { + private fileInput; + + constructor(props, context) { + super(props, context); + + this.fileInput = React.createRef(); + this.onFileUploaded = this.onFileUploaded.bind(this); + } + + /** + * Call click on current file input + */ + public upload = () => { + this.fileInput.current.click(); + } + + public render() { + return ( + + ); + } + + private onFileUploaded = (e) => { + if (e.target.files.length === 0) { + this.props.onError(e, "No files were selected"); + } + + HtmlFileReader.readAsText(e.target.files[0]) + .then((fileInfo) => this.props.onChange(e, fileInfo)) + .catch((err) => this.props.onError(e, err)); + } +} diff --git a/src/react/components/common/localFolderPicker/localFolderPicker.tsx b/src/react/components/common/localFolderPicker/localFolderPicker.tsx new file mode 100644 index 00000000..420a0b89 --- /dev/null +++ b/src/react/components/common/localFolderPicker/localFolderPicker.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { LocalFileSystemProxy } from "../../../../providers/storage/localFileSystemProxy"; +import { strings } from "../../../../common/strings"; +import { TextField, PrimaryButton } from "@fluentui/react"; +import { getPrimaryGreenTheme, getGreenWithWhiteBackgroundTheme } from "../../../../common/themes"; + +/** + * Properties for Local Folder Picker + * @member id - ID for HTML form control element + * @member value - Initial value for picker + * @member onChange - Function to call on change to selected value + */ +interface ILocalFolderPickerProps { + id?: string; + value: string; + onChange: (value) => void; +} + +/** + * State for Local Folder Picker + * @member value - Selected folder + */ +interface ILocalFolderPickerState { + value: string; +} + +/** + * @name - Local Folder Picker + * @description - Select folder from local file system + */ +export default class LocalFolderPicker extends React.Component { + private localFileSystem: LocalFileSystemProxy; + + constructor(props, context) { + super(props, context); + + this.state = { + value: this.props.value || "", + }; + + this.localFileSystem = new LocalFileSystemProxy(); + this.selectLocalFolder = this.selectLocalFolder.bind(this); + } + + public render() { + const { value } = this.state; + + return ( +
+ + +
+ ); + } + + public componentDidUpdate(prevProps) { + if (prevProps.value !== this.props.value) { + this.setState({ + value: this.props.value, + }); + } + } + + private selectLocalFolder = async () => { + const filePath = await this.localFileSystem.selectContainer(); + if (filePath) { + this.setState({ + value: filePath, + }, () => this.props.onChange(filePath)); + } + } +} diff --git a/src/react/components/pages/appSettings/appSettingsPage.tsx b/src/react/components/pages/appSettings/appSettingsPage.tsx index 8e3dcdab..013d3c5e 100644 --- a/src/react/components/pages/appSettings/appSettingsPage.tsx +++ b/src/react/components/pages/appSettings/appSettingsPage.tsx @@ -12,7 +12,6 @@ import { strings } from "../../../../common/strings"; import { AppSettingsForm } from "./appSettingsForm"; import { RouteComponentProps } from "react-router-dom"; import { toast } from "react-toastify"; -import { SkipButton } from "../../shell/skipButton"; /** * Props for App Settings Page diff --git a/src/react/components/pages/connections/connectionForm.json b/src/react/components/pages/connections/connectionForm.json index ea790e25..c20cd7d3 100644 --- a/src/react/components/pages/connections/connectionForm.json +++ b/src/react/components/pages/connections/connectionForm.json @@ -11,6 +11,10 @@ "description": { "title": "${strings.common.description}", "type": "string" + }, + "providerType": { + "title": "${strings.common.provider}", + "type": "string" } } -} \ No newline at end of file +} diff --git a/src/react/components/pages/connections/connectionForm.tsx b/src/react/components/pages/connections/connectionForm.tsx index 3c8bb127..425280cc 100644 --- a/src/react/components/pages/connections/connectionForm.tsx +++ b/src/react/components/pages/connections/connectionForm.tsx @@ -6,6 +6,7 @@ import Form, { Widget, IChangeEvent, FormValidation } from "react-jsonschema-for import { FontIcon, PrimaryButton} from "@fluentui/react"; import { IConnection } from "../../../../models/applicationState"; import { strings, addLocValues } from "../../../../common/strings"; +import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker"; import CustomFieldTemplate from "../../common/customField/customFieldTemplate"; import ConnectionProviderPicker from "../../common/connectionProviderPicker/connectionProviderPicker"; import { ProtectedInput } from "../../common/protectedInput/protectedInput"; @@ -53,6 +54,7 @@ export interface IConnectionFormState { */ export default class ConnectionForm extends React.Component { private widgets = { + localFolderPicker: (LocalFolderPicker as any) as Widget, connectionProviderPicker: (ConnectionProviderPicker as any) as Widget, protectedInput: (ProtectedInput as any) as Widget, checkbox: CustomWidget(Checkbox, (props) => ({ diff --git a/src/react/components/pages/connections/connectionsPage.tsx b/src/react/components/pages/connections/connectionsPage.tsx index 923aeede..4edf3a5f 100644 --- a/src/react/components/pages/connections/connectionsPage.tsx +++ b/src/react/components/pages/connections/connectionsPage.tsx @@ -16,7 +16,6 @@ import ConnectionForm from "./connectionForm"; import ConnectionItem from "./connectionItem"; import "./connectionsPage.scss"; import { toast } from "react-toastify"; -import { SkipButton } from "../../shell/skipButton"; /** * Properties for Connection Page @@ -149,7 +148,9 @@ export default class ConnectionPage extends React.Component { try { - connection.providerOptions["sas"] = connection.providerOptions["sas"].trim(); + if (connection.providerType === "azureBlobStorage") { + connection.providerOptions["sas"] = connection.providerOptions["sas"].trim(); + } await this.props.actions.saveConnection(connection); toast.success(interpolate(strings.connections.messages.saveSuccess, { connection })); diff --git a/src/react/components/pages/homepage/homePage.tsx b/src/react/components/pages/homepage/homePage.tsx index 2ebb5e4e..1533758a 100644 --- a/src/react/components/pages/homepage/homePage.tsx +++ b/src/react/components/pages/homepage/homePage.tsx @@ -12,6 +12,7 @@ import IProjectActions, * as projectActions from "../../../../redux/actions/proj import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions"; import IAppTitleActions, * as appTitleActions from "../../../../redux/actions/appTitleActions"; import { CloudFilePicker } from "../../common/cloudFilePicker/cloudFilePicker"; +import FilePicker from "../../common/filePicker/filePicker"; import CondensedList from "../../common/condensedList/condensedList"; import Confirm from "../../common/confirm/confirm"; import "./homePage.scss"; @@ -24,7 +25,7 @@ import { import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory"; import { decryptProject } from "../../../../common/utils"; import { toast } from "react-toastify"; -import { SkipButton } from "../../shell/skipButton"; +import { isElectron } from "../../../../common/hostProcess"; export interface IHomePageProps extends RouteComponentProps, React.Props { recentProjects: IProject[]; @@ -64,9 +65,11 @@ export default class HomePage extends React.Component = React.createRef(); private newProjectRef = React.createRef(); private deleteConfirmRef = React.createRef(); private cloudFilePickerRef = React.createRef(); + private importConfirmRef: React.RefObject = React.createRef(); public async componentDidMount() { this.props.appTitleActions.setTitle("Welcome"); @@ -92,6 +95,18 @@ export default class HomePage extends React.Component{strings.homePage.newProject} + {isElectron() && +
  • + this.filePicker.current.upload()} > + +
    {strings.homePage.openLocalProject.title}
    +
    + +
  • + }
  • {/*Open Cloud Project*/} {/* eslint-disable-next-line */} @@ -185,4 +200,30 @@ export default class HomePage extends React.Component { await this.props.actions.deleteProject(project); } + + private onProjectFileUpload = async (e, project) => { + let projectJson: IProject; + + try { + projectJson = JSON.parse(project.content); + } catch (error) { + throw new AppError(ErrorCode.ProjectInvalidJson, "Error parsing JSON"); + } + + if (projectJson.name === null || projectJson.name === undefined) { + try { + await this.importConfirmRef.current.open(project); + } catch (e) { + throw new Error(e.message); + } + } else { + await this.loadSelectedProject(projectJson); + } + } + + private onProjectFileUploadError = (e, error: any) => { + if (error instanceof AppError) { + throw error; + } + } } diff --git a/src/react/components/pages/predict/predictPage.tsx b/src/react/components/pages/predict/predictPage.tsx index 7a008f7e..12446ed3 100644 --- a/src/react/components/pages/predict/predictPage.tsx +++ b/src/react/components/pages/predict/predictPage.tsx @@ -32,7 +32,6 @@ import { parseTiffData, renderTiffToCanvas, loadImageToCanvas } from "../../../. import { constants } from "../../../../common/constants"; import { getPrimaryGreenTheme, getPrimaryWhiteTheme, getGreenWithWhiteBackgroundTheme } from "../../../../common/themes"; -import { SkipButton } from "../../shell/skipButton"; import axios from "axios"; const cMapUrl = constants.pdfjsCMapUrl(pdfjsLib.version); @@ -220,7 +219,6 @@ export default class PredictPage extends React.Component { connections: IConnection[]; @@ -37,6 +37,7 @@ export interface ITrainPageProps extends RouteComponentProps, React.Props @@ -132,24 +138,42 @@ export default class TrainPage extends React.Component

    Train a new model

    - {!this.state.isTraining ? ( + {!this.state.isTraining && localFileSystemProvider &&
    - Model Name + {strings.train.labelFolderTitle} - + className="label-folder-url-input" + theme={getGreenWithWhiteBackgroundTheme()} + onFocus={this.removeDefaultInputedLabelFolderURL} + onChange={this.setInputedLabelFolderURL} + placeholder={strings.train.defaultLabelFolderURL} + value={this.state.inputedLabelFolderURL} + /> +
    + } + + {strings.train.modelNameTitle} + + + + {!this.state.isTraining ? ( +
    + onClick={this.handleTrainClick} + disabled={trainDisabled}>
    {strings.train.title}
    @@ -193,6 +217,16 @@ export default class TrainPage extends React.Component { + if (this.state.inputedLabelFolderURL === strings.train.defaultLabelFolderURL) { + this.setState({inputedLabelFolderURL: ""}); + } + } + + private setInputedLabelFolderURL = (event) => { + this.setState({inputedLabelFolderURL: event.target.value}); + } + private onTextChanged = (ev: React.FormEvent, text: string) => { this.modelName = text; } @@ -247,12 +281,20 @@ export default class TrainPage extends React.Component - Train Message + Train message {this.props.trainMessage} diff --git a/src/react/components/shell/titleBar.scss b/src/react/components/shell/titleBar.scss index fc9b0c88..1c70f4fc 100644 --- a/src/react/components/shell/titleBar.scss +++ b/src/react/components/shell/titleBar.scss @@ -378,3 +378,25 @@ } } } + +.app-close-icon { + padding: 6px 18px; + display: inline-block; + &:hover { + color: white; + background-color: red; + cursor: pointer; + } +} + +.end-icons { + padding: 6px 18px; + color: #ccc; + display: inline-block; + + &:hover { + color: #fff; + background-color: $lighter-2; + cursor: pointer; + } +} diff --git a/src/react/components/shell/titleBar.tsx b/src/react/components/shell/titleBar.tsx index 0e028cb0..1c50c306 100644 --- a/src/react/components/shell/titleBar.tsx +++ b/src/react/components/shell/titleBar.tsx @@ -3,9 +3,10 @@ import React from "react"; import { connect } from "react-redux"; -import { FontIcon } from "@fluentui/react"; +import { FontIcon, CommandBarButton, IButtonStyles, IIconProps, IOverflowSetItemProps, OverflowSet, Customizer, ICustomizations, Separator, ContextualMenuItemType } from "@fluentui/react"; import { IApplicationState } from "../../../models/applicationState"; -import { PlatformType } from "../../../common/hostProcess"; +import { PlatformType, isElectron } from "../../../common/hostProcess"; +import { getLightGreyTheme, getSubMenuTheme } from "../../../common/themes"; import "./titleBar.scss"; export interface ITitleBarProps extends React.Props { @@ -14,9 +15,11 @@ export interface ITitleBarProps extends React.Props { } export interface ITitleBarState { + isElectron: boolean; platform: string; maximized: boolean; fullscreen: boolean; + menu: Electron.Menu; } function mapStateToProps(state: IApplicationState) { @@ -28,29 +31,202 @@ function mapStateToProps(state: IApplicationState) { @connect(mapStateToProps, null) export class TitleBar extends React.Component { public state: ITitleBarState = { + isElectron: false, platform: global && global.process && global.process.platform ? global.process.platform : PlatformType.Web, maximized: false, fullscreen: false, + menu: null, }; + private isElectron: boolean; + private remote: Electron.Remote; + private currentWindow: Electron.BrowserWindow; + + public componentDidMount() { + this.isElectron = isElectron(); + + if (this.isElectron) { + this.remote = window.require("electron").remote as Electron.Remote; + this.currentWindow = this.remote.getCurrentWindow(); + this.currentWindow.on("maximize", () => this.onMaximize(true)); + this.currentWindow.on("unmaximize", () => this.onMaximize(false)); + this.currentWindow.on("enter-full-screen", () => this.onFullScreen(true)); + this.currentWindow.on("leave-full-screen", () => this.onFullScreen(false)); + + this.setState({ + maximized: this.currentWindow.isMaximized(), + fullscreen: this.currentWindow.isFullScreen(), + menu: this.remote.Menu.getApplicationMenu(), + }); + } + } + public render() { if (this.state.fullscreen) { return null; } + const titleBarTheme: ICustomizations = { + settings: { + theme: getLightGreyTheme(), + }, + scopedSettings: {}, + }; + + const onRenderItem = (item: IOverflowSetItemProps): JSX.Element => { + const buttonStyles: Partial = { + root: { + padding: '0 5px', + alignSelf: 'stretch', + } + }; + const iconStyles: Partial = { + style: {display: "none"} + }; + + return ( + + ); + }; + + const onRenderOverflowButton = (overflowItems: any[] | undefined): JSX.Element => { + return ( + + ); + }; + return (
    - {(this.state.platform === PlatformType.Web) && -
    - {typeof (this.props.icon) === "string" && } - {typeof (this.props.icon) !== "string" && this.props.icon} -
    +
    + {typeof (this.props.icon) === "string" && } + {typeof (this.props.icon) !== "string" && this.props.icon} +
    + {this.isElectron && + + + }
    {this.props.title || "Welcome"}
    {this.props.children} + {this.isElectron && + [ + , +
    + +
    , +
    + {this.state.maximized + ? + : + } +
    , +
    + +
    + ] + }
    ); } + + + private addDefaultMenuItems = (menu: Electron.Menu) => { + if (!menu) { + return null; + } + + return menu.items.reduce(this.renderMenuItem, []); + } + + private renderMenuItem = (results, menuItem: Electron.MenuItem) => { + if (!menuItem.visible) { + return null; + } + + const itemType: string = menuItem["type"]; + + switch (itemType) { + case "separator": + results.push({ + key: menuItem.label, + itemType: ContextualMenuItemType.Divider, + }) + break; + case "submenu": + results.push({ + key: menuItem.label, + name: menuItem.label, + subMenuProps: { + theme: getSubMenuTheme(), + items: this.addDefaultMenuItems(menuItem["submenu"]), + } + }); + break; + case "normal": + results.push({ + key: menuItem.label, + name: menuItem.label, + onClick: (e) => this.onMenuItemClick(e, menuItem) + }); + + } + return results; + } + + private onMenuItemClick(e: any, menuItem: Electron.MenuItem) { + if (menuItem.label === "Zoom In") { + this.currentWindow.webContents.setZoomLevel(this.currentWindow.webContents.zoomLevel + .3); + } else if (menuItem.label === "Zoom Out") { + this.currentWindow.webContents.setZoomLevel(this.currentWindow.webContents.zoomLevel - .3); + } else if (menuItem.label === "Reset Zoom") { + this.currentWindow.webContents.setZoomLevel(-3); + } else if (menuItem.click) { + menuItem.click.call(menuItem, menuItem, this.currentWindow); + } + } + + private onMaximize = (isMaximized: boolean) => { + this.setState({ + maximized: isMaximized, + }); + } + + private onFullScreen = (isFullScreen: boolean) => { + this.setState({ + fullscreen: isFullScreen, + }); + } + + private minimizeWindow = () => { + this.currentWindow.minimize(); + } + + private maximizeWindow = () => { + this.currentWindow.maximize(); + } + + private unmaximizeWindow = () => { + this.currentWindow.unmaximize(); + } + + private closeWindow = () => { + this.currentWindow.close(); + } + } diff --git a/src/registerIcons.ts b/src/registerIcons.ts index 68147e5b..85b31e64 100644 --- a/src/registerIcons.ts +++ b/src/registerIcons.ts @@ -65,6 +65,10 @@ export function registerIcons() { More: "\uE712", ReceiptProcessing: "\uE496", KeyPhraseExtraction: "\uE395", + ChromeRestore: "\uE923", + ChromeMinimize: "\uE921", + System: "\uE770", + SquareShape: "\uF1A6", }, }); } diff --git a/src/registerProviders.ts b/src/registerProviders.ts index 4d57bbeb..31f06820 100644 --- a/src/registerProviders.ts +++ b/src/registerProviders.ts @@ -6,12 +6,20 @@ import { AzureBlobStorage } from "./providers/storage/azureBlobStorage"; import { StorageProviderFactory } from "./providers/storage/storageProviderFactory"; import registerToolbar from "./registerToolbar"; import { strings } from "./common/strings"; +import { HostProcessType } from "./common/hostProcess"; +import { LocalFileSystemProxy } from "./providers/storage/localFileSystemProxy"; /** * Registers storage, asset and export providers, as well as all toolbar items */ export default function registerProviders() { // Storage Providers + StorageProviderFactory.register({ + name: "localFileSystemProxy", + displayName: strings.connections.providers.local.title, + platformSupport: HostProcessType.Electron, + factory: (options) => new LocalFileSystemProxy(options), + }); StorageProviderFactory.register({ name: "azureBlobStorage", displayName: strings.connections.providers.azureBlob.title, @@ -19,6 +27,12 @@ export default function registerProviders() { }); // Asset Providers + AssetProviderFactory.register({ + name: "localFileSystemProxy", + displayName: strings.connections.providers.local.title, + platformSupport: HostProcessType.Electron, + factory: (options) => new LocalFileSystemProxy(options), + }); AssetProviderFactory.register({ name: "azureBlobStorage", displayName: strings.connections.providers.azureBlob.title, diff --git a/src/services/assetService.ts b/src/services/assetService.ts index e463a149..b0bee8d4 100644 --- a/src/services/assetService.ts +++ b/src/services/assetService.ts @@ -62,7 +62,7 @@ export class AssetService { * @param filePath - filepath of asset * @param fileName - name of asset */ - public static async createAssetFromFilePath(filePath: string, fileName?: string): Promise { + public static async createAssetFromFilePath(filePath: string, fileName?: string, nodejsMode?: boolean): Promise { Guard.empty(filePath); const normalizedPath = filePath.toLowerCase(); @@ -76,7 +76,7 @@ export class AssetService { filePath = encodeFileURI(filePath, true); } - const hash = await sha256Hash(filePath); + const hash = await sha256Hash(filePath, nodejsMode); // eslint-disable-next-line const pathParts = filePath.split(/[\\\/]/); fileName = fileName || pathParts[pathParts.length - 1]; @@ -87,7 +87,14 @@ export class AssetService { let assetFormat = extensionParts[0].toLowerCase(); if (supportedImageFormats.hasOwnProperty(assetFormat)) { - const types = await this.getMimeType(filePath); + let types; + if (nodejsMode) { + const FileType = require('file-type'); + const fileType = await FileType.fromFile(normalizedPath); + types = [fileType.ext]; + } else { + types = await this.getMimeType(filePath); + } // If file was renamed/spoofed - fix file extension to true MIME type and show message if (!types.includes(assetFormat)) { diff --git a/src/services/ocrService.ts b/src/services/ocrService.ts index 4a1ccefe..2044287b 100644 --- a/src/services/ocrService.ts +++ b/src/services/ocrService.ts @@ -82,10 +82,28 @@ export class OCRService { private fetchOcrUriResult = async (filePath: string, ocrFileName: string) => { try { - const headers = { "Content-Type": "application/json" }; + let body; + let headers; + if (filePath.startsWith("file:")) { + const splitFilePath = filePath.split("/") + const fileName = splitFilePath[splitFilePath.length - 1]; + const bodyAndType = await Promise.all( + [ + this.storageProvider.readBinary(decodeURI(fileName)), + this.storageProvider.getFileType(decodeURI(fileName)) + ] + ); + body = bodyAndType[0]; + const fileType = bodyAndType[1].mime; + headers = { "Content-Type": fileType, "cache-control": "no-cache" }; + } + else { + body = { url: filePath }; + headers = { "Content-Type": "application/json" }; + } const response = await ServiceHelper.postWithAutoRetry( this.project.apiUriBase + "/formrecognizer/v2.0-preview/layout/analyze", - { url: filePath }, + body, { headers }, this.project.apiKey as string, );