Refactor towards modular structure

This commit is contained in:
Borys_Levytskyi
2021-01-14 20:48:19 +02:00
parent f671b32b63
commit 50d1606105
31 changed files with 221 additions and 154 deletions

91
src/shell/AppState.ts Normal file
View File

@@ -0,0 +1,91 @@
import log from 'loglevel';
const APP_VERSION = 5;
export type PersistedAppData = {
emphasizeBytes: boolean;
uiTheme: string;
version: number;
debugMode: boolean | null;
}
export type CommandResultView = {
key: number,
input: string,
view: JSX.Element
};
export type AppStateChangeHandler = (state: AppState) => void;
export default class AppState {
version: number = APP_VERSION;
emphasizeBytes: boolean;
debugMode: boolean = false;
uiTheme: string;
changeHandlers: AppStateChangeHandler[];
commandResults: CommandResultView[];
persistedVersion: number;
wasOldVersion: boolean;
env: string;
constructor(persistData : PersistedAppData, env: string) {
this.commandResults = [];
this.changeHandlers = [];
this.uiTheme = persistData.uiTheme || 'midnight';
this.env = env;
this.emphasizeBytes = persistData.emphasizeBytes || true;
this.persistedVersion = persistData.version || 0.1;
this.wasOldVersion = persistData.version != null && this.version > this.persistedVersion;
this.debugMode = env !== 'prod' || persistData.debugMode === true;
}
addCommandResult(input : string, view : JSX.Element) {
const key = generateKey();
this.commandResults.unshift({key, input, view});
log.debug(`command result added: ${input}`);
this.triggerChanged();
}
clearCommandResults() {
this.commandResults = [];
this.triggerChanged();
}
toggleEmphasizeBytes() {
this.emphasizeBytes = !this.emphasizeBytes;
this.triggerChanged();
}
onChange(handler : AppStateChangeHandler) {
this.changeHandlers.push(handler);
}
triggerChanged() {
this.changeHandlers.forEach(h => h(this));
}
setUiTheme(theme: string) {
this.uiTheme = theme;
this.triggerChanged();
}
toggleDebugMode() {
this.debugMode = !this.debugMode;
this.triggerChanged();
}
getPersistData() : PersistedAppData {
return {
emphasizeBytes: this.emphasizeBytes,
uiTheme: this.uiTheme,
version: this.version,
debugMode: this.debugMode
}
}
};
function generateKey() : number {
return Math.ceil(Math.random()*10000000) ^ Date.now(); // Because why the hell not...
}

View File

@@ -0,0 +1,28 @@
import AppState, { PersistedAppData } from "./AppState";
const storeKey = 'AppState';
export default {
getPersistedData() : PersistedAppData {
var json = window.localStorage.getItem(storeKey);
if(!json) {
return {} as PersistedAppData;
}
try {
return JSON.parse(json) as PersistedAppData;
}
catch(ex) {
console.error('Failed to parse AppState json. Json Value: \n' + json, ex);
return {} as PersistedAppData;;
}
},
watch (appState: AppState) {
appState.onChange(() => this.persistData(appState));
},
persistData(appState: AppState) {
localStorage.setItem(storeKey, JSON.stringify(appState.getPersistData()));
}
}

48
src/shell/cmd.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { CmdShell, ICommandHandler } from "./cmd";
describe("CmdShell", () => {
it("simple command", () => {
var handler = {
test1() { },
test2() { }
};
spyOn(handler, "test1");
spyOn(handler, "test2");
var sut = new CmdShell();
sut.command("test1", handler.test1);
sut.execute("test1");
expect(handler.test1).toHaveBeenCalled();
expect(handler.test2).not.toHaveBeenCalled();
});
it("unknown command", () => {
var sut = new CmdShell();
sut.execute("test1");
});
it("object handler", () => {
var handler = {
canHandle: function(input: string) : boolean { return input === "test2"; },
handle: function (input: string) { }
};
spyOn(handler, "handle");
var sut = new CmdShell();
sut.command(handler);
sut.execute("test1");
expect(handler.handle).not.toHaveBeenCalled();
sut.execute("test2");
expect(handler.handle).toHaveBeenCalled();
});
})

107
src/shell/cmd.ts Normal file
View File

