feat: support electron for on premise solution (#333)

* feat: support electron for on premise solution

* refactor: remove trailing whitespaces and empty lines

* feat: add app icon for os taskbar

* refactor: fix indent, variable naming, and brackets

* docs: update test runbook and third party notices
This commit is contained in:
stew-ro 2020-06-23 13:49:59 -07:00 коммит произвёл GitHub
Родитель 8297b18a08
Коммит ca0bd0c2ab
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
38 изменённых файлов: 856 добавлений и 64 удалений

Просмотреть файл

@ -1 +1,2 @@
react: npm run react-start
react: yarn react-start
electron: yarn electron-start

Просмотреть файл

@ -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 <sindresorhus@gmail.com> (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.
==========

Двоичные данные
app-icons/Tag.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.8 KiB

Просмотреть файл

@ -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.\

Просмотреть файл

@ -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",

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@ -96,9 +96,14 @@ export async function decryptObject<T = any>(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) {

Просмотреть файл

@ -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",
},
},
},

Просмотреть файл

@ -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",

Просмотреть файл

@ -117,6 +117,9 @@ export interface IAppStrings {
},
};
train: {
modelNameTitle: string;
labelFolderTitle: string;
defaultLabelFolderURL: string;
title: string;
training: string;
pleaseWait: string;

Просмотреть файл

@ -226,7 +226,58 @@ const DarkDefaultPalette: Partial<IPalette> = {
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;
}

Просмотреть файл

@ -225,6 +225,22 @@
{
"name": "BookAnswers",
"unicode": "F8A4"
},
{
"name": "ChromeRestore",
"unicode": "E923"
},
{
"name": "ChromeMinimize",
"unicode": "E921"
},
{
"name": "System",
"unicode": "E770"
},
{
"name": "SquareShape",
"unicode": "F1A6"
}
]
}

Просмотреть файл

@ -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.

Просмотреть файл

@ -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<Buffer> {
return FileType.fromFile(filePath);
}
public readBinary(filePath: string): Promise<Buffer> {
return new Promise<Buffer>((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)) {

Просмотреть файл

@ -303,6 +303,7 @@ export enum ErrorCode {
HttpStatusNotFound = "notFound",
HttpStatusTooManyRequests = "tooManyRequests",
RequestSendError = "requestSendError",
ProjectUploadError = "ProjectUploadError",
}
/**

Просмотреть файл

@ -0,0 +1,13 @@
{
"title": "${strings.connections.providers.local.title}",
"required": [
"folderPath"
],
"type": "object",
"properties": {
"folderPath": {
"title": "${strings.connections.providers.local.folderPath}",
"type": "string"
}
}
}

Просмотреть файл

@ -49,6 +49,11 @@ export class LocalFileSystemProxy implements IStorageProvider, IAssetProvider {
return IpcRendererProxy.send(`${PROXY_NAME}:readText`, [filePath]);
}
public getFileType(fileName: string): Promise<any> {
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

Просмотреть файл

@ -0,0 +1,5 @@
{
"folderPath": {
"ui:widget": "localFolderPicker"
}
}

Просмотреть файл

@ -24,6 +24,7 @@ export interface IStorageProvider extends IAssetProvider {
storageType: StorageType;
readText(filePath: string, ignoreNotFound?: boolean): Promise<string>;
getFileType?(filePath: string): Promise<any>;
readBinary(filePath: string): Promise<Buffer>;
deleteFile(filePath: string, ignoreNotFound?: boolean, ignoreForbidden?: boolean): Promise<void>;

Просмотреть файл

@ -176,3 +176,9 @@
.align-self-end {
align-self: flex-end;
}
.flex-textbox {
flex: 1;
cursor: pointer;
width: 100%;
}

Просмотреть файл

@ -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<IFilePickerProps> {
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 (
<input id={shortid.generate()} ref={this.fileInput} type="file" onChange={this.onFileUploaded} />
);
}
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));
}
}

Просмотреть файл

@ -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<ILocalFolderPickerProps, ILocalFolderPickerState> {
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 (
<div className="input-group">
<TextField
className="mr-2 flex-textbox"
theme={getGreenWithWhiteBackgroundTheme()}
style={{cursor: "pointer"}}
onClick={this.selectLocalFolder}
readOnly={true}
value={value}
/>
<PrimaryButton
className="keep-button-80px"
theme={getPrimaryGreenTheme()}
text={strings.connections.providers.local.folderPath}
autoFocus={true}
onClick={this.selectLocalFolder}
/>
</div>
);
}
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));
}
}
}

Просмотреть файл

@ -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

Просмотреть файл

@ -11,6 +11,10 @@
"description": {
"title": "${strings.common.description}",
"type": "string"
},
"providerType": {
"title": "${strings.common.provider}",
"type": "string"
}
}
}
}

Просмотреть файл

@ -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<IConnectionFormProps, IConnectionFormState> {
private widgets = {
localFolderPicker: (LocalFolderPicker as any) as Widget,
connectionProviderPicker: (ConnectionProviderPicker as any) as Widget,
protectedInput: (ProtectedInput as any) as Widget,
checkbox: CustomWidget(Checkbox, (props) => ({

Просмотреть файл

@ -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<IConnectionPageProps
private onFormSubmit = async (connection: IConnection) => {
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 }));

Просмотреть файл

@ -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<HomePage> {
recentProjects: IProject[];
@ -64,9 +65,11 @@ export default class HomePage extends React.Component<IHomePageProps, IHomePageS
cloudPickerOpen: false,
};
private filePicker: React.RefObject<FilePicker> = React.createRef();
private newProjectRef = React.createRef<HTMLAnchorElement>();
private deleteConfirmRef = React.createRef<Confirm>();
private cloudFilePickerRef = React.createRef<CloudFilePicker>();
private importConfirmRef: React.RefObject<Confirm> = React.createRef();
public async componentDidMount() {
this.props.appTitleActions.setTitle("Welcome");
@ -92,6 +95,18 @@ export default class HomePage extends React.Component<IHomePageProps, IHomePageS
<div>{strings.homePage.newProject}</div>
</a>
</li>
{isElectron() &&
<li>
<a href="#" className="p-5 file-upload"
onClick={() => this.filePicker.current.upload()} >
<FontIcon iconName="System" className="icon-9x" />
<h6>{strings.homePage.openLocalProject.title}</h6>
</a>
<FilePicker ref={this.filePicker}
onChange={this.onProjectFileUpload}
onError={this.onProjectFileUploadError} />
</li>
}
<li>
{/*Open Cloud Project*/}
{/* eslint-disable-next-line */}
@ -185,4 +200,30 @@ export default class HomePage extends React.Component<IHomePageProps, IHomePageS
private deleteProject = async (project: IProject) => {
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;
}
}
}

Просмотреть файл

@ -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<IPredictPageProps, IPre
<div className="container-space-between">
<Dropdown
className="sourceDropdown"
defaultSelectedKey={this.state.sourceOption}
selectedKey={this.state.sourceOption}
options={sourceOptions}
disabled={this.state.isPredicting || this.state.isFetching}

Просмотреть файл