@@ -0,0 +1,107 @@
import is from '../core/is';
import log from 'loglevel';
export type CommandInput = {
input: string;
}
type HandleFunction = (input: CommandInput) => void;
type InputErrorHandler = (input:string, error: Error) => void;
export interface ICommandHandler {
canHandle (input:string) : boolean;
handle: HandleFunction;
}
export class CmdShell {
debugMode: boolean;
handlers: ICommandHandler[];
errorHandler: InputErrorHandler | null;
constructor() {
this.handlers = [];
this.debugMode = false;
this.errorHandler = null;
};
execute (rawInput: string) {
log.debug(`Executing command: ${rawInput}`)
var input = rawInput.trim().toLowerCase();
var handler = this.findHandler(input);
if(handler != null) {
if(this.debugMode) {
this.invokeHandler(input, handler);
} else {
try {
this.invokeHandler(input, handler);
} catch (e) {
this.handleError(input, e);
}
}
}
else {
log.debug(`Handled is not found for command: ${rawInput}`)
this.handleError(input, new Error("Unsupported expression: " + input.trim()));
}
};
onError(h: InputErrorHandler) {
this.errorHandler = h;
}
command (cmd : string | object, handler? : any) {
var h = this.createHandler(cmd, handler);
if(h == null){
console.warn('unexpected set of arguments: ', JSON.stringify(arguments));
return;
}
if(!is.aFunction(h.canHandle)) {
console.warn('handler is missing "canHandle" function. registration denied.');
return;
}
if(!is.aFunction(h.handle)) {
console.warn('handler is missing "handle" function. registration denied.');
return;
}
this.handlers.push(h);
};
createHandler (cmd : string | object, handler : HandleFunction) : ICommandHandler | null {
if(is.plainObject(cmd)) {
return cmd as ICommandHandler;
}
if(is.string(cmd)) {
return { canHandle: function (input) { return input === cmd; }, handle: handler };
}
return null;
}
findHandler (input: string) : ICommandHandler | null {
return this.handlers.filter(h => h.canHandle(input))[0];
};
invokeHandler (input : string, handler : ICommandHandler) {
var cmdResult = handler.handle({ input: input});
if(cmdResult != null) {
log.debug(cmdResult);
}
};
handleError (input: string, err: Error) {
if(this.debugMode)
console.error(input, err);
if(this.errorHandler != null)
this.errorHandler(input, err);
}
}
export default new CmdShell();

View File