@ -19,7 +19,6 @@ import "./projectSettingsPage.scss";
import { ProjectSettingAction } from "./projectSettingAction";
import ProjectService from "../../../../services/projectService";
import { getStorageItem, setStorageItem, removeStorageItem } from "../../../../redux/middleware/localStorage";
import { SkipButton } from "../../shell/skipButton";
/**
* Properties for Project Settings Page

Просмотреть файл

@ -74,4 +74,8 @@
&-text {
padding-left:4px;
}
}
}
.label-folder-url-input {
margin-bottom: 5px;
}

Просмотреть файл

@ -24,7 +24,7 @@ import Alert from "../../common/alert/alert";
import url from "url";
import PreventLeaving from "../../common/preventLeaving/preventLeaving";
import ServiceHelper from "../../../../services/serviceHelper";
import { getPrimaryGreenTheme } from "../../../../common/themes";
import { getPrimaryGreenTheme, getGreenWithWhiteBackgroundTheme } from "../../../../common/themes";
export interface ITrainPageProps extends RouteComponentProps, React.Props<TrainPage> {
connections: IConnection[];
@ -37,6 +37,7 @@ export interface ITrainPageProps extends RouteComponentProps, React.Props<TrainP
}
export interface ITrainPageState {
inputedLabelFolderURL: string;
trainMessage: string;
isTraining: boolean;
currTrainRecord: ITrainRecordProps;
@ -79,6 +80,7 @@ export default class TrainPage extends React.Component<ITrainPageProps, ITrainPa
super(props);
this.state = {
inputedLabelFolderURL: "",
trainMessage: strings.train.notTrainedYet,
isTraining: false,
currTrainRecord: null,
@ -105,6 +107,10 @@ export default class TrainPage extends React.Component<ITrainPageProps, ITrainPa
public render() {
const currTrainRecord = this.state.currTrainRecord;
const localFileSystemProvider: boolean = this.props.project && this.props.project.sourceConnection &&
this.props.project.sourceConnection.providerType === "localFileSystemProxy";
const trainDisabled: boolean = localFileSystemProvider && (this.state.inputedLabelFolderURL.length === 0 ||
this.state.inputedLabelFolderURL === strings.train.defaultLabelFolderURL);
return (
<div className="train-page skipToMainContent" id="pageTrain">
@ -132,24 +138,42 @@ export default class TrainPage extends React.Component<ITrainPageProps, ITrainPa
<div className="condensed-list-body">
<div className="m-3">
<h4 className="text-shadow-none"> Train a new model </h4>
{!this.state.isTraining ? (
{!this.state.isTraining && localFileSystemProvider &&
<div>
<span>
Model Name
{strings.train.labelFolderTitle}
</span>
<TextField
placeholder={strings.train.addName}
autoComplete="off"
onChange={this.onTextChanged}
>
</TextField>
className="label-folder-url-input"
theme={getGreenWithWhiteBackgroundTheme()}
onFocus={this.removeDefaultInputedLabelFolderURL}
onChange={this.setInputedLabelFolderURL}
placeholder={strings.train.defaultLabelFolderURL}
value={this.state.inputedLabelFolderURL}
/>
</div>
}
<span>
{strings.train.modelNameTitle}
</span>
<TextField
theme={getGreenWithWhiteBackgroundTheme()}
placeholder={strings.train.addName}
autoComplete="off"
onChange={this.onTextChanged}
disabled={this.state.isTraining}
>
</TextField>
{!this.state.isTraining ? (
<div className="container-items-end">
<PrimaryButton
style={{"margin": "10px 0px"}}
style={{"margin": "15px 0px"}}
id="train_trainButton"
theme={getPrimaryGreenTheme()}
autoFocus={true}
className="flex-center"
onClick={this.handleTrainClick}>
onClick={this.handleTrainClick}
disabled={trainDisabled}>
<FontIcon iconName="MachineLearning" />
<h6 className="d-inline text-shadow-none ml-2 mb-0">
{strings.train.title} </h6>
@ -193,6 +217,16 @@ export default class TrainPage extends React.Component<ITrainPageProps, ITrainPa
);
}
private removeDefaultInputedLabelFolderURL = () => {
if (this.state.inputedLabelFolderURL === strings.train.defaultLabelFolderURL) {
this.setState({inputedLabelFolderURL: ""});
}
}
private setInputedLabelFolderURL = (event) => {
this.setState({inputedLabelFolderURL: event.target.value});
}
private onTextChanged = (ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, text: string) => {
this.modelName = text;
}
@ -247,12 +281,20 @@ export default class TrainPage extends React.Component<ITrainPageProps, ITrainPa
constants.apiModelsPath,
);
const provider = this.props.project.sourceConnection.providerOptions as any;
const trainSourceURL = provider.sas;
let trainSourceURL;
let trainPrefix;
if (this.props.project.sourceConnection.providerType === "localFileSystemProxy") {
trainSourceURL = this.state.inputedLabelFolderURL;
trainPrefix = ""
} else {
trainSourceURL = provider.sas;
trainPrefix = this.props.project.folderPath ? this.props.project.folderPath : "";
}
const payload = {
source: trainSourceURL,
sourceFilter: {
prefix: this.props.project.folderPath ? this.props.project.folderPath : "",
prefix: trainPrefix,
includeSubFolders: false,
},
useLabelFile: true,

Просмотреть файл

@ -19,7 +19,7 @@ export default class TrainTable
{!this.props.accuracies && <table className="accuracytable">
<tbody>
<tr>
<th> Train Message </th>
<th> Train message </th>
<td> {this.props.trainMessage} </td>
</tr>
</tbody>

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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<TitleBar> {
@ -14,9 +15,11 @@ export interface ITitleBarProps extends React.Props<TitleBar> {
}
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<ITitleBarProps, ITitleBarState> {
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<IButtonStyles> = {
root: {
padding: '0 5px',
alignSelf: 'stretch',
}
};
const iconStyles: Partial<IIconProps> = {
style: {display: "none"}
};
return (
<CommandBarButton
menuProps={item.subMenuProps}
styles={buttonStyles}
text={item.name}
menuIconProps={iconStyles}
/>
);
};
const onRenderOverflowButton = (overflowItems: any[] | undefined): JSX.Element => {
return (
<CommandBarButton
ariaLabel="More items"
role="menuitem"
menuProps={{ items: overflowItems! }}
/>
);
};
return (
<div className="title-bar bg-lighter-3">
{(this.state.platform === PlatformType.Web) &&
<div className="title-bar-icon">
{typeof (this.props.icon) === "string" && <FontIcon iconName={this.props.icon} />}
{typeof (this.props.icon) !== "string" && this.props.icon}
</div>
<div className="title-bar-icon">
{typeof (this.props.icon) === "string" && <FontIcon iconName={this.props.icon} />}
{typeof (this.props.icon) !== "string" && this.props.icon}
</div>
{this.isElectron &&
<Customizer {...titleBarTheme}>
<OverflowSet
role="menubar"
items={this.addDefaultMenuItems(this.state.menu)}
onRenderOverflowButton={onRenderOverflowButton}
onRenderItem={onRenderItem}
/>
</Customizer>
}
<div className="title-bar-main">{this.props.title || "Welcome"}</div>
<div className="title-bar-controls">
{this.props.children}
{this.isElectron &&
[
<Separator vertical key="seperator" className="mr-2 ml-2"/>,
<div key="minimizeDiv">
<FontIcon className="end-icons" iconName="ChromeMinimize" onClick={this.minimizeWindow}/>
</div>,
<div key="resizeDiv">
{this.state.maximized
? <FontIcon className="end-icons" iconName="ChromeRestore" onClick={this.unmaximizeWindow}/>
: <FontIcon className="end-icons" iconName="SquareShape" onClick={this.maximizeWindow}/>
}
</div>,
<div key="closeDiv">
<FontIcon className="app-close-icon" iconName="Cancel" onClick={this.closeWindow}/>
</div>
]
}
</div>
</div>
);
}
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();
}
}

Просмотреть файл

@ -65,6 +65,10 @@ export function registerIcons() {
More: "\uE712",
ReceiptProcessing: "\uE496",
KeyPhraseExtraction: "\uE395",
ChromeRestore: "\uE923",
ChromeMinimize: "\uE921",
System: "\uE770",
SquareShape: "\uF1A6",
},
});
}

Просмотреть файл

@ -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,

Просмотреть файл

@ -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<IAsset> {
public static async createAssetFromFilePath(filePath: string, fileName?: string, nodejsMode?: boolean): Promise<IAsset> {
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)) {

Просмотреть файл

@ -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,
);