@@ -0,0 +1,12 @@
import React from 'react'
function AboutResultView() {
return <div className="aboutTpl" data-result-type="help">
<p> Created by <a href="http://boryslevytskyi.github.io/">Borys Levytskyi</a>. Please give it a like if BitwiseCmd has helped you in your work.</p>
<p>If you have an idea, suggestion or you've spotted a bug here, please send it to <a href="mailto:&#098;&#105;&#116;&#119;&#105;&#115;&#101;&#099;&#109;&#100;&#064;&#103;&#109;&#097;&#105;&#108;&#046;&#099;&#111;&#109;?subject=Feedback">&#098;&#105;&#116;&#119;&#105;&#115;&#101;&#099;&#109;&#100;&#064;&#103;&#109;&#097;&#105;&#108;&#046;&#099;&#111;&#109;</a> or tweet on <a href="http://twitter.com/BitwiseCmd">@BitwiseCmd</a>. Your feedback is greatly appreciated.</p>
<p><a href="https://github.com/BorisLevitskiy/BitwiseCmd">Project on <strong>GitHub</strong></a></p>
</div>;
};
export default AboutResultView;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import InputBox from './InputBox';
import DisplayResultView from './DisplayResultView';
import AppState, { CommandResultView } from '../AppState';
import cmd from '../cmd';
import log from 'loglevel';
import Indicators from './Indicators';
import hash from '../../core/hash';
type AppRootProps = {
appState: AppState,
};
type AppRootState = {
uiTheme: string,
emphasizeBytes: boolean,
commandResults: CommandResultView[]
}
export default class AppRoot extends React.Component<AppRootProps, AppRootState> {
componentWillMount() {
this.refresh();
this.props.appState.onChange(() => this.refresh());
}
refresh() {
this.setState(this.props.appState);
}
getIndicator(value : boolean) {
return value ? 'on' : 'off';
}
getResultViews() : JSX.Element[] {
var results = this.state.commandResults.map((r, i) =>
<DisplayResultView key={r.key} input={r.input} inputHash={hash.encodeHash(r.input)} appState={this.props.appState}>
{r.view}
</DisplayResultView>);
return results;
}
toggleEmphasizeBytes() {
this.props.appState.toggleEmphasizeBytes();
}
render() {
return <div className={`app-root ${this.state.uiTheme}`}>
<Indicators appState={this.props.appState} />
<div className="header">
<h1>Bitwise<span className="header-cmd">Cmd</span>
</h1>
<ul className="top-links">
<li>
<a href="https://github.com/BorisLevitskiy/BitwiseCmd"><i className="icon github">&nbsp;</i><span className="link-text">Project on GitHub</span></a>
</li>
<li>
<a href="https://twitter.com/BitwiseCmd"><i className="icon twitter">&nbsp;</i><span className="link-text">Twitter</span></a>
</li>
<li>
<a href="mailto:&#098;&#105;&#116;&#119;&#105;&#115;&#101;&#099;&#109;&#100;&#064;&#103;&#109;&#097;&#105;&#108;&#046;&#099;&#111;&#109;?subject=Feedback"><i className="icon feedback">&nbsp;</i><span className="link-text">Send Feedback</span></a>
</li>
</ul>
</div>
<div className="expressionInput-container">
<InputBox onCommandEntered={(input) => cmd.execute(input)} />
<span className="configPnl">
<span id="emphasizeBytes" data-cmd="em" className={"indicator " + this.getIndicator(this.state.emphasizeBytes)} title="Toggle Emphasize Bytes" onClick={() => this.toggleEmphasizeBytes()}>[em]</span>
</span>
</div>
<div id="output">
{this.getResultViews()}
</div>
</div>;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import AppState from '../AppState';
type DisplayResultProps = {
appState: AppState,
inputHash: string,
input: string,
key: number
}
export default class DisplayResultView extends React.Component<DisplayResultProps> {
render() {
return <div className="result">
<div className="input mono"><span className="cur">&gt;</span>{this.props.input}<a className="hashLink" title="Link for this expression" href={window.location.pathname + '#' + this.props.inputHash}>#</a></div>
<div className="content">
{this.props.children}
</div>
</div>;
}
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
function ErrorResultView(props : {errorMessage:string}) {
return <div className="result">
<div className="error">{props.errorMessage}</div>
</div>;
}
export default ErrorResultView;

View File

@@ -0,0 +1,4 @@
.help .section {margin-bottom:10px;}
.help .panel-container {overflow: hidden;}
.help .left-panel {float:left; margin-right: 20px;}
.help .right-panel {float:left;}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import CommandLink from '../../core/components/CommandLink';
import './HelpResultView.css';
function HelpResultView() {
return <div className="help helpResultTpl">
<div className="panel-container">
<div className="left-panel">
<div className="section">
<strong className="section-title soft">Bitiwse Calculation Commands</strong>
<ul>
<li><code><CommandLink text="23 | 34" /></code> type bitwise expression to see result in binary (only positive integers are supported now)</li>
<li><code><CommandLink text="23 34" /></code> type one or more numbers to see their binary representations</li>
</ul>
</div>
<div className="section">
<strong className="section-title soft">IP Address Commands</strong>
<ul>
<li><code><CommandLink text="127.0.0.1" /></code> enter single or multiple ip addresses (separated by space) to see their binary represenation</li>
<li><code><CommandLink text="192.168.0.1/8" /></code> subnet mask notiations are support as well</li>
</ul>
</div>
<div className="section">
<strong className="section-title soft">Color Theme Commands</strong>
<ul>
<li><code><CommandLink text="light" /></code> set Light theme</li>
<li><code><CommandLink text="dark" /></code> set Dark theme</li>
<li><code><CommandLink text="midnight" /></code> set Midnight theme</li>
</ul>
</div>
<div className="section">
<strong className="section-title soft">Other Commands</strong>
<ul>
<li><code><CommandLink text="clear" /></code> clear output pane</li>
<li><code><CommandLink text="help" /></code> display this help</li>
<li><code><CommandLink text="whatsnew" /></code> display changelog</li>
<li><code><CommandLink text="em" /></code> turn On/Off Emphasize Bytes</li>
<li><code><CommandLink text="about" /></code> about the app</li>
<li><code><CommandLink text="guid" /></code> generate <a href="https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_.28random.29">v4</a> GUID</li>
</ul>
</div>
</div>
<div className="right-panel">
<div className="section">
<strong className="section-title soft">Supported Bitwise Operations</strong><br/>
<small>
<a href="https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Bitwise_Operators">
as implemented in JavaScript engine of your browser
</a>
</small>
<ul>
<li><code>&amp;</code> bitwise AND</li>
<li><code>|</code> bitwise inclusive OR</li>
<li><code>^</code> bitwise exclusive XOR</li>
<li><code>~</code> bitwise NOT</li>
<li><code>&lt;&lt;</code> left shift</li>
<li><code>&gt;&gt;</code> sign propagating right shift</li>
<li><code>&gt;&gt;&gt;</code> zero-fill right shift</li>
</ul>
</div>
<div className="section">
<strong className="section-title soft">Tip</strong>
<p>
You can click on bits to flip them in number inputs (e.g. <CommandLink text="2 4" />) or IP addresses (e.g. <CommandLink text="192.168.0.0/8"/>)
</p>
</div>
</div>
</div>
</div>;
}
export default HelpResultView;

View File

@@ -0,0 +1,33 @@
import AppState from "../AppState";
import React from "react";
type IndicatorsProps = {
appState: AppState
};
function Indicators(props: IndicatorsProps) {
const list = [];
const state = props.appState;
if(props.appState.env != 'prod') {
list.push(state.env);
}
if(props.appState.debugMode) {
list.push("debug");
}
if(localStorage.getItem('TrackAnalytics') === 'false') {
list.push("notrack");
}
if(list.length == 0)
return null;
return <div>
{list.map(i => <span>{i}&nbsp;</span>)}
</div>
}
export default Indicators;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import log from 'loglevel';
export interface IInputBoxProps
{
onCommandEntered: (input :string) => void;
}
export default class InputBox extends React.Component<IInputBoxProps> {
history: string[];
historyIndex: number;
nameInput: HTMLInputElement | null;
constructor(props: IInputBoxProps) {
super(props);
this.nameInput = null;
this.history = [];
this.historyIndex = -1;
}
componentDidMount(){
if(this.nameInput != null)
this.nameInput.focus();
}
render() {
return <input id="in" type="text"
ref={(input) => { this.nameInput = input; }}
onKeyUp={e => this.onKeyUp(e)}
onKeyDown={e => this.onKeyDown(e)}
className="expressionInput mono"
placeholder="type expression like '1>>2' or 'help' "/>;
}
onKeyUp(e: any) {
var input = e.target;
if (e.keyCode != 13 || input.value.trim().length == 0) {
return;
}
var commandInput = input.value;
this.history.unshift(commandInput);
this.historyIndex = -1;
input.value = '';
this.props.onCommandEntered(commandInput);
}
onKeyDown(args: any) {
if(args.keyCode == 38) {
var newIndex = this.historyIndex+1;
if (this.history.length > newIndex) { // up
args.target.value = this.history[newIndex];
this.historyIndex = newIndex;
}
args.preventDefault();
return;
}
if(args.keyCode == 40) {
if(this.historyIndex > 0) { // down
args.target.value = this.history[--this.historyIndex];
}
args.preventDefault();
}
}
}

View File

@@ -0,0 +1,7 @@
import React from 'react';
function TextResultView(props : { text: string }) {
return <p>{props.text}</p>;
}
export default TextResultView;

View File

@@ -0,0 +1,10 @@
import React from 'react';
function UnknownInputResultView(props : {input:string}) {
return <div className="result">
<div className="error">¯\_()_/¯ Sorry, i don&prime;t know what <strong>{props.input}</strong> is</div>
</div>;
}
export default UnknownInputResultView;

View File

@@ -0,0 +1,2 @@
.changelog .item { margin-top: 2em; }
.changelog .item-new .date { font-weight: bold; text-decoration: underline;}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import CommandLink from '../../core/components/CommandLink';
import './WhatsNewResultView.css';
function WhatsnewResultView() {
return <div className="changelog">
<h3>Changelog</h3>
<div className="item item-new">
<p><span className="soft date">Jun 14th, 2021</span> <br/>
Added support of ip addresses and subnet masks notatioans. Try them out:
</p>
<ul>
<li>Single IP address <CommandLink text="127.0.0.1" /></li>
<li>Multiple IP addresses and subnet mask notations <CommandLink text="127.0.0.1 192.168.0.0/24" /></li>
</ul>
</div>
<div className="item">
<p><span className="soft date">Jun 6th, 2017</span> <br/>
Added <code><CommandLink text="guid" /></code> command. Use it for generating v4 GUIDs </p>
</div>
<div className="item">
<p><span className="soft date">May 27th, 2017</span> <br/>
Added support of binary number notation (e.g. <code><CommandLink text="0b10101" /></code>). </p>
</div>
<div className="item">
<p><span className="soft">May 20th, 2017</span> <br/>
New <CommandLink text="Midnight" /> theme added. </p>
</div>
<div className="item">
<p><span className="soft">May 16th, 2017</span> <br/>
Complete rewrite using React. Old implementation is available at <a href="http://bitwisecmd.com/old">http://bitwisecmd.com/old</a>. Please let me know if you have problems with this release by <a href="https://github.com/BorysLevytskyi/BitwiseCmd/issues">creating issue</a> in Github Repo.</p>
</div>
</div>;
}
export default WhatsnewResultView;

8
src/shell/interfaces.ts Normal file
View File

@@ -0,0 +1,8 @@
import AppState from "./AppState";
import { CmdShell } from "./cmd";
export type Env = 'prod' | 'stage';
export type AppModule = {
setup: (appState: AppState, cmd: CmdShell) => void;
};

36
src/shell/module.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react';
import uuid from 'uuid';
import AppState from './AppState';
import { CmdShell, CommandInput } from './cmd';
import AboutResultView from './components/AboutResultView';
import ErrorResultView from './components/ErrorResultView';
import HelpResultView from './components/HelpResultView';
import TextResultView from './components/TextResultView';
import WhatsnewResultView from './components/WhatsNewResultView';
const shellModule = {
setup: function(appState: AppState, cmd: CmdShell) {
cmd.debugMode = appState.debugMode;
appState.onChange(() => cmd.debugMode = appState.debugMode);
cmd.command("help", (c: CommandInput) => appState.addCommandResult(c.input, <HelpResultView />));
cmd.command("clear", () => appState.clearCommandResults());
cmd.command("em", () => appState.toggleEmphasizeBytes());
cmd.command("dark", () => appState.setUiTheme('dark'));
cmd.command("light", () => appState.setUiTheme('light'));
cmd.command("midnight", () => appState.setUiTheme('midnight'));
cmd.command("about", (c: CommandInput) => appState.addCommandResult(c.input, <AboutResultView />));
cmd.command("whatsnew", (c: CommandInput) => appState.addCommandResult(c.input, <WhatsnewResultView />));
cmd.command("guid", (c: CommandInput) => appState.addCommandResult(c.input, <TextResultView text={uuid()} />));
cmd.command("-notrack", () => {});
cmd.command("-debug", (c: CommandInput) => {
appState.toggleDebugMode();
appState.addCommandResult(c.input, <TextResultView text={`Debug Mode: ${appState.debugMode}`}/>);
});
cmd.onError((input: string, err: Error) => appState.addCommandResult(input, <ErrorResultView errorMessage={err.toString()} />));
}
}
export default shellModule;

62
src/shell/startup.ts Normal file
View File

@@ -0,0 +1,62 @@
import log from 'loglevel';
import hash from '../core/hash';
import AppState from './AppState';
import { Env } from './interfaces';
import appStateStore from './appStateStore';
export type StartupAppData = {
appState: AppState,
startupCommands: string[]
}
function bootstrapAppData() : StartupAppData {
const env = window.location.host === "bitwisecmd.com" ? 'prod' : 'stage';
setupLogger(env);
const appState = createAppState(env);
const startupCommands = getStartupCommands(appState);
return {
appState,
startupCommands
}
}
function createAppState(env:string) {
var stateData = appStateStore.getPersistedData();
const appState = new AppState(stateData, env);
appStateStore.watch(appState);
log.debug("appState initialized", appState);
return appState;
}
function getStartupCommands(appState : AppState) : string[] {
var hashArgs = hash.getArgs(window.location.hash);
var startupCommands = ['help', '127.0.0.1 192.168.0.0/8', '1|2&6','4 0b1000000 0x80'];
if(appState.wasOldVersion) {
startupCommands = ["whatsnew"];
}
if(hashArgs.length > 0) {
startupCommands = hashArgs;
}
log.debug('Executing startup commands', startupCommands);
return startupCommands;
}
function setupLogger(env: Env) {
if(env != 'prod'){
log.setLevel("debug");
log.debug(`Log level is set to debug. Env: ${env}`)
} else {
log.setLevel("warn");
}
}
export default bootstrapAppData;