Repository: kaustubh-karkare/glados
Branch: master
Commit: b68436687d0a
Files: 199
Total size: 537.6 KB
Directory structure:
gitextract_nsqvf378/
├── .gitignore
├── .husky/
│ └── pre-commit
├── LICENSE
├── README.md
├── config/
│ ├── babel.config.js
│ ├── demo.glados.json
│ ├── eslint.config.js
│ ├── example.glados.json
│ ├── jest.config.js
│ └── webpack.config.js
├── package.json
└── src/
├── README.md
├── client/
│ ├── Application/
│ │ ├── Application.js
│ │ ├── BackupSection.js
│ │ ├── CreditsSection.js
│ │ ├── DetailsSection.css
│ │ ├── DetailsSection.js
│ │ ├── FavoritesSection.js
│ │ ├── IndexSection.css
│ │ ├── IndexSection.js
│ │ ├── TabSection.js
│ │ ├── URLState.js
│ │ └── index.js
│ ├── Bootstrap/
│ │ ├── InputGroup.css
│ │ ├── Modal.css
│ │ ├── Popover.css
│ │ └── index.js
│ ├── Common/
│ │ ├── AddLinkPlugin.js
│ │ ├── AsyncSelector.js
│ │ ├── BulletList/
│ │ │ ├── BulletList.css
│ │ │ ├── BulletList.js
│ │ │ ├── BulletListIcon.js
│ │ │ ├── BulletListItem.js
│ │ │ ├── BulletListLine.js
│ │ │ ├── BulletListPager.js
│ │ │ ├── BulletListTitle.js
│ │ │ └── index.js
│ │ ├── ConfirmModal.js
│ │ ├── Coordinator.js
│ │ ├── DataLoader.js
│ │ ├── DateContext.js
│ │ ├── DatePicker.js
│ │ ├── DateRangePicker.js
│ │ ├── Dropdown.css
│ │ ├── Dropdown.js
│ │ ├── EditorModal.js
│ │ ├── EnumSelectorSection.js
│ │ ├── ErrorModal.js
│ │ ├── Highlightable.css
│ │ ├── Highlightable.js
│ │ ├── Icon.css
│ │ ├── Icon.js
│ │ ├── InfoModal.js
│ │ ├── InputLine.css
│ │ ├── InputLine.js
│ │ ├── LeftRight.js
│ │ ├── Link.js
│ │ ├── ModalStack.js
│ │ ├── Plugins.js
│ │ ├── PopoverElement.js
│ │ ├── ScrollableSection.css
│ │ ├── ScrollableSection.js
│ │ ├── Selector.js
│ │ ├── SettingsContext.js
│ │ ├── SidebarSection.css
│ │ ├── SidebarSection.js
│ │ ├── SortableList.css
│ │ ├── SortableList.js
│ │ ├── StandardIcons.js
│ │ ├── TextEditor.css
│ │ ├── TextEditor.js
│ │ ├── TextInput.js
│ │ ├── TooltipElement.js
│ │ ├── TypeaheadInput.js
│ │ ├── TypeaheadOptions.js
│ │ ├── TypeaheadSelector.css
│ │ ├── TypeaheadSelector.js
│ │ ├── URLManager.js
│ │ ├── Utils.js
│ │ └── index.js
│ ├── Graphs/
│ │ ├── GraphLineChart.js
│ │ ├── GraphSection.css
│ │ ├── GraphSection.js
│ │ ├── GraphSectionData.js
│ │ ├── GraphSectionOptions.js
│ │ ├── GraphTooltip.js
│ │ └── index.js
│ ├── LogEvent/
│ │ ├── LogEventAdder.js
│ │ ├── LogEventDetailsHeader.js
│ │ ├── LogEventEditor.js
│ │ ├── LogEventList.js
│ │ ├── LogEventOptions.js
│ │ ├── LogEventSearch.js
│ │ └── index.js
│ ├── LogKey/
│ │ ├── LogKeyEditor.js
│ │ ├── LogKeyListEditor.js
│ │ ├── LogValueEditor.js
│ │ ├── LogValueListEditor.js
│ │ └── index.js
│ ├── LogStructure/
│ │ ├── LogStructureDetailsHeader.js
│ │ ├── LogStructureEditor.js
│ │ ├── LogStructureFrequencyEditor.js
│ │ ├── LogStructureGroupEditor.js
│ │ ├── LogStructureGroupList.js
│ │ ├── LogStructureList.js
│ │ ├── LogStructureOptions.js
│ │ ├── LogStructureSearch.js
│ │ └── index.js
│ ├── LogTopic/
│ │ ├── LogTopicDetailsHeader.js
│ │ ├── LogTopicEditor.js
│ │ ├── LogTopicList.js
│ │ ├── LogTopicOptions.js
│ │ ├── LogTopicSearch.js
│ │ └── index.js
│ ├── Reminders/
│ │ ├── ReminderItem.js
│ │ ├── ReminderList.js
│ │ ├── ReminderSidebar.js
│ │ └── index.js
│ ├── Settings/
│ │ ├── SettingsEditor.js
│ │ ├── SettingsModal.js
│ │ ├── SettingsSection.js
│ │ └── index.js
│ ├── __tests__/
│ │ └── Colors.test.js
│ ├── index.css
│ ├── index.html
│ ├── index.js
│ └── prop-types.js
├── common/
│ ├── AsyncUtils.js
│ ├── DateUtils.js
│ ├── RichTextUtils.js
│ ├── SocketRPC.js
│ ├── __tests__/
│ │ └── RichTextUtils.test.js
│ ├── data_types/
│ │ ├── LogEvent.js
│ │ ├── LogKey.js
│ │ ├── LogStructure.js
│ │ ├── LogStructureFrequency.js
│ │ ├── LogStructureGroup.js
│ │ ├── LogTopic.js
│ │ ├── __tests__/
│ │ │ └── LogStructureFrequency.test.js
│ │ ├── api.js
│ │ ├── base.js
│ │ ├── enum.js
│ │ ├── index.js
│ │ ├── utils.js
│ │ └── validation.js
│ └── polyfill.js
├── demo/
│ ├── components/
│ │ ├── Application.js
│ │ ├── BaseWrapper.js
│ │ ├── BulletList.js
│ │ ├── DetailsSection.js
│ │ ├── IndexSection.js
│ │ ├── Inputs.js
│ │ ├── ModalDialog.js
│ │ ├── ReminderItem.js
│ │ ├── SidebarSection.js
│ │ └── index.js
│ ├── index.js
│ ├── lessons/
│ │ ├── 001-events.js
│ │ ├── 002-topics.js
│ │ ├── 003-structures.js
│ │ ├── 004-reminders.js
│ │ └── 005-graphs.js
│ ├── lessons.js
│ └── process.js
├── plugins/
│ ├── README.md
│ └── kaustubh/
│ ├── custom.actions.js
│ ├── long_term_goals/
│ │ ├── LongTermGoalGraph.js
│ │ ├── LongTermGoalsSettings.js
│ │ └── client.js
│ ├── more_event_lists/
│ │ ├── MoreEventListsSettings.js
│ │ └── client.js
│ ├── time_sections/
│ │ ├── TimeSection.js
│ │ ├── TimeSectionSettings.js
│ │ └── client.js
│ ├── topic_reminder_sections/
│ │ ├── TopicRemindersSection.js
│ │ ├── TopicRemindersSectionSettings.js
│ │ ├── actions.js
│ │ └── client.js
│ └── topic_sections/
│ ├── TopicSection.js
│ ├── TopicSectionSettings.js
│ └── client.js
└── server/
├── __tests__/
│ └── Config.test.js
├── actions/
│ ├── __tests__/
│ │ ├── Backup.test.js
│ │ ├── Database.test.js
│ │ ├── LogEvent.test.js
│ │ ├── LogStructure.test.js
│ │ ├── LogTopic.test.js
│ │ ├── Reminders.test.js
│ │ └── TestUtils.js
│ ├── backup.js
│ ├── data_types.js
│ ├── database.js
│ ├── reminders.js
│ ├── settings.js
│ └── suggestions.js
├── actions.js
├── database.js
├── index.js
└── models.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
.DS_Store
/config.json
data
dist
node_modules
================================================
FILE: .husky/pre-commit
================================================
yarn run lint && yarn run test
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) Kaustubh Karkare
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: README.md
================================================
## Generic Life Activity Data Organization System (GLADOS)
https://user-images.githubusercontent.com/1102450/147822871-ca69bedc-ed20-45aa-88de-e02c66bb92f0.mp4
If the above video does not work, you can also watch it here: https://www.youtube.com/watch?v=xd3JJi8zSk4
### Rationale
* Over the past decade, I have tried using various todo-list apps, but none of them really worked out for me: the motivation never seemed to last beyond a few days. But when I encountered the idea of an anti-todo/done-list in some blog post (maybe [this one](https://www.fastcompany.com/3034785/why-an-anti-to-do-list-might-be-the-secret-to-productivity)?) I was fascinated enough to give it a try, and it actually proved valuable!
* Additionally, back then, it was fairly easy to measure my productivity at work in terms of the amount of code generated, so I would only make notes about important/memorable events. But in the last couple of years, as my job has evolved, that metric is no longer a useful proxy for my effectiveness. This transition strongly correlated with an increasing reliance on these done-lists to feel satisfied at the end of the day.
* I was using Evernote to manage these notes/lists for a few years, and once I had a good understanding of how I like to use the tool, I found myself wishing for the ability to add more structure to the data being generated, so that I can do more interesting things with it, like building custom visualizations and graphs.
* Looking at the options available online, I did not find anything that did everything I was hoping for, and more importantly, it hurt my pride as a Software Engineer to pay for something I knew I can build. I also did not like the idea of relying on an external product that might go out of business at some point in the future: having complete control over my data was a major design goal. As a result, this tool might not be suited to a larger audience, but it definitely works for me! :)
### Warning!
* Since it is primarily designed for an audience of one, this tool is continuously being modified as I find new ways to improve my workflow. It most definitely is NOT perfect, containing edge cases that I have not yet encountered or fixed. But for what it is worth, I have been using it almost daily since July 2020 without any significant issues.
### Installation
```
git clone https://github.com/kaustubh-karkare/glados
cd glados
cp config/example.glados.json config.json
mkdir data
yarn install
yarn run build
yarn run database-reset
```
* The default `config.json` file specifies the `data` subdirectory as the location of the SQLite database and the backups.
* I personally made `data` a symlink to another directory that synced to my [Dropbox](https://www.dropbox.com/).
* You can theoretically change the config to use whatever storage you want, as long as it is compatible with [Sequelize](https://sequelize.org/).
* And once you're ready,
```
yarn run server
```
### Demo
* In order to show off what I have built, I used to manually create videos by recording my screen as I performed a predetermined set of actions. This was obviously very fragile and involved multiple attempts until I finally made no mistakes.
* I got annoyed at this process, and so automated the whole thing using [Selenium Webdriver](https://www.selenium.dev/selenium/docs/api/javascript/index.html) to perform those actions and [ffmpeg](https://www.ffmpeg.org/) to record that part of the screen.
```
yarn run demo
```
* You can see the result at the top of this README file.
* An auxiliary benefit here is that this functionality can be used as an E2E test for the client code.
### Backups
```
yarn run backup-save # Can also be done via the right-sidebar in the UI.
yarn run backup-load # This involves a database reset, so be careful!
```
* Backup files are created by loading the entire database into memory and then writing that as a JSON file (less than 10MB for data generated over a full year, uncompressed).
* This makes it very easy to apply transformations on the entire database when needed. Eg - the database schema has been updated, or if you just want to change how you organize things.
* These are also useful if data needs to be moved from one storage to another.
### Community
* https://www.reddit.com/r/glados_app/
* Hacker News: [2022-01-01](https://news.ycombinator.com/item?id=29756591).
================================================
FILE: config/babel.config.js
================================================
module.exports = {
plugins: [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-transform-runtime',
],
presets: [
'@babel/preset-env',
'@babel/preset-react',
],
compact: false,
sourceType: 'unambiguous',
};
================================================
FILE: config/demo.glados.json
================================================
{
"lock_name": "glados-demo",
"database": {
"dialect": "sqlite",
"storage": "dist/demo/test.sqlite",
"logging": false
},
"backup": {
"location": "dist/demo",
"save_interval_ms": null
},
"server": {
"host": "localhost",
"port": 8081
}
}
================================================
FILE: config/eslint.config.js
================================================
module.exports = {
env: {
browser: true,
es6: true,
jest: true,
node: true,
},
extends: [
'plugin:react/recommended',
'airbnb',
],
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 11,
sourceType: 'module',
},
plugins: [
'react',
'simple-import-sort',
],
settings: {
react: {
version: '16.13.1',
},
},
rules: {
indent: ['error', 4],
'import/no-cycle': [0],
// Unable to resolve path to module 'react'
'import/no-unresolved': [0],
// Need to add role attribute for accessibility on HTML elements.
'jsx-a11y/no-static-element-interactions': [0],
'jsx-a11y/click-events-have-key-events': [0],
'jsx-a11y/mouse-events-have-key-events': [0],
'jsx-a11y/anchor-is-valid': [0],
'jsx-a11y/no-noninteractive-tabindex': [0],
'no-param-reassign': [0],
'no-underscore-dangle': [0, 'allowAfterThis'],
'no-unused-vars': ['error', { args: 'none', varsIgnorePattern: '^_' }],
'react/jsx-indent': ['error', 4],
'react/jsx-indent-props': ['error', 4],
'react/destructuring-assignment': [0],
'react/jsx-filename-extension': [0],
'react/jsx-props-no-spreading': [0],
'react/no-unused-class-component-methods': [0],
// Otherwise, every non-required propType would need defaultValue.
'react/require-default-props': [0],
'no-restricted-exports': [0],
'simple-import-sort/imports': 'error',
},
};
================================================
FILE: config/example.glados.json
================================================
{
"database": {
"dialect": "sqlite",
"storage": "data/test.sqlite",
"logging": false
},
"backup": {
"location": "data",
"save_interval_ms": null
},
"server": {
"host": "localhost",
"port": 8080
}
}
================================================
FILE: config/jest.config.js
================================================
const path = require('path');
module.exports = {
rootDir: '..',
roots: ['src'],
testRegex: 'test.js',
transform: {
'\\.js$': ['babel-jest', { configFile: path.join(__dirname, 'babel.config.js') }],
},
};
================================================
FILE: config/webpack.config.js
================================================
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const nodeExternals = require('webpack-node-externals');
const path = require('path');
function fromProjectRoot(relativePath) {
return path.resolve(__dirname, '..', relativePath);
}
function getJSModuleRule() {
return {
test: /\.(js|ts)$/,
use: [
{
loader: 'babel-loader',
options: {
configFile: path.join(__dirname, 'babel.config.js'),
},
},
],
exclude: /node_modules/,
};
}
function getStats() {
return {
assets: true, // Show generated bundles.
builtAt: true, // The one signal I actually want.
children: false,
entrypoints: false,
hash: false,
modules: false, // Show all the modules that are part of this package.
timings: false,
version: false,
};
}
function getClientSideBundle(entryPoint, outputFileName) {
return {
mode: 'development',
entry: fromProjectRoot(entryPoint),
output: {
path: fromProjectRoot('dist'),
filename: outputFileName,
},
devServer: {
hot: true,
},
resolve: {
extensions: ['.js', '.css'],
fallback: {
assert: require.resolve('assert'),
util: require.resolve('util'),
},
},
module: {
rules: [
getJSModuleRule(),
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
// The css-loader interprets @import and url()
// like import/require() and will resolve them.
'css-loader',
],
},
],
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
}),
new MiniCssExtractPlugin({
filename: 'index.css',
}),
new HtmlWebpackPlugin({
template: fromProjectRoot('src/client/index.html'),
favicon: 'src/client/index.ico',
}),
],
stats: getStats(),
};
}
function getServerSideBundle(entryPoint, outputFileName) {
return {
mode: 'development',
entry: fromProjectRoot(entryPoint),
output: {
path: fromProjectRoot('dist'),
filename: outputFileName,
},
devServer: {
hot: true,
},
resolve: {
extensions: ['.js'],
},
module: {
rules: [
getJSModuleRule(),
],
},
// https://medium.com/tomincode/hiding-critical-dependency-warnings-from-webpack-c76ccdb1f6c1
plugins: [
new webpack.ContextReplacementPlugin(
/src\/server/,
(data) => {
// The following error is expected in actions.js to support Jest.
// Critical dependency: require function is used in a way in
// which dependencies cannot be statically extracted.
data.dependencies.forEach((dependency) => {
delete dependency.critical;
});
return data;
},
),
],
stats: getStats(),
// https://www.npmjs.com/package/webpack-node-externals
target: 'node',
externals: [nodeExternals()],
};
}
module.exports = [
getClientSideBundle('src/client/index.js', 'client.js'),
getServerSideBundle('src/server/index.js', 'server.js'),
getServerSideBundle('src/demo/index.js', 'demo.js'),
];
================================================
FILE: package.json
================================================
{
"name": "glados",
"version": "1.0.0",
"description": "Generic Life Activity Data Organization System",
"private": true,
"scripts": {
"build": "webpack --config ./config/webpack.config.js",
"demo": "node ./dist/demo.js",
"server": "node ./dist/server.js",
"server-watch": "nodemon --watch ./dist/server.js --exec node ./dist/server.js",
"database-reset": "yarn run server -a database-reset",
"backup-save": "yarn run server -a backup-save",
"backup-load": "yarn run server -a backup-load",
"lint": "eslint -c ./config/eslint.config.js --ext .js,.jsx --fix src",
"test": "jest --config ./config/jest.config.js --no-watchman",
"todo": "grep -nir '// TODO' src",
"kill": "ps -A | grep \"bin/node ./dist\" | grep -v grep | awk '{ print \"kill -9\", $1 }' | zsh"
},
"repository": {
"type": "git",
"url": "git+https://github.com/kaustubh-karkare/glados.git"
},
"keywords": [],
"author": "Kaustubh Karkare",
"license": "MIT",
"bugs": {
"url": "https://github.com/kaustubh-karkare/glados/issues"
},
"homepage": "https://github.com/kaustubh-karkare/glados#readme",
"dependencies": {
"array-move": "^2.2.2",
"assert": "^2.0.0",
"bootstrap": "^4.5.0",
"classnames": "^2.2.6",
"date-fns": "^2.15.0",
"date-fns-timezone": "^0.1.4",
"deep-equal": "^2.0.3",
"deepcopy": "^2.1.0",
"draft-js": "^0.11.5",
"draft-js-markdown-shortcuts-plugin": "^0.6.1",
"draft-js-mention-plugin": "^3.1.5",
"draft-js-plugins-editor": "^3.0.0",
"express": "^5.0.0",
"markdown-draft-js": "^2.2.1",
"process": "^0.11.10",
"prop-types": "^15.7.2",
"query-string": "^6.13.1",
"react": "^16.13.1",
"react-bootstrap": "^1.0.1",
"react-bootstrap-typeahead": "^5.0.0-rc.1",
"react-date-range": "^1.0.3",
"react-datepicker": "^3.0.0",
"react-dom": "^16.13.1",
"react-icons": "^3.10.0",
"react-sortable-hoc": "^1.11.0",
"recharts": "^2.0.9",
"selenium-webdriver": "^4.20.0",
"sequelize": "^6.35.0",
"single-instance": "^0.0.1",
"socket.io": "^4.7.0",
"socket.io-client": "^4.7.0",
"sqlite3": "^5.1.7",
"timezone-support": "^2.0.2",
"toposort": "^2.0.2",
"util": "^0.12.5",
"yargs": "^15.4.1"
},
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/node": "^7.8.7",
"@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-transform-runtime": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"babel-loader": "^8.1.0",
"css-loader": "^6.8.0",
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^9.1.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.7.0",
"nodemon": "^3.1.0",
"style-loader": "^3.3.0",
"tmp": "^0.2.3",
"ts-loader": "^9.4.0",
"typescript": "^5.3.0",
"walk-sync": "^2.2.0",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.0",
"webpack-node-externals": "^3.0.0"
}
}
================================================
FILE: src/README.md
================================================
### Code Organization
* While `server/` and `client/` are self explanatory, `common/` and `plugins/` contain code that is used by both. On the other hand, `demo/` contains an isolated program for E2E testing and generating demo videos (which uses a separate `config.json` to avoid conflicts with the production database).
* Data Model: [`server/models.js`](server/models.js) contains the database schema, which is an excellent starting point. [`common/data_types/api.js`](common/data_types/api.js) is an interface that (almost) all datatypes need to implement, and the other files in that directory contain implementations of that API, along with additional utilities.
* Server: [`server/database.js`](server/database.js) is a wrapper over Sequelize, providing an useful API for "actions". [`server/actions.js`](server/actions.js) creates a registry for all the RPCs that the client can invoke, by looking at all files in [`server/actions/`](server/actions/). And finally, [`server/index.js`](server/index.js) initializes the webserver, and allows clients to invoke these actions.
* Client: [`client/index.js`](client/index.js) initializes React, which powers the whole UI. While [`common/SocketRPC.js`](common/SocketRPC.js) sets up a communication system between server and client, [`client/Common/Coordinator.js`](client/Common/Coordinator.js) allows communication between different UI components. [`client/Common/DataLoader.js`](client/Common/DataLoader.js) is a commonly used utility to not just load data once, but subscribe to changes and react to them (look for `this.broadcast` method calls in server-side actions), allowing different UI components to remain in sync.
* Plugins: This is custom logic that can be activated in the tool, augmenting core functionality, but is likely not relevant for everyone. See the README file in that directory for more details.
### Backup File Size Estimation
* (1 kilobyte / event) * (50 events / day) * (365 days / year) * (10 years) = 182,500,000 bytes < 200 MB for 10 years. Note that this estimation does not include other data types, but those are infrequently created, and not separately counted.
* The total size can reduced significantly by compressing the backup file if needed. JSON was picked for human readability, not for space efficiency. A simple experiment with "gzip" results in a file size that was 10% of the original.
================================================
FILE: src/client/Application/Application.js
================================================
import React from 'react';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import { Enum } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
import {
Coordinator, DataLoader, DateContext, EnumSelectorSection, ModalStack,
PluginDisplayComponent, PluginDisplayLocation, ScrollableSection, SettingsContext,
} from '../Common';
import { LogEventList } from '../LogEvent';
import { LogStructureList } from '../LogStructure';
import { LogTopicList } from '../LogTopic';
import PropTypes from '../prop-types';
import { ReminderSidebar } from '../Reminders';
import { SettingsSection } from '../Settings';
import BackupSection from './BackupSection';
import CreditsSection from './CreditsSection';
import DetailsSection from './DetailsSection';
import FavoritesSection from './FavoritesSection';
import IndexSection from './IndexSection';
import TabSection from './TabSection';
import URLState from './URLState';
const Layout = Enum([
{
label: 'Split',
value: 'split',
},
{
label: 'Left',
value: 'left',
},
{
label: 'Right',
value: 'right',
},
]);
const Widgets = Enum([
{
label: 'Show',
value: 'show',
},
{
label: 'Hide',
value: 'hide',
},
]);
class Applicaton extends React.Component {
constructor(props) {
super(props);
this.state = { urlParams: null, settings: null, disabled: false };
this.tabRef = React.createRef();
}
componentDidMount() {
this.deregisterCallbacks = [
URLState.init(),
Coordinator.subscribe('url-change', (urlParams) => this.setState({ urlParams })),
];
const urlParams = Coordinator.invoke('url-params');
urlParams.tab = urlParams.tab || TabSection.Enum.LOG_EVENT;
urlParams.layout = urlParams.layout || Layout.SPLIT;
urlParams.widgets = urlParams.widgets || Widgets.SHOW;
this.setState({ urlParams });
this.dataLoader = new DataLoader({
getInput: () => ({ name: 'settings-get' }),
onData: (settings) => this.setState({ settings }),
});
}
componentDidUpdate() {
this.dataLoader.reload();
}
componentWillUnmount() {
this.dataLoader.stop();
this.deregisterCallbacks.forEach((deregisterCallback) => deregisterCallback());
}
renderLeftSidebar() {
return (
Coordinator.invoke('url-update', { tab })}
ref={this.tabRef}
/>
{this.state.urlParams.widgets === Widgets.SHOW
? (
)
: null}
);
}
renderCenterSection() {
const { settings } = this.state;
const { layout } = this.state.urlParams;
let indexSection = null;
if (this.tabRef.current) {
const Component = this.tabRef.current.getComponent(this.state.urlParams.tab);
indexSection = (
Coordinator.invoke('url-update', params)}
/>
);
} else {
setTimeout(() => this.forceUpdate(), 0);
}
let detailsSection = (
Coordinator.invoke('url-update', { details })}
/>
);
if (settings.display_two_details_sections) {
detailsSection = (
Coordinator.invoke('url-update', { details })}
/>
Coordinator.invoke('url-update', { details2 })}
/>
);
}
if (layout === Layout.SPLIT) {
return (
<>
{indexSection}
{detailsSection}
>
);
} if (layout === Layout.LEFT) {
return (
{indexSection}
);
} if (layout === Layout.RIGHT) {
return (
{detailsSection}
);
}
return null;
}
renderRightSidebar() {
const { settings } = this.state;
const results = [];
results.push(
,
);
results.push(
Coordinator.invoke('url-update', { layout })}
/>,
Coordinator.invoke('url-update', { widgets })}
/>,
,
);
if (settings) {
results.push(
,
);
}
results.push(
,
);
if (this.state.urlParams.widgets === Widgets.SHOW) {
results.push(...this.renderRightSidebarWidgets());
}
results.push( );
return (
{results}
);
}
renderRightSidebarWidgets() {
const nameSortComparator = (left, right) => left.name.localeCompare(right.name);
const results = [];
results.push(
,
);
results.push(
,
);
results.push(
,
);
return results;
}
render() {
if (!this.state.urlParams) {
return null;
} if (!this.state.settings) {
return null;
}
const container = (
{this.renderLeftSidebar()}
{this.renderCenterSection(this.state.urlParams.layout)}
{this.renderRightSidebar()}
);
return (
{container}
);
}
}
Applicaton.propTypes = {
plugins: PropTypes.Custom.Plugins.isRequired,
};
export default Applicaton;
================================================
FILE: src/client/Application/BackupSection.js
================================================
import React from 'react';
import {
Coordinator, DataLoader, LeftRight, SidebarSection,
} from '../Common';
class BackupSection extends React.Component {
static onClick() {
window.api.send('backup-save')
.then(({ isUnchanged }) => Coordinator.invoke('modal-info', {
title: 'Backup',
message: isUnchanged ? 'Backup unchanged!' : 'Backup complete!',
}));
}
constructor(props) {
super(props);
this.state = { latestBackup: null };
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => ({
name: 'backup-latest',
}),
onData: (latestBackup) => this.setState({ latestBackup }),
});
}
componentWillUnmount() {
this.dataLoader.stop();
}
render() {
const { latestBackup } = this.state;
return (
BackupSection.onClick()}
title="Save New Backup"
>
Backup:
{latestBackup ? `${latestBackup.timetamp}` : 'No backup found!' }
);
}
}
export default BackupSection;
================================================
FILE: src/client/Application/CreditsSection.js
================================================
import React from 'react';
import { SidebarSection } from '../Common';
function CreditsSection(props) {
return (
{'Built by: '}
Kaustubh Karkare
{' | '}
GitHub
);
}
export default CreditsSection;
================================================
FILE: src/client/Application/DetailsSection.css
================================================
.details-section .scrollable-section .text-editor {
background-color: var(--component-color);
padding: 4px;
}
.details-section .scrollable-section .public-DraftEditor-content {
min-height: 200px;
}
================================================
FILE: src/client/Application/DetailsSection.js
================================================
import './DetailsSection.css';
import React from 'react';
import Button from 'react-bootstrap/Button';
import InputGroup from 'react-bootstrap/InputGroup';
import {
MdCheckCircle, MdClose, MdEdit, MdFavorite, MdFavoriteBorder, MdSearch,
} from 'react-icons/md';
import { RiLoaderLine } from 'react-icons/ri';
import RichTextUtils from '../../common/RichTextUtils';
import {
Coordinator, DataLoader, debounce,
ScrollableSection, SettingsContext, TextEditor, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import { LogEventDetailsHeader, LogEventEditor } from '../LogEvent';
import { LogValueListEditor } from '../LogKey';
import { LogStructureDetailsHeader, LogStructureEditor } from '../LogStructure';
import { LogTopicDetailsHeader, LogTopicEditor, LogTopicOptions } from '../LogTopic';
import PropTypes from '../prop-types';
const HEADER_MAPPING = {
'log-event': {
HeaderComponent: LogEventDetailsHeader,
EditorComponent: LogEventEditor,
valueKey: 'logEvent',
},
'log-structure': {
HeaderComponent: LogStructureDetailsHeader,
EditorComponent: LogStructureEditor,
valueKey: 'logStructure',
},
'log-topic': {
HeaderComponent: LogTopicDetailsHeader,
EditorComponent: LogTopicEditor,
valueKey: 'logTopic',
},
};
class DetailsSection extends React.Component {
constructor(props) {
super(props);
this.state = {
item: null,
isDirty: false,
isSaveDisabled: false,
};
this.saveDebounced = debounce(this.saveNotDebounced, 500);
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => {
const { item } = this.props;
if (!item) {
return null;
} if (item.__type__ in HEADER_MAPPING) {
return {
name: `${item.__type__}-load`,
args: { __id__: item.__id__ },
};
}
return null;
},
onData: (newItem) => {
const oldItem = this.state.item;
if (
oldItem
&& newItem
&& oldItem.__type__ === newItem.__type__
&& oldItem.__id__ === newItem.__id__
) {
this.setState((state) => {
const { details } = state.item; // copy local details
state.item = { ...newItem, details };
return state;
});
} else {
this.setState({ item: newItem });
}
},
onError: () => {
const { item } = this.props;
if (item) {
Coordinator.invoke(
'modal-error',
`${JSON.stringify(item, null, 4)}\n\nThis item does support details!`,
);
}
this.props.onChange(null);
},
});
}
componentDidUpdate() {
this.dataLoader.reload();
}
componentWillUnmount() {
this.dataLoader.stop();
}
onChange(item) {
this.setState((state) => {
state.item = item;
state.isDirty = true;
return state;
}, this.saveDebounced);
}
onEditButtonClick() {
const { item } = this.state;
const { EditorComponent, valueKey } = HEADER_MAPPING[item.__type__];
Coordinator.invoke('modal-editor', {
dataType: item.__type__,
EditorComponent,
valueKey,
value: item,
});
}
saveNotDebounced() {
if (this.state.isSaveDisabled) {
return;
}
const { item } = this.state;
if (item) {
window.api.send(`${item.__type__}-upsert`, item)
.then((newItem) => this.setState({
isDirty: !RichTextUtils.equals(item.details, newItem.details),
}));
}
}
renderPrefixButtons(item) {
const buttons = [];
const { HeaderComponent } = HEADER_MAPPING[item.__type__];
if (HeaderComponent.onSearchButtonClick) {
buttons.push(
HeaderComponent.onSearchButtonClick(item)}
title="Search"
>
,
);
}
if (typeof item.isFavorite === 'boolean') {
buttons.push(
this.onChange({ ...item, isFavorite: !item.isFavorite })}
title="Favorite"
>
{item.isFavorite ? : }
,
);
}
return buttons;
}
renderSuffixButtons(item) {
return [
this.onEditButtonClick()}>
,
{this.state.isDirty ? : }
,
this.props.onChange(null)}
>
,
];
}
renderHeader() {
const { item } = this.state;
if (item && item.__type__ in HEADER_MAPPING) {
const { HeaderComponent, valueKey } = HEADER_MAPPING[item.__type__];
const headerComponentProps = { [valueKey]: item };
return (
{this.renderPrefixButtons(item)}
{this.renderSuffixButtons(item)}
);
}
const options = new TypeaheadOptions({
serverSideOptions: [
{ name: 'log-topic' },
{ name: 'log-structure' },
],
});
return (
this.props.onChange(newItem)}
placeholder="Details ..."
/>
);
}
renderKeys() {
const { item } = this.state;
let logKeys = null;
if (!item) {
// nothing
} else if (item.__type__ === 'log-event') {
logKeys = item.logStructure && item.logStructure.logKeys;
} else if (item.__type__ === 'log-topic') {
logKeys = item.parentLogTopic && item.parentLogTopic.childKeys;
}
if (!logKeys) {
return null;
}
return (
null}
/>
);
}
renderDetails() {
const { item } = this.state;
if (!item) {
return null;
}
if (
item.__type__ === 'log-event'
&& item.logStructure
&& !item.logStructure.eventAllowDetails
) {
return (disabled by structure)
;
}
const parentLogTopic = item && item.__type__ === 'log-topic' ? item : undefined;
return (
this.onChange({ ...item, details })}
options={LogTopicOptions.get({
allowCreation: true,
parentLogTopic,
beforeSelect: () => this.setState({ isSaveDisabled: true }),
afterSelect: () => this.setState({ isSaveDisabled: false }),
})}
/>
);
}
render() {
const settings = this.context;
if (settings.display_two_details_sections) {
return (
{this.renderHeader()}
{this.renderKeys()}
{this.renderDetails()}
);
}
return (
{this.renderHeader()}
{this.renderDetails()}
);
}
}
DetailsSection.propTypes = {
item: PropTypes.Custom.Item,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
DetailsSection.contextType = SettingsContext;
export default DetailsSection;
================================================
FILE: src/client/Application/FavoritesSection.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { DataLoader, SidebarSection } from '../Common';
class FavoritesSection extends React.Component {
constructor(props) {
super(props);
this.state = { items: null };
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => ({
name: `${this.props.dataType}-list`,
args: {
where: { isFavorite: true },
},
}),
onData: (items) => {
if (this.props.sortComparator) {
items = items.sort(this.props.sortComparator);
}
this.setState({ items });
},
});
}
componentWillUnmount() {
this.dataLoader.stop();
}
renderContent() {
if (this.state.items === null) {
return 'Loading ...';
}
const { ViewerComponent, viewerComponentProps, valueKey } = this.props;
return this.state.items.map((item) => (
));
}
render() {
return (
{this.renderContent()}
);
}
}
FavoritesSection.propTypes = {
title: PropTypes.string.isRequired,
dataType: PropTypes.string.isRequired,
sortComparator: PropTypes.func,
ViewerComponent: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
viewerComponentProps: PropTypes.object,
valueKey: PropTypes.string.isRequired,
};
FavoritesSection.defaultProps = {
viewerComponentProps: {},
};
export default FavoritesSection;
================================================
FILE: src/client/Application/IndexSection.css
================================================
.index-section {
margin-bottom: 128px;
}
.index-section .text-editor {
max-width: 500px;
}
================================================
FILE: src/client/Application/IndexSection.js
================================================
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { DateRangePicker, ScrollableSection, TypeaheadSelector } from '../Common';
import PropTypes from '../prop-types';
class IndexSection extends React.Component {
renderWithTypeahead() {
const { Component, dateRange, onChange } = this.props;
const typeaheadOptions = Component.getTypeaheadOptions();
const filteredSearch = typeaheadOptions.filterToKnownTypes(this.props.search);
if (filteredSearch.length !== this.props.search.length) {
window.setTimeout(onChange.bind(filteredSearch), 0);
}
return (
onChange({ dateRange: newDateRange })}
/>
onChange({ search })}
placeholder="Search ..."
multiple
/>
);
}
renderSimple() {
const { Component } = this.props;
return (
);
}
render() {
const { Component } = this.props;
if (Component.getTypeaheadOptions) {
return this.renderWithTypeahead();
}
return this.renderSimple();
}
}
IndexSection.propTypes = {
Component: PropTypes.func.isRequired,
dateRange: PropTypes.Custom.DateRange,
search: PropTypes.arrayOf(PropTypes.Custom.Item.isRequired).isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default IndexSection;
================================================
FILE: src/client/Application/TabSection.js
================================================
import React from 'react';
import { Enum } from '../../common/data_types';
import { PluginDisplayLocation, SettingsContext, SidebarSection } from '../Common';
import { GraphSection } from '../Graphs';
import { LogEventSearch } from '../LogEvent';
import { LogStructureSearch } from '../LogStructure';
import { LogTopicSearch } from '../LogTopic';
import PropTypes from '../prop-types';
const Tab = Enum([
{
label: 'Manage Events',
value: 'log-event',
Component: LogEventSearch,
},
{
label: 'Manage Topics',
value: 'log-topic',
Component: LogTopicSearch,
},
{
label: 'Manage Structures',
value: 'log-structure',
Component: LogStructureSearch,
},
{
label: 'Explore Graphs',
value: 'graph',
Component: GraphSection,
},
]);
class TabSection extends React.Component {
constructor(props) {
super(props);
const PluginOptions = [];
const TabComponents = {};
Tab.Options.forEach((option) => {
TabComponents[option.value] = option.Component;
});
Object.entries(this.props.plugins).forEach(([_name, api]) => {
if (api.getDisplayLocation() === PluginDisplayLocation.TAB_SECTION) {
const tabData = api.getTabData();
PluginOptions.push(tabData);
TabComponents[tabData.value] = () => (
{(settings) => {
const key = api.getSettingsKey();
return api.getDisplayComponent({
settings: key ? settings[key] : null,
});
}}
);
}
});
this.state = {
options: Tab.Options.concat(PluginOptions),
components: TabComponents,
};
}
getComponent(value) {
return this.state.components[value];
}
render() {
return this.state.options.map((option) => (
this.props.onChange(option.value)}
selected={this.props.value === option.value}
>
{option.label}
));
}
}
TabSection.Enum = Tab;
TabSection.propTypes = {
plugins: PropTypes.Custom.Plugins.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default TabSection;
================================================
FILE: src/client/Application/URLState.js
================================================
import { Coordinator, DateRangePicker, URLManager } from '../Common';
/**
* [...Array(128).keys()]
* .map(code => String.fromCharCode(code))
* .filter(char => !char.match(/\w/) && char === encodeURIComponent(char))
* ["!", "'", "(", ")", "*", "-", ".", "~"]
* Picked the one most easily readable in the URL.
*/
const SEPARATOR = '~';
function serializeItem(item) {
return `${item.__type__}${SEPARATOR}${item.__id__}${SEPARATOR}${item.name}`;
}
function deserializeItem(token) {
const [__type__, __id__, name] = token.split(SEPARATOR);
return { __type__, __id__: parseInt(__id__, 10), name };
}
class URLState {
static getStateFromURL() {
const params = URLManager.get();
return {
tab: params.tab,
layout: params.layout,
widgets: params.widgets,
dateRange: DateRangePicker.deserialize(params.date_range),
search: params.search ? params.search.map(deserializeItem) : [],
details: params.details ? deserializeItem(params.details) : null,
// settings.display_two_details_sections
details2: params.details2 ? deserializeItem(params.details2) : null,
};
}
static getURLFromState(state) {
const params = {
tab: state.tab,
layout: state.layout,
widgets: state.widgets,
date_range: DateRangePicker.serialize(state.dateRange),
search: state.search ? state.search.map(serializeItem) : undefined,
details: state.details ? serializeItem(state.details) : undefined,
// settings.display_two_details_sections
details2: state.details2 ? serializeItem(state.details2) : undefined,
};
return URLManager.getLink(params);
}
static init() {
const instance = new URLState();
return () => instance.cleanup();
}
constructor() {
this.deregisterCallbacks = [
URLManager.init(() => this.onChange()),
Coordinator.register('url-params', () => this.state),
Coordinator.register('url-link', (data) => this.getLink(data)),
Coordinator.register('url-update', (data) => this.onUpdate(data)),
];
this.onChange(); // set this.state
}
cleanup() {
this.deregisterCallbacks.forEach((deregisterCallback) => deregisterCallback());
}
onChange() {
this.state = URLState.getStateFromURL();
Coordinator.broadcast('url-change', this.state);
}
getLink(methodOrData) {
let newState;
if (typeof methodOrData === 'function') {
newState = methodOrData(this.state) || this.state;
} else {
newState = { ...this.state, ...methodOrData };
}
return URLState.getURLFromState(newState);
}
onUpdate(methodOrData) {
URLManager.update(this.getLink(methodOrData));
}
}
export default URLState;
================================================
FILE: src/client/Application/index.js
================================================
// eslint-disable-next-line import/prefer-default-export
export { default as Application } from './Application';
================================================
FILE: src/client/Bootstrap/InputGroup.css
================================================
.input-group:focus {
outline: none;
}
.input-group > * {
border-style: solid;
border-color: transparent;
border-radius: 0;
border-width: 0px 0px;
font-size: var(--font-size);
height: 20px;
}
.input-group *:focus {
outline: none;
}
.input-group > :first-child {
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border-left-width: 0;
}
.input-group > :last-child {
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
border-right-width: 0;
}
.input-group > .input-group-text {
background: var(--component-color);
display: block;
padding: 1px;
text-align: center;
width: 128px;
}
.input-group > .btn {
background: var(--input-background-color);
width: 20px;
padding: 0px;
}
.input-group > .btn > svg {
position: relative;
top: -1px;
}
.input-group > input.form-control {
background-color: var(--input-background-color);
color: var(--input-text-color);
height: 20px;
padding: 0 4px;
}
.input-group > select.form-control {
background-color: var(--input-background-color);
color: var(--input-text-color);
padding: 0;
}
.input-group > .form-check-inline {
margin-right: 0;
width: 16px;
}
.input-group > .rbt input:first-child {
border: none;
border-radius: 0;
border-width: 0 1px;
padding: 0 4px;
height: 20px;
}
.input-group > .rbt input:first-child[disabled] {
background-color: var(--input-disabled-background-color);;
}
.input-group > .text-editor {
flex-grow: 1;
width: 1px;
height: auto;
}
================================================
FILE: src/client/Bootstrap/Modal.css
================================================
.modal-dialog {
max-width: 800px;
}
.modal-content {
background-color: var(--background-color);
border-color: var(--component-highlight-color);
color: var(--text-color);
}
================================================
FILE: src/client/Bootstrap/Popover.css
================================================
.popover {
max-width: none;
}
.popover-header,
.popover-body {
background-color: var(--input-background-color);
}
================================================
FILE: src/client/Bootstrap/index.js
================================================
import 'bootstrap/dist/css/bootstrap.min.css';
import './InputGroup.css';
import './Modal.css';
import './Popover.css';
================================================
FILE: src/client/Common/AddLinkPlugin.js
================================================
// https://bitwiser.in/2017/05/11/creating-rte-part-3-entities-and-decorators.html
/* eslint-disable */
import React from 'react';
import {
RichUtils,
KeyBindingUtil,
EditorState,
} from 'draft-js';
export const linkStrategy = (contentBlock, callback, contentState) => {
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null
&& contentState.getEntity(entityKey).getType() === 'LINK'
);
},
callback,
);
};
export const Link = (props) => {
const { contentState, entityKey } = props;
const { url } = contentState.getEntity(entityKey).getData();
return (
{props.children}
);
};
const AddLinkPlugin = {
keyBindingFn(event, { getEditorState }) {
const editorState = getEditorState();
const selection = editorState.getSelection();
// Don't do anything if no text is selected.
if (selection.isCollapsed()) {
return;
}
if (KeyBindingUtil.hasCommandModifier(event) && event.which === 75) {
return 'add-link';
}
},
handleKeyCommand(command, editorState, eventTimeStamp, { getEditorState, setEditorState }) {
if (command !== 'add-link') {
return 'not-handled';
}
const link = window.prompt('Paste the link:');
const selection = editorState.getSelection();
if (!link) {
setEditorState(RichUtils.toggleLink(editorState, selection, null));
return 'handled';
}
const content = editorState.getCurrentContent();
const contentWithEntity = content.createEntity('LINK', 'MUTABLE', { url: link });
const newEditorState = EditorState.push(editorState, contentWithEntity, 'create-entity');
const entityKey = contentWithEntity.getLastCreatedEntityKey();
setEditorState(RichUtils.toggleLink(newEditorState, selection, entityKey));
return 'handled';
},
decorators: [{
strategy: linkStrategy,
component: Link,
}],
};
export default AddLinkPlugin;
================================================
FILE: src/client/Common/AsyncSelector.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Form from 'react-bootstrap/Form';
import DataLoader from './DataLoader';
class AsyncSelector extends React.Component {
constructor(props) {
super(props);
this.state = { options: null };
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => this.props.options,
onData: (options) => this.setState({
options: [...this.props.prefixOptions, ...options, ...this.props.suffixOptions],
}),
});
}
componentDidUpdate(prevProps) {
this.dataLoader.reload();
}
componentWillUnmount() {
this.dataLoader.stop();
}
onChange(id) {
if (this.state.options) {
const selectedOption = this.state.options.find(
(option) => option.id.toString() === id,
);
if (selectedOption) {
this.props.onChange(selectedOption);
}
}
}
render() {
const options = this.state.options || [this.props.value];
return (
this.onChange(event.target.value)}
>
{options.map((item) => {
const optionProps = { key: item.__id__, value: item.__id__ };
return {item[this.props.labelKey]} ;
})}
);
}
}
AsyncSelector.propTypes = {
labelKey: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.object,
// eslint-disable-next-line react/forbid-prop-types
prefixOptions: PropTypes.array,
// eslint-disable-next-line react/forbid-prop-types
options: PropTypes.object,
// eslint-disable-next-line react/forbid-prop-types
suffixOptions: PropTypes.array,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
AsyncSelector.defaultProps = {
labelKey: 'name',
prefixOptions: [],
suffixOptions: [],
};
export default AsyncSelector;
================================================
FILE: src/client/Common/BulletList/BulletList.css
================================================
.bullet-list .pager {
color: var(--text-disabled-color);
}
.bullet-list .pager > span {
cursor: pointer;
}
.bullet-list .pager > span:hover {
color: var(--text-color);
}
================================================
FILE: src/client/Common/BulletList/BulletList.js
================================================
import './BulletList.css';
import arrayMove from 'array-move';
import classNames from 'classnames';
import deepEqual from 'deep-equal';
import PropTypes from 'prop-types';
import React from 'react';
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
import { getDataTypeMapping } from '../../../common/data_types';
import DataLoader from '../DataLoader';
import SettingsContext from '../SettingsContext';
import BulletListItem from './BulletListItem';
import BulletListLine from './BulletListLine';
import BulletListPager from './BulletListPager';
import BulletListTitle from './BulletListTitle';
const WrappedContainer = SortableContainer(({ children }) => {children}
);
const SortableBulletListItem = SortableElement(BulletListItem);
class BulletList extends React.Component {
static getDerivedStateFromProps(props, state) {
if (state.items) {
state.areAllExpanded = state.items
.every((item) => state.isExpanded[item.__id__]);
}
return state;
}
constructor(props) {
super(props);
const pageSize = parseInt(props.settings.bullet_list_page_size, 10) || 25;
this.state = {
items: null,
isExpanded: {},
areAllExpanded: true,
pageSize,
limit: pageSize,
};
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => ({
name: `${this.props.dataType}-list`,
args: {
where: this.props.where,
limit: this.state.limit !== null ? this.state.limit + 1 : undefined,
},
}),
onData: (items) => {
if (this.state.limit && items.length > this.state.limit) {
this.setState({ items: items.slice(1), hasMoreItems: true });
} else {
this.setState({ items, hasMoreItems: false, limit: null });
}
},
});
}
componentDidUpdate(prevProps) {
if (
prevProps.dataType !== this.props.dataType
|| !deepEqual(prevProps.where, this.props.where)
) {
this.updateLimit(this.state.pageSize);
}
}
componentWillUnmount() {
this.dataLoader.stop();
}
onAddButtonClick(event) {
const DataType = getDataTypeMapping()[this.props.dataType];
const value = DataType.createVirtual(this.props.where);
const context = { ...this };
context.props = { ...context.props, value };
BulletListItem.prototype.onEdit.call(context, event);
}
onSortButtonClick(event) {
const input = {
dataType: this.props.dataType,
where: this.props.where,
};
window.api.send(`${this.props.dataType}-sort`, input);
}
onMove(index, delta, event) {
if (!event.shiftKey) return;
const otherIndex = index + delta;
const totalLength = this.state.items.length;
if (otherIndex < 0 || otherIndex === totalLength) return;
this.onReorder({ oldIndex: index, newIndex: otherIndex });
}
onReorder({ oldIndex, newIndex }) {
if (!this.props.allowReordering) return;
const orderedItems = arrayMove(this.state.items, oldIndex, newIndex);
const input = {
dataType: this.props.dataType,
where: this.props.where,
ordering: orderedItems.map((item) => item.__id__),
};
window.api.send(`${this.props.dataType}-reorder`, input)
.then(() => this.setState({ items: orderedItems }));
}
updateLimit(limit) {
this.setState({ limit, items: null }, () => this.dataLoader.reload());
}
renderItems() {
if (!this.state.items) {
return (
Loading ...
);
}
return this.state.items.map((item, index) => (
({ ...action, perform: action.perform.bind(null, item) }))}
onMoveUp={(event) => this.onMove(index, -1, event)}
onMoveDown={(event) => this.onMove(index, 1, event)}
isExpanded={this.state.isExpanded[item.__id__] || false}
setIsExpanded={(isExpanded) => this.setState((state) => {
state.isExpanded[item.__id__] = isExpanded;
return state;
})}
value={item}
dragHandleSpace
/>
));
}
renderAdder() {
const { AdderComponent } = this.props;
if (!AdderComponent) {
return null;
}
return (
);
}
render() {
return (
this.setState((state) => {
if (state.areAllExpanded) {
return { isExpanded: {} };
}
return {
isExpanded: Object.fromEntries(
state.items.map((item) => [item.__id__, true]),
),
};
})}
onAddButtonClick={this.props.allowCreation
? (event) => this.onAddButtonClick(event)
: null}
onSortButtonClick={this.props.allowSorting
? (event) => this.onSortButtonClick(event)
: null}
/>
this.updateLimit(limit)}
itemsLength={this.state.items ? this.state.items.length : null}
hasMoreItems={this.state.hasMoreItems}
/>
this.onReorder(data)}
>
{this.renderItems()}
{this.renderAdder()}
);
}
}
BulletList.propTypes = {
name: PropTypes.string.isRequired,
dataType: PropTypes.string.isRequired,
valueKey: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
where: PropTypes.object,
allowCreation: PropTypes.bool,
allowSorting: PropTypes.bool,
allowReordering: PropTypes.bool,
ViewerComponent: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
viewerComponentProps: PropTypes.object,
EditorComponent: PropTypes.func.isRequired,
AdderComponent: PropTypes.func,
// eslint-disable-next-line react/forbid-prop-types
prefixActions: PropTypes.array,
className: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
settings: PropTypes.object.isRequired,
};
BulletList.defaultProps = {
allowCreation: true,
prefixActions: [],
};
const WrappedBulletList = SettingsContext.Wrapper(BulletList);
WrappedBulletList.Item = BulletListItem;
export default WrappedBulletList;
================================================
FILE: src/client/Common/BulletList/BulletListIcon.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { KeyCodes } from '../Utils';
function BulletListIcon(props) {
return (
{
if (event.keyCode === KeyCodes.ENTER) {
props.onClick(event);
}
}}
>
{props.children}
);
}
BulletListIcon.propTypes = {
onClick: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any.isRequired,
};
export default BulletListIcon;
================================================
FILE: src/client/Common/BulletList/BulletListItem.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { BsList } from 'react-icons/bs';
import { GoPrimitiveDot } from 'react-icons/go';
import { MdEdit, MdFormatLineSpacing } from 'react-icons/md';
import { TiMinus, TiPlus } from 'react-icons/ti';
import { SortableHandle } from 'react-sortable-hoc';
import Coordinator from '../Coordinator';
import Dropdown from '../Dropdown';
import Highlightable from '../Highlightable';
import Icon from '../Icon';
import InputLine from '../InputLine';
import { KeyCodes } from '../Utils';
const SortableDragHandle = SortableHandle(() => (
));
class BulletListItem extends React.Component {
constructor(props) {
super(props);
this.state = { isHighlighted: false, isExpanded: false };
this.dropdownRef = React.createRef();
}
onEdit(event) {
if (event) {
// Don't let enter propagate to EditorModal.
event.preventDefault();
event.stopPropagation();
}
if (event && event.shiftKey) {
Coordinator.invoke('url-update', { details: this.props.value });
return;
}
Coordinator.invoke('modal-editor', {
dataType: this.props.dataType,
EditorComponent: this.props.EditorComponent,
valueKey: this.props.valueKey,
value: this.props.value,
});
}
onDelete(event) {
if (event) {
// Don't let enter propagate to ConfirmationModal.
event.preventDefault();
event.stopPropagation();
}
if (event && !event.shiftKey) {
Coordinator.invoke('modal-confirm', {
title: 'Confirm deletion?',
body: this.renderViewer(),
onClose: (result) => {
if (result) this.onDelete();
},
});
return;
}
window.api.send(`${this.props.dataType}-delete`, this.props.value.__id__);
}
onKeyDown(event) {
if (event.keyCode === KeyCodes.SPACE) {
this.setIsExpanded(!this.getIsExpanded());
} else if (event.keyCode === KeyCodes.ENTER) {
this.onEdit(event);
} else if (event.keyCode === KeyCodes.DELETE) {
this.onDelete(event);
} else if (event.keyCode === KeyCodes.UP_ARROW) {
if (this.props.allowReordering) this.props.onMoveUp(event);
} else if (event.keyCode === KeyCodes.DOWN_ARROW) {
if (this.props.allowReordering) this.props.onMoveDown(event);
}
}
getIsExpanded() {
if (typeof this.props.isExpanded !== 'undefined') {
return this.props.isExpanded;
}
return this.state.isExpanded;
}
setIsExpanded(isExpanded) {
if (typeof this.props.isExpanded !== 'undefined') {
this.props.setIsExpanded(isExpanded);
} else {
this.setState({ isExpanded });
}
}
setIsHighlighted(isHighlighted) {
if (!isHighlighted && this.dropdownRef.current) {
this.dropdownRef.current.hide();
}
this.setState({ isHighlighted });
}
getViewerProps() {
return { [this.props.valueKey]: this.props.value, ...this.props.viewerComponentProps };
}
renderDragHandle() {
if (!this.props.dragHandleSpace) return null;
if (this.state.isHighlighted && this.props.allowReordering) return ;
return ;
}
renderBullet() {
const isExpanded = this.getIsExpanded();
const iconProps = {
alwaysHighlighted: true,
className: 'mr-1',
title: isExpanded ? 'Collapse' : 'Expand',
};
if (this.state.isHighlighted) {
return (
this.setIsExpanded(!isExpanded)}>
{isExpanded ? : }
);
}
return (
{isExpanded ? : }
);
}
renderEditButton() {
if (!this.state.isHighlighted) {
return null;
}
return this.onEdit(event)} />;
}
renderActionsDropdown() {
if (!this.state.isHighlighted) {
return null;
}
const actions = [...this.props.prefixActions];
actions.push({
__id__: 'delete',
name: 'Delete',
perform: (event) => this.onDelete(event),
});
actions.push({
__id__: 'info',
name: 'Debug Info',
perform: (_event) => Coordinator.invoke(
'modal-info',
{
title: 'Debug Info',
message: {JSON.stringify(this.props.value, null, 4)} ,
},
),
});
return (
action.perform(event)}
ref={this.dropdownRef}
>
{
if (this.dropdownRef.current) {
this.dropdownRef.current.show();
}
}}
/>
);
}
renderExpanded() {
if (!this.getIsExpanded()) {
return null;
}
// 13 = width of 1 icon. 4 = margin right of bullet icon
const marginLeft = 13 * (this.props.dragHandleSpace ? 2 : 1) + 4;
return (
{this.renderExpandedViewer()}
);
}
renderViewer() {
const { ViewerComponent } = this.props;
return (
this.setIsExpanded(!this.getIsExpanded())}
/>
);
}
renderExpandedViewer() {
const { ViewerComponent } = this.props;
if (ViewerComponent.Expanded) {
return ;
}
return null;
}
render() {
return (
<>
this.setIsHighlighted(isHighlighted)}
onKeyDown={(event) => this.onKeyDown(event)}
>
{this.renderDragHandle()}
{this.renderBullet()}
{this.renderViewer()}
{this.renderEditButton()}
{this.renderActionsDropdown()}
{this.renderExpanded()}
>
);
}
}
BulletListItem.propTypes = {
dataType: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.object.isRequired,
valueKey: PropTypes.string.isRequired,
ViewerComponent: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
viewerComponentProps: PropTypes.object,
EditorComponent: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
prefixActions: PropTypes.array,
// The following props are only used by BulletList.
dragHandleSpace: PropTypes.bool,
allowReordering: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
isExpanded: PropTypes.bool,
setIsExpanded: PropTypes.func,
};
BulletListItem.defaultProps = {
prefixActions: [],
};
export default BulletListItem;
================================================
FILE: src/client/Common/BulletList/BulletListLine.js
================================================
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { GoPrimitiveDot } from 'react-icons/go';
function BulletListLine(props) {
// eslint-disable-next-line react/prop-types
const { children, ...moreProps } = props;
return (
{children}
);
}
export default BulletListLine;
================================================
FILE: src/client/Common/BulletList/BulletListPager.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Highlightable from '../Highlightable';
import { KeyCodes } from '../Utils';
import BulletListLine from './BulletListLine';
class BulletListPager extends React.Component {
constructor(props) {
super(props);
this.state = {
isHighlighted: false,
};
}
onKeyDown(event) {
if (event.keyCode === KeyCodes.SPACE) {
this.props.updateLimit(this.props.limit + this.props.batchSize);
} else if (event.keyCode === KeyCodes.ENTER) {
this.props.updateLimit(null);
}
}
renderButtons() {
return (
<>
{' |'}
this.props.updateLimit(this.props.limit + this.props.batchSize)}
>
Load More
|
this.props.updateLimit(null)}
>
Load All
>
);
}
render() {
let message;
if (this.props.itemsLength === null) {
if (this.props.limit === this.props.batchSize) {
// We don't know whether we need pagination yet.
return null;
} if (this.props.limit) {
message = `Fetching last ${this.props.limit} items ...`;
} else {
message = 'Fetching all items ...';
}
return (
{message}
);
}
if (this.props.itemsLength <= this.props.batchSize && !this.props.hasMoreItems) {
// No need for pagination.
return null;
}
let buttons;
if (this.props.hasMoreItems) {
message = `Showing last ${this.props.itemsLength} items`;
buttons = this.renderButtons();
} else {
message = `Showing all ${this.props.itemsLength} items`;
}
return (
this.setState({ isHighlighted })}
>
{message}
{buttons}
);
}
}
BulletListPager.propTypes = {
batchSize: PropTypes.number.isRequired,
limit: PropTypes.number,
updateLimit: PropTypes.func.isRequired,
itemsLength: PropTypes.number,
hasMoreItems: PropTypes.bool,
};
export default BulletListPager;
================================================
FILE: src/client/Common/BulletList/BulletListTitle.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { MdAddCircleOutline } from 'react-icons/md';
import { TiMinus, TiPlus } from 'react-icons/ti';
import Highlightable from '../Highlightable';
import { KeyCodes } from '../Utils';
import BulletListIcon from './BulletListIcon';
class BulletListTitle extends React.Component {
constructor(props) {
super(props);
this.state = { isHighlighted: false };
}
onKeyDown(event) {
if (event.keyCode === KeyCodes.ENTER) {
this.props.onAddButtonClick(event);
}
}
renderListToggleButton() {
if (this.props.areAllExpanded) {
return (
);
}
return (
);
}
renderAddButton() {
if (!this.props.onAddButtonClick) {
return null;
}
return (
);
}
renderSortButton() {
if (!this.props.onSortButtonClick) {
return null;
}
// TODO: Use a proper icon to indicate sorting.
// Was on a flight (no internet access) when I added this feature.
return (
);
}
render() {
return (
this.setState({ isHighlighted })}
onKeyDown={(event) => this.onKeyDown(event)}
>
{this.props.name}
{this.state.isHighlighted ? this.renderListToggleButton() : null}
{this.state.isHighlighted ? this.renderAddButton() : null}
{this.state.isHighlighted ? this.renderSortButton() : null}
);
}
}
BulletListTitle.propTypes = {
name: PropTypes.string.isRequired,
areAllExpanded: PropTypes.bool.isRequired,
onToggleButtonClick: PropTypes.func.isRequired,
onAddButtonClick: PropTypes.func,
onSortButtonClick: PropTypes.func,
};
export default BulletListTitle;
================================================
FILE: src/client/Common/BulletList/index.js
================================================
export { default } from './BulletList';
================================================
FILE: src/client/Common/ConfirmModal.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import { suppressUnlessShiftKey } from './Utils';
function ConfirmModal(props) {
return (
props.onClose()}
onEscapeKeyDown={suppressUnlessShiftKey}
>
{props.title}
{props.body}
props.onClose(false)}>
{props.noLabel}
{' '}
props.onClose(true)}>
{props.yesLabel}
);
}
ConfirmModal.propTypes = {
title: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
body: PropTypes.any.isRequired,
yesLabel: PropTypes.string,
noLabel: PropTypes.string,
onClose: PropTypes.func.isRequired,
};
ConfirmModal.defaultProps = {
yesLabel: 'Yes',
noLabel: 'No',
};
export default ConfirmModal;
================================================
FILE: src/client/Common/Coordinator.js
================================================
const callbacks = {};
class Coordinator {
static register(name, callback) {
callbacks[name] = callback;
return () => delete callbacks[name];
}
static invoke(name, ...args) {
return callbacks[name].call(this, ...args);
}
static subscribe(name, callback) {
if (!(name in callbacks)) {
callbacks[name] = [];
}
callbacks[name].push(callback);
return () => {
const index = callbacks[name].indexOf(callback);
callbacks[name].splice(index, 1);
};
}
static broadcast(name, ...args) {
if (!(name in callbacks)) {
callbacks[name] = [];
}
callbacks[name].forEach((callback) => callback.call(this, ...args));
}
}
export default Coordinator;
================================================
FILE: src/client/Common/DataLoader.js
================================================
import deepEqual from 'deep-equal';
import deepcopy from 'deepcopy';
import { getPartialItem, isItem } from '../../common/data_types';
function IGNORE() {
return null;
}
class DataLoader {
constructor({ getInput, onData, onError }) {
this.getInput = getInput;
this.input = null;
this.cancelSubscription = null;
this.onData = onData || IGNORE;
this.onError = onError || IGNORE;
this.isMounted = true;
this.reload();
}
reload({ force } = {}) {
const input = deepcopy(this.getInput());
if (input && input.args && input.args.where) {
// This is an optimization to prevent sending unnecessary data to the server.
Object.entries(input.args.where).forEach(([key, value]) => {
if (isItem(value)) {
input.args.where[key] = getPartialItem(value);
}
});
}
if (!force && deepEqual(input, this.input)) {
return;
}
this.input = input;
if (this.input === null) {
this.onData(null);
return;
}
window.api.send(this.input.name, this.input.args)
.then((data) => {
if (this.isMounted) {
this.setupSubscription();
this.onData(data);
}
})
.catch((error) => {
if (this.isMounted) {
this.onError(error);
}
});
}
// eslint-disable-next-line class-methods-use-this
_compare(name, left, right) {
if (name.endsWith('-load')) {
return left.__id__ === right.__id__;
} if (name.endsWith('-list')) {
left = left.where || {};
right = right.where || {};
return Object.keys(left).every(
(key) => typeof right[key] === 'undefined' || left[key] === right[key],
);
}
return true;
}
setupSubscription() {
const { promise, cancel } = window.api.subscribe(this.input.name);
if (this.cancelSubscription) {
this.cancelSubscription();
}
this.cancelSubscription = cancel;
promise.then((data) => {
if (!this.isMounted || !this.input) {
return;
}
const queryArgs = this.input.args || {};
const broadcastArgs = data || {};
if (this._compare(this.input.name, queryArgs, broadcastArgs)) {
this.reload({ force: true });
} else {
this.setupSubscription();
}
});
}
stop() {
this.isMounted = false;
if (this.cancelSubscription) {
this.cancelSubscription();
}
}
}
export default DataLoader;
================================================
FILE: src/client/Common/DateContext.js
================================================
import React from 'react';
const DateContext = React.createContext(null);
DateContext.Wrapper = (Component) => (moreProps) => (
{(dateContext) => }
);
export default DateContext;
================================================
FILE: src/client/Common/DatePicker.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { Calendar } from 'react-date-range';
import DateUtils from '../../common/DateUtils';
import DateContext from './DateContext';
import PopoverElement from './PopoverElement';
// https://github.com/hypeserver/react-date-range
// Note: The corresponding CSS is included from DateRangePicker.
class DatePicker extends React.Component {
constructor(props) {
super(props);
this.state = {
lastDate: this.props.date || null,
};
}
render() {
const { todayLabel } = this.context;
const lastDate = this.state.lastDate || todayLabel;
return (
this.props.onChange(null)}>
{this.props.date || 'Date: Unspecified'}
{
const date = DateUtils.getLabel(rawDate);
this.setState({ lastDate: date });
this.props.onChange(date);
}}
/>
);
}
}
DatePicker.propTypes = {
date: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
DatePicker.contextType = DateContext;
export default DatePicker;
================================================
FILE: src/client/Common/DateRangePicker.js
================================================
// https://adphorus.github.io/react-date-range/
import 'react-date-range/dist/styles.css'; // main css file
import 'react-date-range/dist/theme/default.css'; // theme css file
import React from 'react';
import { DateRangePicker as DateRangePickerOriginal } from 'react-date-range';
import DateUtils from '../../common/DateUtils';
import PropTypes from '../prop-types';
import DateContext from './DateContext';
import PopoverElement from './PopoverElement';
const KEY = 'selection';
function DateRangeSelector(props) {
const { dateRange } = props;
return (
props.onChange({
startDate: DateUtils.getLabel(ranges[KEY].startDate),
endDate: DateUtils.getLabel(ranges[KEY].endDate),
})}
/>
);
}
DateRangeSelector.propTypes = {
dateRange: PropTypes.Custom.DateRange.isRequired,
onChange: PropTypes.func.isRequired,
};
class DateRangePicker extends React.Component {
constructor(props) {
super(props);
this.state = {
lastDateRange: this.props.dateRange || null,
};
}
renderSummary() {
const { dateRange } = this.props;
if (!dateRange) {
return 'Date Range: Unspecified';
}
if (dateRange.startDate === dateRange.endDate) {
return dateRange.startDate;
}
return `${dateRange.startDate} to ${dateRange.endDate}`;
}
render() {
const { todayLabel } = this.context;
const lastDateRange = this.state.lastDateRange || {
startDate: todayLabel,
endDate: todayLabel,
};
return (
this.props.onChange(null)}>
{this.renderSummary()}
{
this.setState({ lastDateRange: newDateRange });
this.props.onChange(newDateRange);
}}
/>
);
}
}
DateRangePicker.propTypes = {
dateRange: PropTypes.Custom.DateRange,
onChange: PropTypes.func.isRequired,
};
DateRangePicker.Selector = DateRangeSelector;
const DATE_RANGE_SEPARATOR = ' to ';
DateRangePicker.serialize = (dateRange) => {
if (!dateRange) return null;
return dateRange.startDate + DATE_RANGE_SEPARATOR + dateRange.endDate;
};
DateRangePicker.deserialize = (value) => {
if (!value) return null;
const [startDate, endDate] = value.split(DATE_RANGE_SEPARATOR);
return { startDate, endDate };
};
DateRangePicker.contextType = DateContext;
export default DateRangePicker;
================================================
FILE: src/client/Common/Dropdown.css
================================================
.dropdown-toggle::after {
display: none;
}
/**
* There are multiple reports of this problem.
* https://stackoverflow.com/q/42046287/903585
* https://stackoverflow.com/q/18892351/903585
* None of those solutions worked, so this is what I came up with.
*/
.dropdown-menu {
inset: 0px 0px auto auto !important;
}
================================================
FILE: src/client/Common/Dropdown.js
================================================
import './Dropdown.css';
import PropTypes from 'prop-types';
import React from 'react';
import Dropdown from 'react-bootstrap/Dropdown';
import TypeaheadOptions from './TypeaheadOptions';
class CustomDropdown extends React.Component {
constructor(props) {
super(props);
this.state = { isShown: false };
}
componentDidMount() {
if (Array.isArray(this.props.options)) {
this.setState({ items: this.props.options });
}
}
onSelect(item, event) {
if (this.props.options instanceof TypeaheadOptions) {
this.props.options.select(item)
.then((adjustedItem) => {
// undefined = no change
// null = cancel operation
if (adjustedItem !== null) {
this.props.onChange(adjustedItem || item, event);
}
});
} else {
this.props.onChange(item, event);
}
}
setIsShown(nextIsShown) {
if (nextIsShown) {
this.show();
} else {
this.hide();
}
}
hide() {
this.setState({ isShown: false });
}
show() {
if (this.props.options instanceof TypeaheadOptions) {
this.props.options.search('')
.then((items) => this.setState({ isShown: true, items }));
} else {
this.setState({ isShown: true });
}
}
renderItems() {
if (!this.state.items) return null;
if (this.state.items.length === 0) {
return (
No Results
);
}
return this.state.items.map((item) => (
this.onSelect(item, event)}
>
{item[this.props.labelKey]}
));
}
render() {
return (
this.setIsShown(isShown)}
show={this.state.isShown}
>
{this.props.children}
{this.renderItems()}
);
}
}
CustomDropdown.propTypes = {
labelKey: PropTypes.string,
// eslint-disable-next-line react/no-unused-prop-types
disabled: PropTypes.bool.isRequired,
options: PropTypes.oneOfType([
PropTypes.instanceOf(TypeaheadOptions),
PropTypes.array,
]).isRequired,
onChange: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
CustomDropdown.defaultProps = {
labelKey: 'name',
};
export default CustomDropdown;
================================================
FILE: src/client/Common/EditorModal.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import InputGroup from 'react-bootstrap/InputGroup';
import Modal from 'react-bootstrap/Modal';
import LeftRight from './LeftRight';
import { debounce, KeyCodes, suppressUnlessShiftKey } from './Utils';
class EditorModal extends React.Component {
constructor(props) {
super(props);
this.state = {
value: props.value,
status: 'Pending Validation ...',
isSaving: false,
isValidating: false,
};
this.validateItemDebounced = debounce(this.validateItemNotDebounced, 500);
}
componentDidMount() {
this.validateItemDebounced();
}
onChange(value) {
this.setState({ value }, () => this.validateItemDebounced());
}
onSave() {
this.saveItemNotDebounced();
}
onClose() {
this.props.onClose(this.state.value);
}
validateItemNotDebounced() {
this.setState({ isValidating: true, status: 'Validating ...' });
window.api.send(`${this.props.dataType}-validate`, this.state.value)
.finally(() => this.setState({ isValidating: false }))
.then((validationErrors) => this.setState({
status: validationErrors.join('\n') || 'No validation errors!',
}))
.catch(() => this.setState({ status: 'Error!' }));
}
saveItemNotDebounced() {
this.setState({ isSaving: true, status: 'Saving ...' });
let promise;
if (this.props.onSave) {
// A custom onSave method is used for because reminder completion needs to
// create the event and update the structure as part of same single transaction.
promise = this.props.onSave(this.state.value);
if (!(promise instanceof Promise)) {
// If the custom onSave method does not return a promise,
// it is assumed that the component will be unmounted.
return;
}
} else {
promise = window.api.send(`${this.props.dataType}-upsert`, this.state.value);
}
promise
.finally(() => this.setState({ isSaving: false }))
.then((value) => {
this.setState({ status: 'Saved!', value });
this.onClose();
})
.catch(() => this.setState({ status: 'Error!' }));
}
renderSaveButton() {
return (
this.onSave()}
style={{ width: '50px' }}
>
Save
);
}
render() {
if (!this.props.value) {
return null;
}
const { EditorComponent, editorProps } = this.props;
editorProps[this.props.valueKey] = this.state.value;
editorProps.disabled = this.state.isSaving;
return (
this.onClose()}
onEscapeKeyDown={suppressUnlessShiftKey}
>
Editor
this.onChange(newValue)}
onSpecialKeys={(event) => {
if (!event.shiftKey) return;
if (event.keyCode === KeyCodes.ENTER) {
this.onSave();
} else if (event.keyCode === KeyCodes.ESCAPE) {
this.onClose();
}
}}
/>
{this.state.status}
{this.renderSaveButton()}
);
}
}
EditorModal.propTypes = {
dataType: PropTypes.string.isRequired,
EditorComponent: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
valueKey: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired, // provided by ModalStack
// eslint-disable-next-line react/forbid-prop-types
editorProps: PropTypes.object,
onSave: PropTypes.func,
};
EditorModal.defaultProps = {
editorProps: {},
};
export default EditorModal;
================================================
FILE: src/client/Common/EnumSelectorSection.js
================================================
import React from 'react';
import PropTypes from '../prop-types';
import LeftRight from './LeftRight';
import SidebarSection from './SidebarSection';
class EnumSelectorSection extends React.Component {
renderOptions() {
return this.props.options.map((option, index) => {
let { label } = option;
if (this.props.value !== option.value) {
label = (
this.props.onChange(option.value)}>
{option.label}
);
}
return (
{index ? ' | ' : ''}
{' '}
{label}
);
});
}
render() {
return (
{this.props.label}
{this.renderOptions()}
);
}
}
EnumSelectorSection.propTypes = {
label: PropTypes.string.isRequired,
options: PropTypes.Custom.EnumOptions.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default EnumSelectorSection;
================================================
FILE: src/client/Common/ErrorModal.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'react-bootstrap/Modal';
import { suppressUnlessShiftKey } from './Utils';
function ErrorModal(props) {
let { error } = props;
if (typeof error !== 'string') {
error = JSON.stringify(error);
}
return (
Error
{error}
);
}
ErrorModal.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
error: PropTypes.any.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ErrorModal;
================================================
FILE: src/client/Common/Highlightable.css
================================================
.highlightable:focus {
outline: none;
}
.highlightable.highlighted {
background: var(--component-highlight-color);
}
================================================
FILE: src/client/Common/Highlightable.js
================================================
import './Highlightable.css';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Coordinator from './Coordinator';
class Highlightable extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
componentDidMount() {
this.deregisterCallbacks = [
Coordinator.subscribe('unhighlight', () => this.setHighlight(false)),
];
}
componentWillUnmount() {
this.deregisterCallbacks.forEach((deregisterCallback) => deregisterCallback());
}
setHighlight(isHighlighted) {
if (this.props.isHighlighted === isHighlighted) {
return;
}
if (isHighlighted) {
Coordinator.broadcast('unhighlight');
this.ref.current.focus();
}
this.props.onChange(isHighlighted);
}
render() {
const {
isHighlighted, onChange: _, children, ...moreProps
} = this.props;
return (
this.setHighlight(true)}
onMouseLeave={() => this.setHighlight(false)}
onFocus={() => this.setHighlight(true)}
onBlur={() => this.setHighlight(false)}
ref={this.ref}
>
{children}
);
}
}
Highlightable.propTypes = {
isHighlighted: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
export default Highlightable;
================================================
FILE: src/client/Common/Icon.css
================================================
.icon {
cursor: pointer;
height: 20px;
position: relative;
top: -1px;
width: 13px;
}
.icon > svg {
fill: var(--text-disabled-color);
}
.icon:not(.icon-never-highlight):hover > svg,
.icon.icon-highlighted > svg {
fill: var(--text-color);
}
================================================
FILE: src/client/Common/Icon.js
================================================
import './Icon.css';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
function Icon(props) {
const {
alwaysHighlighted, neverHighlighted, className, children, ...moreProps
} = props;
moreProps.className = classNames({
icon: true,
'icon-highlighted': alwaysHighlighted,
'icon-never-highlight': neverHighlighted,
}, className);
return (
{children}
);
}
Icon.propTypes = {
className: PropTypes.string,
alwaysHighlighted: PropTypes.bool,
neverHighlighted: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
export default Icon;
================================================
FILE: src/client/Common/InfoModal.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'react-bootstrap/Modal';
import { suppressUnlessShiftKey } from './Utils';
function InfoModal(props) {
return (
{props.title}
{props.message}
);
}
InfoModal.propTypes = {
title: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
message: PropTypes.any.isRequired,
onClose: PropTypes.func.isRequired,
};
export default InfoModal;
================================================
FILE: src/client/Common/InputLine.css
================================================
.input-line {
flex: 1 1 auto;
height: auto;
overflow: hidden;
width: 0px;
}
.input-line.overflow {
overflow: visible;
}
.input-line.styled {
background-color: var(--input-background-color);
}
================================================
FILE: src/client/Common/InputLine.js
================================================
import './InputLine.css';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
function InputLine(props) {
const {
className, overflow, styled, children, ...moreProps
} = props;
moreProps.className = classNames({
'input-line': true,
overflow,
styled,
}, className);
return (
{children}
);
}
InputLine.propTypes = {
className: PropTypes.string,
overflow: PropTypes.bool,
styled: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
export default InputLine;
================================================
FILE: src/client/Common/LeftRight.js
================================================
/* eslint-disable react/prop-types */
import React from 'react';
function LeftRight(props) {
return (
{props.children[0]}
{props.children[1]}
);
}
export default LeftRight;
================================================
FILE: src/client/Common/Link.js
================================================
import assert from 'assert';
import React from 'react';
import PropTypes from '../prop-types';
import Coordinator from './Coordinator';
function Link(props) {
const { logStructure, logTopic } = props;
assert(!(logStructure && logTopic));
const item = logStructure || logTopic;
assert(item);
let link;
try {
link = Coordinator.invoke('url-link', { details: item });
} catch (error) {
link = '#';
}
return (
{
event.preventDefault();
event.stopPropagation();
Coordinator.invoke('url-update', { details: item });
}}
>
{props.children}
);
}
Link.propTypes = {
logStructure: PropTypes.Custom.LogStructure,
logTopic: PropTypes.Custom.LogTopic,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
export default Link;
================================================
FILE: src/client/Common/ModalStack.js
================================================
import assert from 'assert';
import React from 'react';
import ConfirmModal from './ConfirmModal';
import Coordinator from './Coordinator';
import EditorModal from './EditorModal';
import ErrorModal from './ErrorModal';
import InfoModal from './InfoModal';
class ModalStack extends React.Component {
constructor(props) {
super(props);
this.state = {
components: [],
sourceElement: null,
};
}
componentDidMount() {
this.deregisterCallbacks = [
Coordinator.register(
'modal-editor',
(componentProps) => this.push(EditorModal, componentProps),
),
Coordinator.register('modal-confirm', this.push.bind(this, ConfirmModal)),
Coordinator.register('modal-error', (error) => this.push(ErrorModal, { error })),
Coordinator.register('modal-info', ({ title, message }) => this.push(InfoModal, { title, message })),
];
}
componentWillUnmount() {
this.deregisterCallbacks.forEach((deregisterCallback) => deregisterCallback());
}
push(ComponentClass, componentProps) {
const index = this.state.components.length;
this.setState((state) => {
if (index === 0) {
state.sourceElement = document.activeElement;
}
state.components.push({ ComponentClass, componentProps });
return state;
});
return this.pop.bind(this, index);
}
pop(index, callback) {
this.setState((state) => {
state.components.pop();
assert(index === state.components.length);
if (index === 0) {
state.sourceElement.focus();
state.sourceElement = null;
}
return state;
}, callback);
}
renderItem({ ComponentClass, componentProps }, index) {
return (
this.pop(index, () => {
if (componentProps.onClose) {
componentProps.onClose(...args);
}
})}
/>
);
}
render() {
return this.state.components.map((item, index) => this.renderItem(item, index));
}
}
export default ModalStack;
================================================
FILE: src/client/Common/Plugins.js
================================================
/* eslint-disable max-classes-per-file */
import React from 'react';
import { Enum } from '../../common/data_types';
import PropTypes from '../prop-types';
import SettingsContext from './SettingsContext';
export class PluginClient {
static getSettingsKey() {
// The key corresponding to the setting for your plugin.
// Must be unique across all plugins.
// Maybe infer this based on path?
throw new Error('not implemented');
}
static getSettingsComponent() {
// Return a React element that is rendered in the SettingsEditor.
// Props = { disabled: bool, value: any, onChange: function }
throw new Error('not implemented');
}
static getDisplayLocation() {
// A string that indicated where this component should be rendered.
// The various options can be found in Application.js
throw new Error('not implemented');
}
static getDisplayComponent() {
// Return a React element that is rendered in the application UI.
// Gets the "setting" as property.
throw new Error('not implemented');
}
static getTabData() {
// Return an object that contains data about an extra Tab.
// { value: string, label: string }
throw new Error('not implemented');
}
}
export const PluginDisplayLocation = Enum([
{
value: 'tab_section',
},
{
value: 'right_sidebar_main_top',
},
{
value: 'right_sidebar_main_bottom',
},
{
value: 'right_sidebar_widgets_top',
},
{
value: 'right_sidebar_widgets_bottom',
},
]);
export class PluginDisplayComponent extends React.Component {
renderActual(settings) {
const results = [];
Object.entries(this.props.plugins).forEach(([name, api]) => {
if (api.getDisplayLocation() !== this.props.location) {
return;
}
const key = api.getSettingsKey();
const props = {
settings: key ? settings[key] : null,
};
results.push({api.getDisplayComponent(props)}
);
});
return results;
}
render() {
return (
{(settings) => this.renderActual(settings)}
);
}
}
PluginDisplayComponent.propTypes = {
plugins: PropTypes.Custom.Plugins.isRequired,
location: PropTypes.string.isRequired,
};
================================================
FILE: src/client/Common/PopoverElement.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Popover from 'react-bootstrap/Popover';
import { MdClose } from 'react-icons/md';
import InputLine from './InputLine';
class PopoverElement extends React.Component {
renderOverlayTrigger() {
const overlay = (
{this.props.children[1]}
);
return (
{this.props.children[0]}
);
}
renderButton() {
return this.props.onReset(null)}> ;
}
render() {
return (
<>
{this.renderOverlayTrigger()}
{this.renderButton()}
>
);
}
}
PopoverElement.propTypes = {
onReset: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any.isRequired,
};
export default PopoverElement;
================================================
FILE: src/client/Common/ScrollableSection.css
================================================
.scrollable-section {
padding-right: 4px;
overflow-y: scroll;
}
.scrollable-section::-webkit-scrollbar {
width: 8px;
}
.scrollable-section::-webkit-scrollbar-track {
background: var(--background-color);
border-radius: 4px;
}
.scrollable-section::-webkit-scrollbar-thumb {
background-color: var(--component-color);
border-radius: 4px;
}
================================================
FILE: src/client/Common/ScrollableSection.js
================================================
/* eslint-disable max-classes-per-file */
import './ScrollableSection.css';
import PropTypes from 'prop-types';
import React from 'react';
class WindowHeightDetector {
static subscribe(callback) {
if (!WindowHeightDetector.instance) {
WindowHeightDetector.instance = new WindowHeightDetector();
}
const { instance } = WindowHeightDetector;
instance.callbacks.push(callback);
return instance.height;
}
constructor() {
this.callbacks = [];
this.height = window.innerHeight;
window.addEventListener('resize', this.onResize.bind(this));
}
onResize() {
this.height = window.innerHeight;
this.callbacks.forEach((callback) => callback(this.height));
}
}
class ScrollableSection extends React.Component {
constructor(props) {
super(props);
this.state = {
height: WindowHeightDetector.subscribe((height) => this.setState({ height })),
};
}
render() {
const height = this.state.height
- this.props.padding
- 32; // Why 32? 16px padding at top/bottom.
return (
{this.props.children}
);
}
}
ScrollableSection.propTypes = {
padding: PropTypes.number,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
ScrollableSection.defaultProps = {
padding: 0,
};
export default ScrollableSection;
================================================
FILE: src/client/Common/Selector.js
================================================
/* eslint-disable max-classes-per-file */
import PropTypes from 'prop-types';
import React from 'react';
import Form from 'react-bootstrap/Form';
class Selector extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
focus() {
this.ref.current.focus();
}
render() {
const { onChange, options, ...moreProps } = this.props;
return (
onChange(event.target.value)}
ref={this.ref}
>
{options.map((item) => {
const optionProps = { key: item.value, value: item.value };
return {item.label} ;
})}
);
}
}
Selector.propTypes = {
value: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
}),
).isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
Selector.getStringListOptions = (items) => items.map((item) => ({
label: item,
value: item,
}));
class BinarySelector extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
focus() {
this.ref.current.focus();
}
render() {
const {
noLabel, yesLabel, value, onChange, ...moreProps
} = this.props;
const options = [
{ label: noLabel, value: 'no' },
{ label: yesLabel, value: 'yes' },
];
return (
onChange(newValue === options[1].value)}
ref={this.ref}
/>
);
}
}
BinarySelector.propTypes = {
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
noLabel: PropTypes.string,
yesLabel: PropTypes.string,
};
BinarySelector.defaultProps = {
noLabel: 'No',
yesLabel: 'Yes',
};
Selector.Binary = BinarySelector;
export default Selector;
================================================
FILE: src/client/Common/SettingsContext.js
================================================
import React from 'react';
const SettingsContext = React.createContext({});
SettingsContext.Wrapper = (Component) => (moreProps) => (
{(settings) => }
);
export default SettingsContext;
================================================
FILE: src/client/Common/SidebarSection.css
================================================
.sidebar-section {
border: 1px solid var(--background-color);
border-radius: 4px;
padding: 3px 8px 5px;
margin: 4px 0;
}
.sidebar-section:hover {
border-color: var(--component-highlight-color);
}
.sidebar-section.selected {
background: var(--component-color);
border-color: var(--component-highlight-color);
}
.sidebar-section > .header {
color: var(--text-disabled-color);
}
.sidebar-section > .cursor {
cursor: pointer;
}
.sidebar-section > .separator {
border-bottom: 1px solid var(--component-highlight-color);
margin-bottom: 4px;
padding-bottom: 4px;
}
.sidebar-section > li {
width: 1000px;
}
.sidebar-section > li > a {
left: -8px;
position: relative;
}
================================================
FILE: src/client/Common/SidebarSection.js
================================================
import './SidebarSection.css';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { GoPrimitiveDot } from 'react-icons/go';
import { TiMinus, TiPlus } from 'react-icons/ti';
import Icon from './Icon';
import LeftRight from './LeftRight';
class SidebarSection extends React.Component {
constructor(props) {
super(props);
this.state = { isCollapsed: false };
}
renderHeader() {
if (!this.props.title) {
return null;
}
const { isCollapsed } = this.state;
return (
this.setState({ isCollapsed: !isCollapsed })}
>
{this.props.title}
{isCollapsed ? : }
);
}
renderChildren() {
if (this.state.isCollapsed) {
return null;
}
return (
{this.props.children}
);
}
render() {
const {
selected, title: _title, children: _children, ...moreProps
} = this.props;
return (
{this.renderHeader()}
{this.renderChildren()}
);
}
}
SidebarSection.propTypes = {
selected: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
title: PropTypes.any,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
SidebarSection.Item = function ({ children }) {
return (
{children}
);
};
SidebarSection.Item.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
export default SidebarSection;
================================================
FILE: src/client/Common/SortableList.css
================================================
button.sortableDragHandle.btn {
cursor: grab;
}
.sortableDraggedItem {
cursor: grab;
z-index: 10000;
}
================================================
FILE: src/client/Common/SortableList.js
================================================
import './SortableList.css';
import arrayMove from 'array-move';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import { GoTrashcan } from 'react-icons/go';
import { GrDrag } from 'react-icons/gr';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
const SortableDragHandle = SortableHandle((props) => (
));
const WrappedContainer = SortableContainer(({ children }) => {children}
);
const WrappedRow = SortableElement((props) => {
const disabled = props.wrappedRowDisabled;
const { children, ...otherProps } = props.originalElement.props;
return React.createElement(
props.originalElement.type,
otherProps,
[
,
...(children || []),
,
],
);
});
class SortableList extends React.Component {
constructor(props) {
super(props);
let { type } = props;
if (type.constructor) {
type = (innerProps) => React.createElement(props.type, innerProps);
}
this.state = { type };
}
onReorder({ oldIndex, newIndex }) {
this.props.onChange(arrayMove(this.props.items, oldIndex, newIndex));
}
onChange(index, item) {
const items = [...this.props.items];
items[index] = item;
this.props.onChange(items);
}
onDelete(index) {
const items = [...this.props.items];
items.splice(index, 1);
this.props.onChange(items);
}
renderRow(item, index) {
const {
items, itemsKey, onChange: _onChange, type: _type, disabled, ...moreProps
} = this.props;
return React.createElement(WrappedRow, {
key: item.__id__,
// Consumed by SortableElement
index,
disabled,
// Forwarded to the WrappedRow.
originalElement: this.state.type({
disabled,
onChange: (updatedItem) => this.onChange(index, updatedItem),
[itemsKey]: items,
index,
...moreProps,
}),
wrappedRowDisabled: disabled,
onDelete: () => this.onDelete(index),
});
}
render() {
return (
this.onReorder(data)}
>
{this.props.items.map((item, index) => this.renderRow(item, index))}
);
}
}
SortableList.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
items: PropTypes.arrayOf(PropTypes.any.isRequired).isRequired,
onChange: PropTypes.func.isRequired,
type: PropTypes.func.isRequired,
itemsKey: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
};
export default SortableList;
================================================
FILE: src/client/Common/StandardIcons.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { AiOutlineWarning } from 'react-icons/ai';
import { BiDetail } from 'react-icons/bi';
import { MdHelp, MdInfo } from 'react-icons/md';
function getIconWrapper(Component, color = 'var(--link-color)', style = { cursor: 'pointer' }) {
function IconWrapper(props) {
const { isShown, ...moreProps } = props;
if (!isShown) {
return null;
}
return (
);
}
IconWrapper.propTypes = {
isShown: PropTypes.bool.isRequired,
};
return IconWrapper;
}
export const WarningIcon = getIconWrapper(AiOutlineWarning, 'var(--warning-color)', { position: 'relative', top: -1 });
export const DetailsIcon = getIconWrapper(BiDetail);
export const HelpIcon = getIconWrapper(MdHelp);
export const InfoIcon = getIconWrapper(MdInfo);
================================================
FILE: src/client/Common/TextEditor.css
================================================
.public-DraftStyleDefault-ul,
.public-DraftStyleDefault-ol {
margin: 0;
}
.text-editor {
position: relative;
}
.text-editor.min-width .public-DraftEditor-content {
min-width: 100px;
}
.text-editor.styled {
background: var(--input-background-color);
color: var(--input-text-color);
}
.text-editor.styled .public-DraftEditor-content {
background: var(--input-background-color);
border-radius: 0 0.2rem 0.2rem 0;
color: var(--input-text);
min-height: 20px;
padding: 1px 4px 0;
}
.text-editor.styled.disabled .public-DraftEditor-content {
background: var(--input-disabled-background-color);
}
.text-editor.isSingleLine {
display: inline-block;
}
/* TextEditor Mention Suggestions + Dropdown Options (used by RBT Suggestions) */
.dropdown-menu,
.text-editor .mention-suggestions > div {
background-color: var(--component-color);
border: 1px solid var(--component-highlight-color);
box-shadow: 0px 4px 30px 0px var(--component-color);
font-family: var(--font-family);
font-size: var(--font-size);
min-width: 100px;
position: absolute;
z-index: 2;
}
.dropdown-item,
.text-editor .mention-suggestions > div > div {
color: var(--text-color);
padding: 1px 16px;
}
.text-editor .mention-suggestions > div > div > span {
font-size: inherit;
margin: 0;
overflow: inherit;
}
.dropdown-item.active,
.text-editor .mention-suggestions > div > div[aria-selected="true"] {
background-color: var(--suggestion-highlight-color);
}
================================================
FILE: src/client/Common/TextEditor.js
================================================
import 'draft-js/dist/Draft.css';
import './TextEditor.css';
import assert from 'assert';
import classNames from 'classnames';
import { RichUtils } from 'draft-js';
import createMarkdownShortcutsPlugin from 'draft-js-markdown-shortcuts-plugin';
import createMentionPlugin from 'draft-js-mention-plugin';
import Editor from 'draft-js-plugins-editor';
import PropTypes from 'prop-types';
import React from 'react';
import RichTextUtils from '../../common/RichTextUtils';
import AddLinkPlugin from './AddLinkPlugin';
import Link from './Link';
import TypeaheadOptions from './TypeaheadOptions';
import { KeyCodes } from './Utils';
function MentionComponent(props) {
return {props.children};
}
MentionComponent.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
mention: PropTypes.any.isRequired,
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any,
};
function OptionComponent(props) {
const {
isFocused: _isFocused, // eslint-disable-line react/prop-types
mention: item,
searchValue: _searchValue, // eslint-disable-line react/prop-types
theme: _theme, // eslint-disable-line react/prop-types
...moreProps
} = props;
return {item.name}
;
}
OptionComponent.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
mention: PropTypes.any.isRequired,
};
class TextEditor extends React.Component {
static getDerivedStateFromProps(props, state) {
if (state.onChange) {
return { onChange: false };
}
const isFirstTime = !('value' in state);
// WARNING: Even if props.value is equivalent to state.value, they might
// not be in the same format, and that could lead to an infinite loop!
if (isFirstTime || !RichTextUtils.equals(state.value, props.value)) {
return {
value: props.value,
editorState: RichTextUtils.toEditorState(props.value),
};
}
return null;
}
constructor(props) {
super(props);
this.state = {
suggestions: [],
open: false,
plugins: [],
};
this.state.plugins.push(AddLinkPlugin);
if (!this.props.isSingleLine) {
this.markdownShortcutsPlugin = createMarkdownShortcutsPlugin();
this.state.plugins.push(this.markdownShortcutsPlugin);
}
this.mentionPlugin = createMentionPlugin({
mentionComponent: MentionComponent,
supportWhitespace: true,
});
// Workaround for two bugs in draft-js-mention-plugin v3.x:
// 1. Deleting @ doesn't close the dropdown (early return skips closeDropdown).
// 2. Typing @ at a non-zero offset doesn't trigger search (> vs >=).
// Detection: onSearchChange fires synchronously inside pluginOnChange
// when the plugin finds an active search. If it didn't fire, either
// the search ended (clear suggestions) or was skipped (trigger manually).
const pluginOnChange = this.mentionPlugin.onChange;
this.mentionPlugin.onChange = (editorState) => {
this._searchFired = false;
const result = pluginOnChange(editorState);
if (!this._searchFired) {
if (this.state.open) {
this.setState({ suggestions: [] });
} else if (this.props.options) {
const text = editorState.getCurrentContent().getPlainText();
const offset = editorState.getSelection().getAnchorOffset();
if (offset > 0 && text.charAt(offset - 1) === '@') {
this.onSearchChange({ value: '' });
}
}
}
return result;
};
this.state.plugins.push(this.mentionPlugin);
this.ref = React.createRef();
}
handleKeyCommand(command, editorState) {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
this.onChange(newState);
return 'handled';
}
if (this.props.isSingleLine && command === 'split-block') {
return 'handled';
}
return 'not-handled';
}
onSearchChange({ value: query }) {
this._searchFired = true;
this.props.options
.search(query)
.then((suggestions) => this.setState({ suggestions }));
}
onAddMention(option) {
this.props.options
.select(option)
.then((result) => {
if (typeof result === 'undefined') return;
const selection = RichTextUtils.getSelectionData(this.state.editorState);
// Abstraction leak! Do not assume name.
const delta = result.name.length - option.name.length;
selection.anchorOffset += delta;
selection.focusOffset += delta;
let content = RichTextUtils.fromEditorState(this.state.editorState);
content = RichTextUtils.updateDraftContent(content, [option], [result || '']);
let editorState = RichTextUtils.toEditorState(content);
// TODO: Figure out why the cursor is not updated properly.
editorState = RichTextUtils.setSelectionData(editorState, selection);
this.onChange(editorState);
});
}
onChange(editorState) {
editorState = RichTextUtils.fixCursorBug(this.state.editorState, editorState);
this.setState({ editorState });
const newValue = RichTextUtils.fromEditorState(editorState);
if (this.props.onChange) {
this.setState(
{ onChange: true, value: newValue },
() => this.props.onChange(newValue),
);
}
}
focus() {
// Why the delay?
// This broke something inside the DraftJS Editor
// that caused mentions to not be rendered properly.
window.setTimeout(this.ref.current.focus, 0);
}
keyBindingFn(event) {
if (
this.props.isSingleLine
&& [KeyCodes.ESCAPE, KeyCodes.ENTER].includes(event.keyCode)
&& this.props.onSpecialKeys
) {
this.props.onSpecialKeys(event);
}
// https://github.com/draft-js-plugins/draft-js-plugins/issues/1117
// Do not invoke getDefaultKeyBinding here!
}
renderSuggestions() {
const { MentionSuggestions } = this.mentionPlugin;
const suggestions = this.state.suggestions
.map((item) => ({ id: `${item.__type__}:${item.__id__}`, ...item }));
return (
this.setState({ open: true })}
onClose={() => this.setState({ open: false })}
onSearchChange={(data) => this.onSearchChange(data)}
onAddMention={(option) => this.onAddMention(option)}
suggestions={suggestions}
entryComponent={OptionComponent}
/>
);
}
render() {
return (
this.keyBindingFn(event)}
handleKeyCommand={
(command, editorState) => this.handleKeyCommand(command, editorState)
}
plugins={this.state.plugins}
onChange={(editorState) => this.onChange(editorState)}
placeholder={this.props.placeholder}
ref={this.ref}
/>
{this.props.disabled ? null : this.renderSuggestions()}
);
}
}
TextEditor.propTypes = {
unstyled: PropTypes.bool,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
minWidth: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.object,
onChange: PropTypes.func,
isSingleLine: PropTypes.bool,
onSpecialKeys: PropTypes.func,
options: PropTypes.instanceOf(TypeaheadOptions),
};
TextEditor.defaultProps = {
unstyled: false,
disabled: false,
isSingleLine: false,
minWidth: false,
};
/**
* The primary component generates an inline block that does not look great on
* the main page. This alternative implementation generates an inline span.
*/
TextEditor.SimpleViewer = function (props) {
if (!props.value) {
return null;
}
const rawContent = props.value;
assert(rawContent.blocks.length === 1);
const { text } = rawContent.blocks[0];
let textIndex = 0;
const entityRanges = Object.values(rawContent.blocks[0].entityRanges);
let entityRangeIndex = 0;
const parts = [];
while (textIndex < text.length) {
const entityRange = entityRanges[entityRangeIndex];
let part;
if (entityRange && entityRange.offset === textIndex) {
const key = `entity-${entityRangeIndex}`;
entityRangeIndex += 1;
const entity = rawContent.entityMap[entityRange.key];
const endIndex = textIndex + entityRange.length;
const textPart = text.slice(textIndex, endIndex);
textIndex = endIndex;
if (entity.type === 'mention') {
part = (
{textPart}
);
} else if (entity.type === 'LINK') {
part = (
{textPart}
);
} else {
assert(false, `unknown entity type: ${entity.type}`);
}
} else {
const endIndex = entityRange ? entityRange.offset : text.length;
const textPart = text.slice(textIndex, endIndex);
textIndex = endIndex;
part = textPart;
}
parts.push(part);
}
return {parts} ;
};
TextEditor.SimpleViewer.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
};
export default TextEditor;
================================================
FILE: src/client/Common/TextInput.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Form from 'react-bootstrap/Form';
class TextInput extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
focus() {
this.ref.current.focus();
}
render() {
const {
value, disabled, onChange, ...moreProps
} = this.props;
return (
onChange(event.target.value)}
ref={this.ref}
{...moreProps}
/>
);
}
}
TextInput.propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default TextInput;
================================================
FILE: src/client/Common/TooltipElement.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Tooltip from 'react-bootstrap/Tooltip';
function TooltipElement(props) {
const overlay = (
{props.children[1]}
);
return (
{props.children[0]}
);
}
TooltipElement.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
children: PropTypes.any.isRequired,
};
export default TooltipElement;
================================================
FILE: src/client/Common/TypeaheadInput.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { AsyncTypeahead } from 'react-bootstrap-typeahead';
class TypeaheadInput extends React.Component {
constructor(props) {
super(props);
this.state = { isLoading: false, options: [] };
this.ref = React.createRef();
}
onSearch(query) {
this.setState({ isLoading: true }, () => {
this.props.onSearch(query)
.then((options) => this.setState({ isLoading: false, options }));
});
}
focus() {
this.ref.current.focus();
}
render() {
return (
this.onSearch(this.props.value)}
onSearch={(query) => this.onSearch(query)}
filterBy={this.props.filterBy}
placeholder={this.props.placeholder}
selected={[this.props.value]}
onInputChange={(newValue) => {
this.onSearch(newValue);
this.props.onChange(newValue);
}}
onChange={(newSelected) => {
if (newSelected.length) {
this.props.onChange(newSelected[0]);
}
}}
renderMenuItemChildren={(option) => {option}
}
isLoading={this.state.isLoading}
options={this.state.options}
ref={this.ref}
/>
);
}
}
TypeaheadInput.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
placeholder: PropTypes.string,
filterBy: PropTypes.func,
};
export default TypeaheadInput;
================================================
FILE: src/client/Common/TypeaheadOptions.js
================================================
import assert from 'assert';
class TypeaheadOptions {
static getFromTypes(names) {
return new TypeaheadOptions({
serverSideOptions: names.map((name) => ({ name })),
});
}
constructor(config) {
assert(Array.isArray(config.serverSideOptions));
if (!config.prefixOptions) {
config.prefixOptions = [];
}
if (!config.suffixOptions) {
config.suffixOptions = [];
}
if (!config.getComputedOptions) {
config.getComputedOptions = async () => [];
}
if (!config.computedOptionTypes) {
config.computedOptionTypes = [];
}
if (!config.allowMultipleItems) {
config.allowMultipleItems = {};
config.allowMultipleItems['log-topic'] = true;
}
this.config = config;
}
async search(query, existingItems) {
const skipTypes = {};
if (existingItems) {
// When existing items are provided, we can check to see which types
// have already been selected, and exclude them from the results.
existingItems.forEach((item) => {
if (!this.config.allowMultipleItems[item.__type__]) {
skipTypes[item.__type__] = true;
}
});
}
// Server-side filtering invokes case insensitive LIKE `${query}%`.
let options = await Promise.all(
this.config.serverSideOptions
.filter((item) => !skipTypes[item.name])
.map((item) => window.api.send(
`${item.name}-typeahead`,
{ query, where: item.where },
)),
);
options = options.flat();
const doesMatchQuery = (item) => item.name.toLowerCase().startsWith(query.toLowerCase());
options = [
...this.config.prefixOptions
.filter((item) => !skipTypes[item.__type__])
.filter(doesMatchQuery),
...options,
...this.config.suffixOptions
.filter((item) => !skipTypes[item.__type__])
.filter(doesMatchQuery),
];
if (this.config.serverSideOptions.length > 1) {
const seenOptionIds = new Set();
// Since option.__id__ is used as a React Array Key, adjust it.
// Do this only if needed to minimize later adjustment.
options.forEach((option) => {
if (seenOptionIds.has(option.__id__)) {
option.__original_id__ = option.__id__;
option.__id__ = `${option.__type__}:${option.__id__}`;
} else {
seenOptionIds.add(option.__id__);
}
});
}
const computedOptions = await this.config.getComputedOptions(query);
options.push(...computedOptions);
// TODO: Maybe prefix type name, before item name, for clarity.
return options;
}
async select(option) {
let adjusted = false;
if (option.__original_id__) {
option.__id__ = option.__original_id__;
delete option.__original_id__;
adjusted = true;
}
if (this.config.onSelect) {
const result = await this.config.onSelect(option);
// undefined = no change
// null = cancel operation
if (typeof result === 'object') {
option = result;
adjusted = true;
}
}
return adjusted ? option : undefined;
}
// This method is used while switching between different tabs,
// in an attempt to retain as many search filters as possible.
filterToKnownTypes(items) {
const knownTypes = new Set([
...this.config.serverSideOptions.map((option) => option.name),
...this.config.prefixOptions.map((option) => option.__type__),
...this.config.suffixOptions.map((option) => option.__type__),
...this.config.computedOptionTypes,
]);
return items.filter((item) => knownTypes.has(item.__type__));
}
}
export default TypeaheadOptions;
================================================
FILE: src/client/Common/TypeaheadSelector.css
================================================
.rbt input:first-child {
background-color: var(--input-background-color);
color: var(--input-text-color);
font-size: var(--font-size);
}
.rbt .rbt-input-multi {
background-color: var(--input-background-color);
border: none;
border-radius: 0;
padding: 1px 2px;
}
.rbt .rbt-input-multi .rbt-token {
background-color: var(--input-background-token-color);
color: var(--input-text-color);
font-size: var(--font-size);
margin: 3px 1px 0;
padding-bottom: 1px;
padding-top: 1px;
}
.rbt .rbt-input-multi input:first-child {
margin-bottom: 2px;
margin-left: 2px;
}
================================================
FILE: src/client/Common/TypeaheadSelector.js
================================================
import 'react-bootstrap-typeahead/css/Typeahead.min.css';
import './TypeaheadSelector.css';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import { AsyncTypeahead } from 'react-bootstrap-typeahead';
import { MdClose } from 'react-icons/md';
import TypeaheadOptions from './TypeaheadOptions';
class TypeaheadSelector extends React.Component {
constructor(props) {
super(props);
this.state = { isLoading: false, text: '', options: [] };
this.ref = React.createRef();
}
onInputChange(text) {
this.setState({ text });
this.onSearch(text);
}
onSearch(query) {
this.setState({ isLoading: true });
let existingItems = [];
if (this.props.multiple) {
existingItems = this.props.value;
} else {
existingItems = (this.props.value ? [this.props.value] : []);
}
this.props.options
.search(query, existingItems)
.then((options) => this.setState({ isLoading: false, options }, this.forceUpdate));
}
async onChange(selected) {
if (selected.length) {
const index = selected.length - 1;
const result = await this.props.options.select(selected[index]);
if (result) {
selected[index] = result;
} else if (result === null) {
return;
}
}
if (this.props.multiple) {
this.props.onChange(selected);
} else {
this.props.onChange(selected[0] || null);
}
}
focus() {
this.ref.current.focus();
}
renderDeleteButton() {
if (this.props.disabled || !this.props.value) {
return null;
}
return (
this.props.onChange(null)}
title="Cancel"
>
);
}
render() {
const commonProps = {
...this.state,
id: this.props.id,
labelKey: 'name',
minLength: 0,
onFocus: () => this.onSearch(this.state.text),
onSearch: (query) => this.onSearch(query),
placeholder: this.props.placeholder,
onInputChange: (text) => this.onInputChange(text),
onChange: (selected) => this.onChange(selected),
filterBy: (option, props) => true,
renderMenuItemChildren: (option) => {option.name}
,
ref: this.ref,
};
if (this.props.multiple) {
return (
);
}
return (
<>
{this.renderDeleteButton()}
>
);
}
}
TypeaheadSelector.propTypes = {
id: PropTypes.string.isRequired,
multiple: PropTypes.bool,
options: PropTypes.instanceOf(TypeaheadOptions).isRequired,
value: PropTypes.oneOfType([
// eslint-disable-next-line react/no-typos
PropTypes.Custom.Item,
PropTypes.arrayOf(PropTypes.Custom.Item),
]),
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
};
TypeaheadSelector.defaultProps = {
multiple: false,
};
TypeaheadSelector.getStringItem = (value, index = -1) => ({
__type__: 'string',
__id__: index + 1,
name: value,
});
TypeaheadSelector.getStringListItems = (values) => {
if (!values) return [];
return values.map(TypeaheadSelector.getStringItem);
};
TypeaheadSelector.getStringListTypeaheadOptions = (fetcher) => new TypeaheadOptions({
serverSideOptions: [],
getComputedOptions: async (query) => {
// Maybe skip fetching results if query is empty?
let options = [];
if (fetcher) {
options = await fetcher(query);
}
options = TypeaheadSelector.getStringListItems(options);
options.push(TypeaheadSelector.getStringItem(query));
return options;
},
});
export default TypeaheadSelector;
================================================
FILE: src/client/Common/URLManager.js
================================================
import assert from 'assert';
import queryString from 'query-string';
let onChange;
let pushState;
const options = { arrayFormat: 'bracket' };
class URLManager {
static init(callback) {
assert(!onChange, 'URLManager already initialized');
onChange = callback;
pushState = window.history.pushState;
window.history.pushState = (...args) => {
pushState.apply(window.history, args);
onChange();
};
return () => {
window.history.pushState = pushState;
pushState = null;
onChange = null;
};
}
static get() {
return queryString.parse(window.location.search, options);
}
static getLink(params) {
return `?${queryString.stringify(params, options).replace(/%20/g, '+')}`;
}
static update(link) {
window.history.pushState({}, '', link);
}
}
export default URLManager;
================================================
FILE: src/client/Common/Utils.js
================================================
export function suppressUnlessShiftKey(event) {
if (!event.shiftKey) {
event.preventDefault();
}
}
// https://davidwalsh.name/javascript-debounce-function
export function debounce(func, wait, immediate) {
let timeout;
return function inner(...args) {
const context = this;
const later = () => {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
export const KeyCodes = {
DELETE: 8,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
UP_ARROW: 38,
DOWN_ARROW: 40,
};
================================================
FILE: src/client/Common/index.js
================================================
export { default as AsyncSelector } from './AsyncSelector';
export { default as BulletList } from './BulletList';
export { default as Coordinator } from './Coordinator';
export { default as DataLoader } from './DataLoader';
export { default as DateContext } from './DateContext';
export { default as DatePicker } from './DatePicker';
export { default as DateRangePicker } from './DateRangePicker';
export { default as Dropdown } from './Dropdown';
export { default as EditorModal } from './EditorModal';
export { default as EnumSelectorSection } from './EnumSelectorSection';
export { default as ErrorModal } from './ErrorModal';
export { default as Highlightable } from './Highlightable';
export { default as Icon } from './Icon';
export { default as InfoModal } from './InfoModal';
export { default as InputLine } from './InputLine';
export { default as LeftRight } from './LeftRight';
export { default as Link } from './Link';
export { default as ModalStack } from './ModalStack';
export { default as ScrollableSection } from './ScrollableSection';
export { default as Selector } from './Selector';
export { default as SettingsContext } from './SettingsContext';
export { default as SidebarSection } from './SidebarSection';
export { default as SortableList } from './SortableList';
export { default as TextInput } from './TextInput';
export { default as TextEditor } from './TextEditor';
export { default as TooltipElement } from './TooltipElement';
export { default as TypeaheadInput } from './TypeaheadInput';
export { default as TypeaheadOptions } from './TypeaheadOptions';
export { default as TypeaheadSelector } from './TypeaheadSelector';
export { default as URLManager } from './URLManager';
export * from './Plugins';
export * from './StandardIcons';
export * from './Utils';
================================================
FILE: src/client/Graphs/GraphLineChart.js
================================================
import React from 'react';
import {
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import PropTypes from '../prop-types';
function GraphLineChart(props) {
const lineElements = props.lines.map((lineItem) => (
));
return (
{props.tooltip ? : null}
{lineElements}
);
}
const LineItem = PropTypes.shape({
name: PropTypes.string.isRequired,
dataKey: PropTypes.string.isRequired,
});
GraphLineChart.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
samples: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
lines: PropTypes.arrayOf(LineItem.isRequired).isRequired,
tooltip: PropTypes.func,
};
export default GraphLineChart;
================================================
FILE: src/client/Graphs/GraphSection.css
================================================
.graph-tooltip {
background-color: var(--component-color);
padding: 4px;
border-radius: 4px;
white-space: pre-wrap;
}
================================================
FILE: src/client/Graphs/GraphSection.js
================================================
import './GraphSection.css';
import deepEqual from 'deep-equal';
import React from 'react';
import { DataLoader } from '../Common';
import PropTypes from '../prop-types';
import GraphLineChart from './GraphLineChart';
import { getGraphData } from './GraphSectionData';
import GraphSectionOptions, { Granularity } from './GraphSectionOptions';
import { NormalTooltip } from './GraphTooltip';
class GraphSection extends React.Component {
static getTypeaheadOptions() {
return GraphSectionOptions.get();
}
static getDerivedStateFromProps(props, state) {
const result = GraphSectionOptions.extractData(props.search);
result.where.date = props.dateRange || undefined;
const newGranularity = result.extra.granularity || Granularity.WEEK;
if (!deepEqual(state.where, result.where)) {
state.reload = true;
}
state.where = result.where;
state.hasAnyFilters = props.search.length > 0;
if (state.granularity !== newGranularity && state.logEvents) {
state.graphData = getGraphData(
state.where.logStructure,
state.logEvents,
props.dateRange,
newGranularity,
);
}
state.granularity = newGranularity;
return state;
}
constructor(props) {
super(props);
this.state = { graphData: null };
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => {
const { hasAnyFilters, where } = this.state;
if (!hasAnyFilters) {
return null;
}
return {
name: 'log-event-list',
args: {
where: {
...where,
date: this.props.dateRange || undefined,
},
},
};
},
onData: (logEvents) => {
const { dateRange } = this.props;
const { where, granularity } = this.state;
const graphData = getGraphData(
where.logStructure,
logEvents,
dateRange,
granularity,
);
this.setState({ logEvents, graphData });
},
});
}
componentDidUpdate(prevProps) {
if (this.state.reload) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ reload: false });
this.dataLoader.reload();
}
}
componentWillUnmount() {
this.dataLoader.stop();
}
render() {
const { hasAnyFilters, graphData } = this.state;
if (!hasAnyFilters) {
return 'Please add some filters!';
} if (!graphData) {
return 'Loading ...';
} if (graphData.isEmpty) {
return 'No data!';
}
return graphData.lines.map((line) => {
const lines = [{
name: line.name,
dataKey: line.valueKey,
}];
return (
);
});
}
}
GraphSection.propTypes = {
dateRange: PropTypes.Custom.DateRange,
search: PropTypes.arrayOf(PropTypes.Custom.Item.isRequired).isRequired,
};
export default GraphSection;
================================================
FILE: src/client/Graphs/GraphSectionData.js
================================================
import { addDays, compareAsc } from 'date-fns';
import { LogKey } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
import RichTextUtils from '../../common/RichTextUtils';
import { Granularity } from './GraphSectionOptions';
function getLogKeyValues(keyIndex, valueParser, logEvents) {
return logEvents.map((logEvent) => {
const logKey = logEvent.logStructure.eventKeys[keyIndex];
if (logKey.value === null) {
return null;
}
return valueParser(logKey.value);
}).filter((value) => value !== null);
}
function getAverageValue(values) {
if (values.length === 0) {
return null;
} if (values.length === 1) {
return values[0];
}
// logEvents[0].logStructure.eventKeys[keyIndex].aggregationType?
return values.reduce((result, value) => (result + value), 0) / values.length;
}
function getLines(logStructure, logEvent) {
const lines = [];
lines.push({
valueKey: 'event_count',
valuesKey: 'event_count_values',
name: 'Event Count',
getValues: (logEvents) => [logEvents.length],
});
if (logStructure) {
logEvent.logStructure.eventKeys.forEach((logKey, index) => {
let valueParser;
if (logKey.type === LogKey.Type.INTEGER) {
valueParser = parseInt;
} else if (logKey.type === LogKey.Type.NUMBER) {
valueParser = parseFloat;
} else if (logKey.type === LogKey.Type.TIME) {
valueParser = (value) => parseInt(value.replace(':', ''), 10);
} else {
return;
}
lines.push({
valueKey: `key_${logKey.__id__}_value`,
valuesKey: `key_${logKey.__id__}_values`,
name: `Key: ${logKey.name}`,
getValues: getLogKeyValues.bind(null, index, valueParser),
});
});
}
// TODO: Add support for custom graphs?
return lines;
}
function getTimeSeries(logEvents, lines, dateRange, granularity) {
if (logEvents.length === 0) {
return [];
}
const dateLabelToLogEvents = {};
logEvents.forEach((item) => {
if (!item.date) {
return;
}
if (!(item.date in dateLabelToLogEvents)) {
dateLabelToLogEvents[item.date] = [];
}
dateLabelToLogEvents[item.date].push(item);
});
let startDate;
let endDate;
if (dateRange) {
startDate = DateUtils.getDate(dateRange.startDate);
endDate = DateUtils.getDate(dateRange.endDate);
} else {
const dateLabels = Object.keys(dateLabelToLogEvents).sort();
startDate = DateUtils.getDate(dateLabels[0]);
endDate = DateUtils.getDate(dateLabels[dateLabels.length - 1]);
}
const samples = [];
for (
let currentDate = startDate;
compareAsc(currentDate, endDate) <= 0;
) {
const currentLogEvents = [];
let label = null;
// eslint-disable-next-line no-constant-condition
while (true) {
const nextLabel = Granularity[granularity].getLabel(currentDate);
if (label === null) {
label = nextLabel;
} else if (label === nextLabel) {
// nothing changes
} else {
break; // move to next group
}
const dateLabel = DateUtils.getLabel(currentDate);
const nextLogEvents = dateLabelToLogEvents[dateLabel] || [];
currentLogEvents.push(...nextLogEvents);
currentDate = addDays(currentDate, 1);
}
const sample = { label };
lines.forEach((line) => {
const values = line.getValues(currentLogEvents);
sample[line.valuesKey] = values;
sample[line.valueKey] = getAverageValue(values);
});
sample.logEventTitles = currentLogEvents.map(
(logEvent) => `${logEvent.date}: ${RichTextUtils.extractPlainText(logEvent.title)}`,
);
samples.push(sample);
}
return samples;
}
// eslint-disable-next-line import/prefer-default-export
export function getGraphData(logStructure, logEvents, dateRange, granularity) {
if (!logEvents || !logEvents.length) return { isEmpty: true };
try {
const lines = getLines(logStructure, logEvents[0]);
const samples = getTimeSeries(logEvents, lines, dateRange, granularity);
return { lines, samples };
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
throw error;
}
}
================================================
FILE: src/client/Graphs/GraphSectionOptions.js
================================================
import {
getDay, getMonth, getYear, subDays,
} from 'date-fns';
import { Enum } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
import { LogEventOptions } from '../LogEvent';
const Granularity = Enum([
{
label: 'Day',
value: 'day',
getLabel: (date) => DateUtils.getLabel(date),
},
{
label: 'Week',
value: 'week',
getLabel: (date) => {
const dayOfWeek = getDay(date);
const startDateOfWeek = subDays(date, dayOfWeek);
return DateUtils.getLabel(startDateOfWeek);
},
},
{
label: 'Month',
value: 'month',
getLabel: (date) => {
let month = (getMonth(date) + 1).toString();
month = (month.length === 1 ? '0' : '') + month;
return `${getYear(date)}-${month}`;
},
},
]);
const GRANULARITY_TYPE = 'graph-granularity';
const GRANULARITY_PREFIX = 'Granularity: ';
const GRANULARITY_OPTIONS = Granularity.Options.map((option, index) => ({
__type__: GRANULARITY_TYPE,
__id__: -index - 1,
name: GRANULARITY_PREFIX + option.label,
}));
const GRANULARITY_MOCK_OPTION = {
__type__: GRANULARITY_TYPE,
apply: (item, _where, extra) => {
extra.granularity = item.name.substr(GRANULARITY_PREFIX.length).toLowerCase();
},
};
class GraphSectionOptions {
static get() {
return LogEventOptions.get(GRANULARITY_OPTIONS);
}
static extractData(items) {
const result = LogEventOptions.extractData(
items,
LogEventOptions.getTypeToActionMap([GRANULARITY_MOCK_OPTION]),
);
return result;
}
}
export { Granularity };
export default GraphSectionOptions;
================================================
FILE: src/client/Graphs/GraphTooltip.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
function NormalTooltip({ active, label, payload }) {
if (active && payload && payload.length) {
const item = payload[0];
const output = [];
output.push(`Group: ${label}`);
output.push(`${item.name}: ${item.payload[item.dataKey]}`);
const { logEventTitles } = item.payload;
if (logEventTitles.length) {
output.push('', ...logEventTitles);
}
return (
{output.map((line) => `${line}\n`).join('')}
);
}
return null;
}
NormalTooltip.propTypes = {
active: PropTypes.bool,
label: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
payload: PropTypes.any,
};
// eslint-disable-next-line import/prefer-default-export
export { NormalTooltip };
================================================
FILE: src/client/Graphs/index.js
================================================
export { default as GraphSection } from './GraphSection';
export { default as GraphLineChart } from './GraphLineChart';
export { Granularity } from './GraphSectionOptions';
export { getGraphData } from './GraphSectionData';
================================================
FILE: src/client/LogEvent/LogEventAdder.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { isRealItem, LogEvent } from '../../common/data_types';
import {
Coordinator, KeyCodes, TextEditor, TypeaheadOptions,
} from '../Common';
import LogEventEditor from './LogEventEditor';
class LogEventAdder extends React.Component {
constructor(props) {
super(props);
this.state = {
logEvent: LogEvent.createVirtual(this.props.where),
};
}
onEditLogEvent(logEvent) {
this.setState({ logEvent: LogEvent.createVirtual(this.props.where) });
Coordinator.invoke('modal-editor', {
dataType: 'log-event',
EditorComponent: LogEventEditor,
valueKey: 'logEvent',
value: logEvent,
});
}
onSaveLogEvent(logEvent) {
if (logEvent.title) {
window.api.send('log-event-upsert', logEvent)
.then((_newLogEvent) => {
// The new LogEvent would have been added to list, so we can reset this.
this.setState({ logEvent: LogEvent.createVirtual(this.props.where) });
});
} else {
this.onEditLogEvent(logEvent);
}
}
async onSelect(option) {
if (option.__type__ === 'log-structure') {
const logStructure = await window.api.send('log-structure-load', option);
const updatedLogEvent = LogEvent.createVirtual({
...this.props.where,
logStructure,
});
LogEvent.trigger(updatedLogEvent);
if (logStructure.eventNeedsEdit) {
this.onEditLogEvent(updatedLogEvent);
} else {
this.onSaveLogEvent(updatedLogEvent);
}
}
}
render() {
const { logEvent } = this.state;
return (
this.onSelect(option),
})}
disabled={isRealItem(logEvent.logStructure)}
onChange={(value) => {
const updatedLogEvent = { ...logEvent };
updatedLogEvent.title = value;
LogEvent.trigger(updatedLogEvent);
this.setState({ logEvent: updatedLogEvent });
}}
onSpecialKeys={(event) => {
if (event.keyCode === KeyCodes.ENTER) {
this.onSaveLogEvent(logEvent);
}
}}
{...this.props}
/>
);
}
}
LogEventAdder.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
where: PropTypes.object,
};
LogEventAdder.defaultProps = {
where: {},
};
export default LogEventAdder;
================================================
FILE: src/client/LogEvent/LogEventDetailsHeader.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import {
InputLine, TextEditor,
} from '../Common';
class LogEventDetailsHeader extends React.Component {
renderTitle() {
const { logEvent } = this.props;
return (
);
}
render() {
return this.renderTitle();
}
}
LogEventDetailsHeader.propTypes = {
logEvent: PropTypes.Custom.LogEvent.isRequired,
};
export default LogEventDetailsHeader;
================================================
FILE: src/client/LogEvent/LogEventEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { LogEvent } from '../../common/data_types';
import {
DatePicker, Selector, TextEditor, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import { LogValueListEditor } from '../LogKey';
const { LogLevel } = LogEvent;
class LogEventEditor extends React.Component {
constructor(props) {
super(props);
this.titleRef = React.createRef();
this.detailsRef = React.createRef();
this.valueListRef = React.createRef();
}
componentDidMount() {
const { logEvent } = this.props;
if (logEvent.logStructure) {
if (logEvent.logStructure.eventKeys.length) {
this.valueListRef.current.focus();
} else {
this.detailsRef.current.focus();
}
} else {
this.titleRef.current.focus();
}
}
updateLogEvent(methodOrName, maybeValue) {
const updatedLogEvent = { ...this.props.logEvent };
if (typeof methodOrName === 'function') {
methodOrName(updatedLogEvent);
} else {
updatedLogEvent[methodOrName] = maybeValue;
}
LogEvent.trigger(updatedLogEvent);
this.props.onChange(updatedLogEvent);
}
renderDate() {
return (
{this.props.logEvent.isComplete ? 'Date' : 'Deadline Date'}
this.updateLogEvent('date', date)}
/>
);
}
renderIsComplete() {
return (
Complete?
this.updateLogEvent('isComplete', isComplete)}
/>
);
}
renderTitle() {
return (
Title
{
if (option.__type__ === 'log-structure') {
const logStructure = await window.api.send('log-structure-load', option);
this.updateLogEvent('logStructure', logStructure);
}
},
})}
disabled={this.props.disabled || !!this.props.logEvent.logStructure}
onChange={(title) => this.updateLogEvent('title', title)}
onSpecialKeys={this.props.onSpecialKeys}
ref={this.titleRef}
/>
);
}
renderDetails() {
const { logEvent } = this.props;
const eventAllowDetails = logEvent.logStructure
? logEvent.logStructure.eventAllowDetails
: true;
return (
Details
this.updateLogEvent('details', details)}
ref={this.detailsRef}
/>
);
}
renderLogLevel() {
return (
Log Level
this.updateLogEvent('logLevel', LogLevel.getIndex(value))}
/>
);
}
renderStructureSelector() {
return (
Structure
window.api.send('log-structure-load', option),
})}
value={this.props.logEvent.logStructure}
disabled={this.props.disabled}
onChange={(logStructure) => this.updateLogEvent((updatedLogEvent) => {
updatedLogEvent.logStructure = logStructure;
if (logStructure) {
LogEvent.addDefaultStructureValues(updatedLogEvent);
} else {
updatedLogEvent.title = null;
}
})}
allowDelete
/>
);
}
renderStructureValues() {
const { logEvent } = this.props;
if (!logEvent.logStructure || logEvent.logStructure.eventKeys.length === 0) {
return null;
}
return (
this.updateLogEvent((updatedLogEvent) => {
updatedLogEvent.logStructure.eventKeys = updatedLogKeys;
})}
ref={this.valueListRef}
/>
);
}
render() {
return (
{this.renderDate()}
{this.renderIsComplete()}
{this.renderTitle()}
{this.renderDetails()}
{this.renderLogLevel()}
{this.renderStructureSelector()}
{this.renderStructureValues()}
);
}
}
LogEventEditor.propTypes = {
logEvent: PropTypes.Custom.LogEvent.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onSpecialKeys: PropTypes.func,
};
export default LogEventEditor;
================================================
FILE: src/client/LogEvent/LogEventList.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { BulletList, DetailsIcon, TextEditor } from '../Common';
import LogEventAdder from './LogEventAdder';
import LogEventEditor from './LogEventEditor';
function LogEventViewer(props) {
const { logEvent } = props;
let datePrefix;
if (props.displayDate) {
datePrefix = (
{`${logEvent.date}: `}
);
}
const title = (
);
let detailsSuffix;
if (logEvent.details) {
detailsSuffix = (
);
}
let logLevelSuffix;
if (props.displayLogLevel) {
logLevelSuffix = (
{`L${logEvent.logLevel}`}
);
}
return (
{datePrefix}
{logLevelSuffix}
{title}
{detailsSuffix}
);
}
LogEventViewer.propTypes = {
logEvent: PropTypes.Custom.LogEvent.isRequired,
displayDate: PropTypes.bool,
displayLogLevel: PropTypes.bool,
toggleExpansion: PropTypes.func.isRequired,
};
LogEventViewer.Expanded = function (props) {
const { logEvent } = props;
if (!logEvent.details) {
return null;
}
return (
);
};
LogEventViewer.Expanded.propTypes = {
logEvent: PropTypes.Custom.LogEvent.isRequired,
};
function LogEventList(props) {
const { showAdder, ...moreProps } = props;
return (
);
}
LogEventList.propTypes = {
name: PropTypes.string.isRequired,
showAdder: PropTypes.bool,
};
LogEventList.Single = function (props) {
const { logEvent, ...moreProps } = props;
return (
);
};
LogEventList.Single.propTypes = {
logEvent: PropTypes.Custom.LogEvent.isRequired,
};
export default LogEventList;
================================================
FILE: src/client/LogEvent/LogEventOptions.js
================================================
import assert from 'assert';
import { getVirtualID } from '../../common/data_types';
import { TypeaheadOptions } from '../Common';
const NO_STRUCTURE_ITEM = {
__type__: 'log-structure',
__id__: 0,
name: 'No Structure',
};
const EVENT_TITLE_ITEM_TYPE = 'log-event-title';
const EVENT_TITLE_ITEM_PREFIX = 'Title: ';
class LogEventOptions {
static get(prefixOptions) {
prefixOptions = [...prefixOptions, NO_STRUCTURE_ITEM];
return new TypeaheadOptions({
serverSideOptions: [
{ name: 'log-topic' },
{ name: 'log-structure' },
],
prefixOptions,
computedOptionTypes: [EVENT_TITLE_ITEM_TYPE],
getComputedOptions: async (query) => {
const options = [];
if (query) {
options.push({
__type__: EVENT_TITLE_ITEM_TYPE,
__id__: getVirtualID(),
name: EVENT_TITLE_ITEM_PREFIX + query,
});
}
return options;
},
onSelect: (option) => {
if (option && option.getItem) {
return option.getItem(option);
}
return undefined;
},
});
}
static getTypeToActionMap(extraOptions) {
const result = {
'log-structure': (item, where, extra) => {
// This also handles NO_STRUCTURE_ITEM.
assert(!Object.prototype.hasOwnProperty.call(where, 'logStructure'));
where.logStructure = item.__id__ ? item : null;
extra.searchView = true;
},
'log-topic': (item, where, extra) => {
if (!where.logTopics) {
where.logTopics = [];
}
where.logTopics.push(item);
extra.searchView = true;
},
[EVENT_TITLE_ITEM_TYPE]: (item, where, extra) => {
where.title = item.name.substring(EVENT_TITLE_ITEM_PREFIX.length);
extra.searchView = true;
},
};
if (extraOptions) {
extraOptions.forEach((item) => {
assert(typeof item.apply === 'function', `Missing apply method on ${item}`);
result[item.__type__] = item.apply;
});
}
return result;
}
static extractData(items, typeToActionMap, defaultWhere) {
const where = { ...defaultWhere };
const extra = {};
items.forEach((item) => {
const action = typeToActionMap[item.__type__];
if (action) {
action(item, where, extra);
} else {
assert(false, `Unable to process ${JSON.stringify(item)}`);
}
});
return { where, extra };
}
}
export default LogEventOptions;
================================================
FILE: src/client/LogEvent/LogEventSearch.js
================================================
import assert from 'assert';
import { addDays, eachDayOfInterval, getDay } from 'date-fns';
import React from 'react';
import { getVirtualID, LogEvent } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
import { Coordinator, DateContext, SettingsContext } from '../Common';
import PropTypes from '../prop-types';
import LogEventEditor from './LogEventEditor';
import LogEventList from './LogEventList';
import LogEventOptions from './LogEventOptions';
// Extra Filters for Events
const INCOMPLETE_ITEM = {
__type__: 'incomplete',
__id__: getVirtualID(),
name: 'Incomplete Events',
apply: (_item, where, _extra) => {
where.isComplete = false;
},
};
const LOG_LEVEL_MINOR_ITEM = {
__type__: 'log-event-level',
__id__: getVirtualID(),
name: 'Log Level: Minor+',
};
const LOG_LEVEL_MAJOR_ITEM = {
__type__: 'log-event-level',
__id__: getVirtualID(),
name: 'Log Level: Major+',
};
const LOG_LEVEL_MOCK_ITEM = {
__type__: 'log-event-level',
apply: (item, where, extra) => {
if (item.__id__ === LOG_LEVEL_MINOR_ITEM.__id__) {
delete where.logLevel; // [1, 2, 3]
extra.allowReordering = true;
} else if (item.__id__ === LOG_LEVEL_MAJOR_ITEM.__id__) {
where.logLevel = [3];
extra.searchView = true;
}
},
};
const DEFAULT_WHERE = {
isComplete: true,
logLevel: [2, 3],
};
function getDayOfTheWeek(label) {
return DateUtils.DaysOfTheWeek[getDay(DateUtils.getDate(label))];
}
class LogEventSearch extends React.Component {
static getTypeaheadOptions() {
return LogEventOptions.get([
INCOMPLETE_ITEM,
LOG_LEVEL_MINOR_ITEM,
LOG_LEVEL_MAJOR_ITEM,
]);
}
static getDerivedStateFromProps(props, _state) {
return LogEventOptions.extractData(
props.search,
LogEventOptions.getTypeToActionMap([
INCOMPLETE_ITEM,
LOG_LEVEL_MOCK_ITEM,
]),
DEFAULT_WHERE,
);
}
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this.deregisterCallbacks = [
Coordinator.subscribe('log-event-created', (logEvent) => {
if (logEvent.logLevel === 1 && !this.props.search.length) {
Coordinator.invoke('url-update', { search: [LOG_LEVEL_MINOR_ITEM] });
}
}),
];
}
componentWillUnmount() {
this.deregisterCallbacks.forEach((deregisterCallback) => deregisterCallback());
}
// Extra Actions for Events
getPlanForTodayAction() {
const { todayLabel } = this.context;
return {
__id__: 'plan_for_today',
name: 'Plan for Today',
perform: (logEvent) => {
window.api.send('log-event-upsert', {
...logEvent,
date: todayLabel,
isComplete: false,
});
},
};
}
getCompleteAction() {
const { todayLabel } = this.context;
return {
__id__: 'complete',
name: 'Complete',
perform: (logEvent) => {
window.api.send('log-event-upsert', {
...logEvent,
date: todayLabel,
isComplete: true,
});
},
};
}
getDuplicateAction() {
const { todayLabel } = this.context;
return {
__id__: 'duplicate_for_today',
name: 'Duplicate for Today',
perform: (logEvent) => {
Coordinator.invoke('modal-editor', {
dataType: 'log-event',
EditorComponent: LogEventEditor,
valueKey: 'logEvent',
value: LogEvent.createVirtual({
...logEvent,
date: logEvent.date ? todayLabel : null,
}),
});
},
};
}
// eslint-disable-next-line class-methods-use-this
renderDefaultView(where, moreProps, settings) {
const { todayDate, todayLabel } = this.context;
const todoMoreProps = {
...moreProps,
prefixActions: [this.getCompleteAction(), ...moreProps.prefixActions],
};
const overdueAndUpcomingMoreProps = {
...todoMoreProps,
viewerComponentProps: {
...todoMoreProps.viewerComponentProps,
displayDate: true,
},
prefixActions: [this.getPlanForTodayAction(), ...todoMoreProps.prefixActions],
};
const results = [
,
,
];
if (settings.display_overdue_and_upcoming_events) {
results.push(
,
,
,
);
}
return results;
}
renderMultipleDaysView(where, moreProps) {
return eachDayOfInterval({
start: DateUtils.getDate(this.props.dateRange.startDate),
end: DateUtils.getDate(this.props.dateRange.endDate),
}).map((date) => {
const dateLabel = DateUtils.getLabel(date);
return (
);
});
}
renderSearchView(where, moreProps) {
assert(where.isComplete);
const displayDateMoreProps = {
...moreProps,
viewerComponentProps: {
...moreProps.viewerComponentProps,
displayDate: true,
},
};
if (this.props.dateRange) {
where = { ...where, date: this.props.dateRange };
}
return (
<>
>
);
}
// eslint-disable-next-line class-methods-use-this
renderIncompleteView(where, moreProps) {
const displayDateMoreProps = {
...moreProps,
viewerComponentProps: {
...moreProps.viewerComponentProps,
displayDate: true,
},
};
return (
<>
>
);
}
render() {
const { where, extra } = this.state;
const moreProps = { viewerComponentProps: {} };
moreProps.prefixActions = [];
moreProps.prefixActions.push(this.getDuplicateAction());
if (extra.allowReordering) {
moreProps.allowReordering = true;
moreProps.viewerComponentProps.displayLogLevel = true;
}
if (where.isComplete === false) {
return this.renderIncompleteView(where, moreProps);
} if (extra.searchView) {
return this.renderSearchView(where, moreProps);
} if (this.props.dateRange) {
return this.renderMultipleDaysView(where, moreProps);
}
return (
{(settings) => this.renderDefaultView(where, moreProps, settings)}
);
}
}
LogEventSearch.propTypes = {
dateRange: PropTypes.Custom.DateRange,
search: PropTypes.arrayOf(PropTypes.Custom.Item.isRequired).isRequired,
};
LogEventSearch.contextType = DateContext;
export default LogEventSearch;
================================================
FILE: src/client/LogEvent/index.js
================================================
export { default as LogEventEditor } from './LogEventEditor';
export { default as LogEventList } from './LogEventList';
export { default as LogEventSearch } from './LogEventSearch';
export { default as LogEventDetailsHeader } from './LogEventDetailsHeader';
export { default as LogEventOptions } from './LogEventOptions';
================================================
FILE: src/client/LogKey/LogKeyEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { LogKey } from '../../common/data_types';
import {
Selector, TextEditor, TextInput, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import LogStructureValueEditor from './LogValueEditor';
class LogKeyEditor extends React.Component {
static getDerivedStateFromProps(props) {
return {
logKey: props.logKeys[props.index],
};
}
constructor(props) {
super(props);
this.state = {};
}
update(methodOrName, maybeValue) {
const logKey = { ...this.state.logKey };
if (typeof methodOrName === 'function') {
methodOrName(logKey);
} else {
logKey[methodOrName] = maybeValue;
}
this.props.onChange(logKey);
}
updateType(newType) {
const logKey = { ...this.state.logKey };
logKey.type = newType;
logKey.value = LogKey.Type[newType].default;
this.props.onChange(logKey);
}
renderTypeSelector() {
return (
this.updateType(type)}
style={{ borderRight: '2px solid transparent' }}
/>
);
}
renderNameInput() {
return (
this.update('name', name)}
/>
);
}
renderParentLogTopic() {
return (
this.update('parentLogTopic', parentLogTopic)}
placeholder="Parent Topic"
/>
);
}
renderOptionalSelector() {
return (
this.update('isOptional', isOptional)}
yesLabel="Optional"
noLabel="Required"
style={{ borderLeft: '1px solid transparent' }}
/>
);
}
renderValue() {
if (this.state.logKey.isOptional) {
return null;
}
return (
Promise.resolve([])}
/>
);
}
renderKeyTemplate() {
return (
this.update('template', template)}
/>
);
}
renderEnumValuesSelector() {
const { logKey } = this.state;
return (
this.props.onSearch(query, this.props.index),
)}
value={TypeaheadSelector.getStringListItems(logKey.enumValues)}
disabled={this.props.disabled}
onChange={(items) => this.update('enumValues', items.map((item) => item.name))}
placeholder="Enum Values"
/>
);
}
renderEnumValuesSection() {
return (
Enum Values
{this.renderEnumValuesSelector()}
);
}
render() {
// eslint-disable-next-line react/prop-types
const children = this.props.children || [];
return (
{children.shift()}
Key
{this.renderTypeSelector()}
{this.renderNameInput()}
{this.state.logKey.type === LogKey.Type.LOG_TOPIC
? this.renderParentLogTopic() : null}
{this.renderOptionalSelector()}
{this.renderValue()}
{children.pop()}
Key Template
{this.renderKeyTemplate()}
{this.state.logKey.type === LogKey.Type.ENUM
? this.renderEnumValuesSection() : null}
);
}
}
LogKeyEditor.propTypes = {
logKeys: PropTypes.arrayOf(PropTypes.Custom.LogKey.isRequired).isRequired,
index: PropTypes.number.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
};
export default LogKeyEditor;
================================================
FILE: src/client/LogKey/LogKeyListEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'react-bootstrap/Button';
import InputGroup from 'react-bootstrap/InputGroup';
import { MdAddCircleOutline } from 'react-icons/md';
import { LogKey } from '../../common/data_types';
import { SortableList, TextEditor, TypeaheadOptions } from '../Common';
import LogKeyEditor from './LogKeyEditor';
class LogKeyListEditor extends React.Component {
renderTitleTemplateEditor() {
return (
{this.props.templateLabel}
this.props.onTemplateChange(newTemplate)}
/>
this.props.onLogKeysChange([
...this.props.logKeys,
LogKey.createVirtual(),
])}
style={{ height: 'inherit' }}
>
);
}
renderSortableList() {
return (
this.props.onLogKeysChange(updatedLogKeys)}
onSearch={this.props.onValueSearch}
type={LogKeyEditor}
itemsKey="logKeys"
/>
);
}
render() {
return (
<>
{this.renderTitleTemplateEditor()}
{this.renderSortableList()}
>
);
}
}
LogKeyListEditor.propTypes = {
templateLabel: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
templateValue: PropTypes.any,
templateOptions: PropTypes.instanceOf(TypeaheadOptions),
onTemplateChange: PropTypes.func.isRequired,
logKeys: PropTypes.arrayOf(PropTypes.Custom.LogKey.isRequired).isRequired,
onLogKeysChange: PropTypes.func.isRequired,
onValueSearch: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
export default LogKeyListEditor;
================================================
FILE: src/client/LogKey/LogValueEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { getPartialItem, LogKey } from '../../common/data_types';
import {
Selector, TextEditor, TypeaheadInput, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import { LogTopicOptions } from '../LogTopic';
class LogValueEditor extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
focus() {
this.ref.current.focus();
}
update(value) {
const logKey = { ...this.props.logKey };
if (logKey.type === LogKey.Type.LOG_TOPIC && value) {
value = getPartialItem(value);
}
logKey.value = value;
this.props.onChange(logKey);
}
render() {
const { logKey } = this.props;
const disabled = this.props.disabled || !!logKey.template;
const uniqueId = `log-structure-value-editor-${logKey.__id__}`;
let { value } = logKey;
if (typeof value === 'undefined') {
value = LogKey.Type[logKey.type].default;
}
if (logKey.type === LogKey.Type.LINK && disabled) {
return (
);
} if (logKey.type === LogKey.Type.STRING_LIST) {
return (
this.update(items.map((item) => item.name))}
multiple
ref={this.ref}
/>
);
} if (logKey.type === LogKey.Type.YES_OR_NO) {
return (
this.update(newValue ? 'yes' : 'no')}
ref={this.ref}
/>
);
} if (logKey.type === LogKey.Type.ENUM) {
return (
this.update(newValue)}
ref={this.ref}
/>
);
} if (logKey.type === LogKey.Type.LOG_TOPIC) {
const parentLogTopicId = logKey.parentLogTopic
? logKey.parentLogTopic.__id__
: undefined;
return (
this.update(newValue)}
where={{ parent_topic_id: parentLogTopicId }}
ref={this.ref}
/>
);
} if (logKey.type === LogKey.Type.RICH_TEXT_LINE) {
return (
this.update(newValue)}
ref={this.ref}
/>
);
}
return (
this.update(newValue)}
onSearch={(query) => this.props.onSearch(query)}
ref={this.ref}
/>
);
}
}
LogValueEditor.propTypes = {
logKey: PropTypes.Custom.LogKey.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
};
export default LogValueEditor;
================================================
FILE: src/client/LogKey/LogValueListEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import LogValueEditor from './LogValueEditor';
class LogValueListEditor extends React.Component {
constructor(props) {
super(props);
this.firstRef = React.createRef();
}
focus() {
this.firstRef.current.focus();
}
render() {
return this.props.logKeys.map((logKey, index) => (
{logKey.name}
{
const updatedLogKeys = [...this.props.logKeys];
updatedLogKeys[index] = updatedLogKey;
this.props.onChange(updatedLogKeys);
}}
onSearch={(query) => window.api.send('value-typeahead', {
source: this.props.source,
query,
index,
})}
ref={index === 0 ? this.firstRef : null}
/>
));
}
}
LogValueListEditor.propTypes = {
source: PropTypes.Custom.Item.isRequired,
logKeys: PropTypes.arrayOf(PropTypes.Custom.LogKey.isRequired).isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default LogValueListEditor;
================================================
FILE: src/client/LogKey/index.js
================================================
export { default as LogKeyEditor } from './LogKeyEditor';
export { default as LogKeyListEditor } from './LogKeyListEditor';
export { default as LogValueEditor } from './LogValueEditor';
export { default as LogValueListEditor } from './LogValueListEditor';
================================================
FILE: src/client/LogStructure/LogStructureDetailsHeader.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import {
Coordinator, InputLine,
} from '../Common';
class LogStructureDetailsHeader extends React.Component {
static onSearchButtonClick(logStructure) {
Coordinator.invoke('url-update', { search: [logStructure] });
}
render() {
const { logStructure } = this.props;
return (
{logStructure.logStructureGroup.name}
{' / '}
{logStructure.name}
);
}
}
LogStructureDetailsHeader.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
};
export default LogStructureDetailsHeader;
================================================
FILE: src/client/LogStructure/LogStructureEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { getPartialItem, LogStructure } from '../../common/data_types';
import {
Selector, TextInput, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import { LogKeyListEditor } from '../LogKey';
import LogStructureFrequencyEditor from './LogStructureFrequencyEditor';
const { LogLevel } = LogStructure;
class LogStructureEditor extends React.Component {
constructor(props) {
super(props);
this.nameRef = React.createRef();
}
componentDidMount() {
this.nameRef.current.focus();
}
updateLogStructure(methodOrName, maybeValue) {
const updatedLogStructure = { ...this.props.logStructure };
if (typeof methodOrName === 'function') {
methodOrName(updatedLogStructure);
} else {
updatedLogStructure[methodOrName] = maybeValue;
}
LogStructure.trigger(updatedLogStructure);
this.props.onChange(updatedLogStructure);
}
renderGroup() {
const options = new TypeaheadOptions({
serverSideOptions: [{ name: 'log-structure-group' }],
onSelect: async (option) => window.api.send('log-structure-group-load', option),
});
return (
Group
this.updateLogStructure(
'logStructureGroup',
logStructureGroup,
)}
/>
);
}
renderName() {
return (
Name
this.updateLogStructure('name', name)}
ref={this.nameRef}
/>
);
}
renderEventNeedsEditSelector() {
return (
Needs Edit?
this.updateLogStructure('eventNeedsEdit', eventNeedsEdit)}
/>
);
}
renderEventAllowDetailsSelector() {
return (
Event Details?
this.updateLogStructure('eventAllowDetails', eventAllowDetails)}
/>
);
}
renderLogLevelSelector() {
return (
Log Level
this.updateLogStructure('logLevel', LogLevel.getIndex(value))}
/>
);
}
renderIsDeprecated() {
return (
Is Deprecated?
this.updateLogStructure('isDeprecated', isDeprecated)}
/>
);
}
render() {
const { logStructure } = this.props;
return (
<>
{this.renderGroup()}
{this.renderName()}
this.updateLogStructure('eventTitleTemplate', eventTitleTemplate)
}
logKeys={logStructure.eventKeys}
onLogKeysChange={(eventKeys) => this.updateLogStructure('eventKeys', eventKeys)}
onValueSearch={(query, index) => window.api.send('value-typeahead', {
logStructure: this.props.logStructure,
query,
index,
})}
disabled={this.props.disabled}
/>
{this.renderEventNeedsEditSelector()}
{this.renderEventAllowDetailsSelector()}
this.updateLogStructure(...args)}
/>
{this.renderLogLevelSelector()}
{this.renderIsDeprecated()}
>
);
}
}
LogStructureEditor.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default LogStructureEditor;
================================================
FILE: src/client/LogStructure/LogStructureFrequencyEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { LogStructure } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
import {
DateContext, DatePicker, Selector, TextInput,
} from '../Common';
const MonthOptions = DateUtils.MonthsOfTheYear.map((month, index) => {
const value = `0${index + 1}`.substr(-2);
return { label: `${month.name} (${value})`, value };
});
const DayOptions = Array(Math.max(...DateUtils.MonthsOfTheYear.map((month) => month.days)))
.fill(null)
.map((_, index) => {
const value = `0${index + 1}`.substr(-2);
return { label: value, value };
});
const WarningDayOptions = Array(15).fill(null).map((_, index) => {
const value = `${index}`;
return { label: value, value };
});
class LogStructureFrequencyEditor extends React.Component {
updateIsPeriodic(newIsPeriodic) {
const { todayDate } = this.context;
this.props.updateLogStructure((updatedLogStructure) => {
if (newIsPeriodic) {
updatedLogStructure.isPeriodic = true;
updatedLogStructure.reminderText = updatedLogStructure._reminderText || '';
updatedLogStructure.frequency = (
updatedLogStructure._frequency || LogStructure.Frequency.EVERYDAY
);
updatedLogStructure.warningDays = updatedLogStructure._warningDays || 0;
updatedLogStructure.suppressUntilDate = updatedLogStructure._suppressUntilDate || '{yesterday}';
DateUtils.maybeSubstitute(todayDate, updatedLogStructure, 'suppressUntilDate');
} else {
updatedLogStructure.isPeriodic = false;
updatedLogStructure._reminderText = updatedLogStructure.reminderText;
updatedLogStructure.reminderText = null;
updatedLogStructure._frequency = updatedLogStructure.frequency;
updatedLogStructure.frequency = null;
updatedLogStructure._warningDays = updatedLogStructure.warningDays;
updatedLogStructure.warningDays = null;
updatedLogStructure._suppressUntilDate = updatedLogStructure.suppressUntilDate;
updatedLogStructure.suppressUntilDate = null;
}
});
}
updateFrequency(newFrequency) {
const { todayLabel } = this.context;
this.props.updateLogStructure((updatedLogStructure) => {
const oldFrequency = updatedLogStructure.frequency;
updatedLogStructure.frequency = newFrequency;
if (newFrequency === LogStructure.Frequency.YEARLY) {
updatedLogStructure.frequencyArgs = (
updatedLogStructure._frequencyArgs || todayLabel.substr(5)
);
} else if (oldFrequency === LogStructure.Frequency.YEARLY) {
updatedLogStructure._frequencyArgs = updatedLogStructure.frequencyArgs;
updatedLogStructure.frequencyArgs = null;
}
});
}
renderIsPeriodic() {
return (
Is Periodic?
this.updateIsPeriodic(isPeriodic)}
/>
);
}
renderReminderText() {
return (
Reminder Text
this.props.updateLogStructure('reminderText', reminderText)}
/>
);
}
renderFrequency() {
return (
Frequency
this.updateFrequency(frequency)}
/>
);
}
renderFrequencyArgs() {
const { frequency, frequencyArgs } = this.props.logStructure;
if (frequency !== LogStructure.Frequency.YEARLY) {
return null;
}
const [oldMonth, oldDay] = frequencyArgs.split('-');
return (
Yearly Date
this.props.updateLogStructure('frequencyArgs', `${newMonth}-${oldDay}`)}
/>
this.props.updateLogStructure('frequencyArgs', `${oldMonth}-${newDay}`)}
/>
);
}
renderWarningDays() {
return (
Warning Days
this.props.updateLogStructure('warningDays', parseInt(warningDays, 10))}
/>
);
}
renderSuppressUntilDate() {
return (
Suppress Until
this.props.updateLogStructure('suppressUntilDate', suppressUntilDate)}
/>
);
}
render() {
return (
<>
{this.renderIsPeriodic()}
{this.props.logStructure.isPeriodic
? (
<>
{this.renderReminderText()}
{this.renderFrequency()}
{this.renderFrequencyArgs()}
{this.renderWarningDays()}
{this.renderSuppressUntilDate()}
>
)
: null}
>
);
}
}
LogStructureFrequencyEditor.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
disabled: PropTypes.bool.isRequired,
updateLogStructure: PropTypes.func.isRequired,
};
LogStructureFrequencyEditor.contextType = DateContext;
export default LogStructureFrequencyEditor;
================================================
FILE: src/client/LogStructure/LogStructureGroupEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { TextInput } from '../Common';
class LogStructureGroupEditor extends React.Component {
constructor(props) {
super(props);
this.nameRef = React.createRef();
}
componentDidMount() {
this.nameRef.current.focus();
}
updateLogStructureGroup(methodOrName, maybeValue) {
const updatedLogStructureGroup = { ...this.props.logStructureGroup };
if (typeof methodOrName === 'function') {
methodOrName(updatedLogStructureGroup);
} else {
updatedLogStructureGroup[methodOrName] = maybeValue;
}
this.props.onChange(updatedLogStructureGroup);
}
renderName() {
return (
Name
this.updateLogStructureGroup('name', name)}
ref={this.nameRef}
/>
);
}
render() {
return this.renderName();
}
}
LogStructureGroupEditor.propTypes = {
logStructureGroup: PropTypes.Custom.LogStructureGroup.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default LogStructureGroupEditor;
================================================
FILE: src/client/LogStructure/LogStructureGroupList.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { BulletList } from '../Common';
import LogStructureGroupEditor from './LogStructureGroupEditor';
import LogStructureList from './LogStructureList';
function LogStructureGroupViewer(props) {
const { logStructureGroup } = props;
return (
{logStructureGroup.name}
);
}
LogStructureGroupViewer.propTypes = {
logStructureGroup: PropTypes.Custom.LogStructureGroup.isRequired,
};
LogStructureGroupViewer.Expanded = function (props) {
const { logStructureGroup, ...viewerComponentProps } = props;
return (
);
};
LogStructureGroupViewer.Expanded.propTypes = {
logStructureGroup: PropTypes.Custom.LogStructureGroup.isRequired,
};
function LogStructureGroupList(props) {
return (
);
}
export default LogStructureGroupList;
================================================
FILE: src/client/LogStructure/LogStructureList.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import {
BulletList, DetailsIcon, InfoIcon, LeftRight, Link, TextEditor, WarningIcon,
} from '../Common';
import LogStructureEditor from './LogStructureEditor';
function LogStructureViewer(props) {
const { logStructure, showDetails } = props;
if (!showDetails) {
return (
{logStructure.name}
);
}
let suffix;
if (logStructure.isPeriodic) {
if (logStructure.frequencyArgs) {
suffix = `(${logStructure.frequency}: ${logStructure.frequencyArgs})`;
} else {
suffix = `(${logStructure.frequency})`;
}
}
return (
{suffix}
);
}
LogStructureViewer.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
showDetails: PropTypes.bool,
};
function LogStructureList(props) {
return (
);
}
LogStructureList.Single = function (props) {
return (
);
};
LogStructureList.Single.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
};
export default LogStructureList;
================================================
FILE: src/client/LogStructure/LogStructureOptions.js
================================================
import assert from 'assert';
import { TypeaheadOptions } from '../Common';
class LogStructureOptions {
static get() {
return new TypeaheadOptions({
serverSideOptions: [
{ name: 'log-structure' },
{ name: 'log-topic' },
],
});
}
static getTypeToActionMap() {
return {
'log-structure': (item, where, extra) => {
if (!where.__id__) where.__id__ = [];
where.__id__.push(item.__id__);
extra.searchView = true;
},
'log-topic': (item, where, extra) => {
if (!where.logTopics) {
where.logTopics = [];
}
where.logTopics.push(item);
extra.searchView = true;
},
};
}
static extractData(items, typeToActionMap) {
const where = {};
const extra = {};
items.forEach((item) => {
const action = typeToActionMap[item.__type__];
if (action) {
action(item, where, extra);
} else {
assert(false, `Unable to process ${JSON.stringify(item)}`);
}
});
return { where, extra };
}
}
export default LogStructureOptions;
================================================
FILE: src/client/LogStructure/LogStructureSearch.js
================================================
import React from 'react';
import PropTypes from '../prop-types';
import LogStructureGroupList from './LogStructureGroupList';
import LogStructureList from './LogStructureList';
import LogStructureOptions from './LogStructureOptions';
class LogStructureSearch extends React.Component {
static getTypeaheadOptions() {
return LogStructureOptions.get();
}
static getDerivedStateFromProps(props, _state) {
return LogStructureOptions.extractData(
props.search,
LogStructureOptions.getTypeToActionMap(),
);
}
constructor(props) {
super(props);
this.state = {};
}
renderSearchView() {
return (
);
}
// eslint-disable-next-line class-methods-use-this
renderDefaultView() {
return (
);
}
render() {
if (this.state.extra.searchView) {
return this.renderSearchView();
}
return this.renderDefaultView();
}
}
LogStructureSearch.propTypes = {
search: PropTypes.arrayOf(PropTypes.Custom.Item.isRequired).isRequired,
};
export default LogStructureSearch;
================================================
FILE: src/client/LogStructure/index.js
================================================
export { default as LogStructureEditor } from './LogStructureEditor';
export { default as LogStructureGroupList } from './LogStructureGroupList';
export { default as LogStructureList } from './LogStructureList';
export { default as LogStructureSearch } from './LogStructureSearch';
export { default as LogStructureDetailsHeader } from './LogStructureDetailsHeader';
================================================
FILE: src/client/LogTopic/LogTopicDetailsHeader.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import {
Coordinator, Dropdown, InputLine, Link,
} from '../Common';
import LogTopicOptions from './LogTopicOptions';
class LogTopicDetailsHeader extends React.Component {
static onSearchButtonClick(logTopic) {
Coordinator.invoke('url-update', { search: [logTopic] });
}
renderParentTopic() {
const { logTopic } = this.props;
if (!logTopic.parentLogTopic) {
return null;
}
return (
<>
{logTopic.parentLogTopic.name}
{' / '}
>
);
}
renderChildTopics() {
const { logTopic } = this.props;
if (logTopic.hasStructure) {
return null;
}
return (
<>
{' / '}
Coordinator.invoke(
'url-update',
{ details: childLogTopic },
)}
>
...
>
);
}
render() {
const { logTopic } = this.props;
return (
{this.renderParentTopic()}
{logTopic.name}
{this.renderChildTopics()}
);
}
}
LogTopicDetailsHeader.propTypes = {
logTopic: PropTypes.Custom.LogTopic.isRequired,
};
export default LogTopicDetailsHeader;
================================================
FILE: src/client/LogTopic/LogTopicEditor.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { LogTopic } from '../../common/data_types';
import {
Selector, TextInput, TypeaheadOptions, TypeaheadSelector,
} from '../Common';
import { LogKeyListEditor, LogValueListEditor } from '../LogKey';
class LogTopicEditor extends React.Component {
constructor(props) {
super(props);
this.nameRef = React.createRef();
}
componentDidMount() {
this.nameRef.current.focus();
}
updateLogTopic(methodOrName, maybeValue) {
const updatedLogTopic = { ...this.props.logTopic };
if (typeof methodOrName === 'function') {
methodOrName(updatedLogTopic);
} else {
updatedLogTopic[methodOrName] = maybeValue;
}
LogTopic.trigger(updatedLogTopic);
this.props.onChange(updatedLogTopic);
}
renderParent() {
const options = new TypeaheadOptions({
serverSideOptions: [{ name: 'log-topic' }],
onSelect: async (option) => window.api.send('log-topic-load', option),
});
return (
Parent
this.updateLogTopic('parentLogTopic', parentLogTopic)}
/>
);
}
renderName() {
const { parentLogTopic } = this.props.logTopic;
const isNameDerived = parentLogTopic ? parentLogTopic.childNameTemplate !== null : false;
return (
Name
this.updateLogTopic('name', name)}
ref={this.nameRef}
/>
);
}
renderIsDeprecated() {
return (
Is Deprecated?
this.updateLogTopic('isDeprecated', isDeprecated)}
/>
);
}
renderValues() {
const { parentLogTopic } = this.props.logTopic;
if (!parentLogTopic || !parentLogTopic.childKeys) {
return null;
}
return (
this.updateLogTopic((updatedLogTopic) => {
updatedLogTopic.parentLogTopic.childKeys = updatedChildKeys;
})}
/>
);
}
renderChildKeys() {
const { logTopic } = this.props;
let logKeyList;
if (logTopic.childKeys) {
logKeyList = (
this.updateLogTopic('childNameTemplate', childNameTemplate)}
logKeys={logTopic.childKeys || []}
onLogKeysChange={(newChildKeys) => this.updateLogTopic('childKeys', newChildKeys)}
onValueSearch={(query, index) => { throw new Error('not implemented'); }}
disabled={this.props.disabled}
/>
);
}
return (
<>
Enable Child Keys?
this.updateLogTopic((updatedLogTopic) => {
if (!enableChildKeys) {
updatedLogTopic._childKeys = updatedLogTopic.childKeys;
updatedLogTopic.childKeys = null;
} else {
updatedLogTopic.childKeys = updatedLogTopic._childKeys || [];
}
})}
/>
{logKeyList}
>
);
}
render() {
return (
<>
{this.renderParent()}
{this.renderValues()}
{this.renderName()}
{this.renderIsDeprecated()}
{this.renderChildKeys()}
>
);
}
}
LogTopicEditor.propTypes = {
logTopic: PropTypes.Custom.LogTopic.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
export default LogTopicEditor;
================================================
FILE: src/client/LogTopic/LogTopicList.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import {
BulletList, DetailsIcon, Link, WarningIcon,
} from '../Common';
import LogTopicEditor from './LogTopicEditor';
function LogTopicViewer(props) {
const { logTopic } = props;
let childIndicator = null;
if (logTopic.childCount) {
childIndicator = (
{logTopic.childCount}
);
}
return (
{logTopic.name}
{childIndicator}
);
}
LogTopicViewer.propTypes = {
logTopic: PropTypes.Custom.LogTopic.isRequired,
};
function LogTopicList(props) {
return (
);
}
LogTopicViewer.Expanded = function (props) {
const { logTopic } = props;
if (logTopic.hasStructure) {
return null;
}
return (
);
};
LogTopicViewer.Expanded.propTypes = {
logTopic: PropTypes.Custom.LogTopic.isRequired,
};
LogTopicList.Single = function (props) {
return (
);
};
LogTopicList.Single.propTypes = {
logTopic: PropTypes.Custom.LogTopic.isRequired,
};
export default LogTopicList;
================================================
FILE: src/client/LogTopic/LogTopicOptions.js
================================================
import assert from 'assert';
import { getVirtualID, isRealItem, LogTopic } from '../../common/data_types';
import { Coordinator, TypeaheadOptions } from '../Common';
import LogTopicEditor from './LogTopicEditor';
const CREATE_ITEM = {
__type__: 'log-topic',
__id__: getVirtualID(),
name: 'Create New Topic ...',
getItem(_option, partialParentLogTopic) {
return window.api.send('log-topic-load', partialParentLogTopic)
.then((parentLogTopic) => new Promise((resolve) => {
Coordinator.invoke('modal-editor', {
dataType: 'log-topic',
EditorComponent: LogTopicEditor,
valueKey: 'logTopic',
value: LogTopic.createVirtual({ parentLogTopic }),
onClose: (newLogTopic) => {
if (newLogTopic && isRealItem(newLogTopic)) {
resolve(newLogTopic);
} else {
resolve(null);
}
},
});
}));
},
};
class LogTopicOptions {
static get({
allowCreation, parentLogTopic, beforeSelect, afterSelect,
} = {}) {
return new TypeaheadOptions({
serverSideOptions: [{ name: 'log-topic', where: { parentLogTopic } }],
suffixOptions: [allowCreation ? CREATE_ITEM : null].filter((item) => !!item),
onSelect: async (option) => {
if (option.getItem) {
if (beforeSelect) beforeSelect();
const result = await option.getItem(option, parentLogTopic);
if (afterSelect) afterSelect();
return result;
}
return undefined;
},
});
}
static getTypeToActionMap() {
return {
'log-topic': (item, where, extra) => {
if (!where.logTopics) {
where.logTopics = [];
}
where.logTopics.push(item);
extra.searchView = true;
},
};
}
static extractData(items, typeToActionMap) {
const where = {};
const extra = {};
items.forEach((item) => {
const action = typeToActionMap[item.__type__];
if (action) {
action(item, where, extra);
} else {
assert(false, `Unable to process ${JSON.stringify(item)}`);
}
});
return { where, extra };
}
}
export default LogTopicOptions;
================================================
FILE: src/client/LogTopic/LogTopicSearch.js
================================================
import React from 'react';
import { TypeaheadOptions } from '../Common';
import PropTypes from '../prop-types';
import LogTopicList from './LogTopicList';
import LogTopicOptions from './LogTopicOptions';
class LogTopicSearch extends React.Component {
static getTypeaheadOptions() {
const where = {};
return new TypeaheadOptions({
serverSideOptions: [
{ name: 'log-topic', args: { where } },
],
});
}
static getDerivedStateFromProps(props, _state) {
return LogTopicOptions.extractData(
props.search,
LogTopicOptions.getTypeToActionMap(),
);
}
constructor(props) {
super(props);
this.state = {};
}
renderSearchView() {
return (
<>
logTopic.__id__),
}}
allowCreation={false}
allowReordering={false}
/>
>
);
}
// eslint-disable-next-line class-methods-use-this
renderDefaultView() {
return ;
}
render() {
if (this.state.extra.searchView) {
return this.renderSearchView();
}
return this.renderDefaultView();
}
}
LogTopicSearch.propTypes = {
search: PropTypes.arrayOf(PropTypes.Custom.Item.isRequired).isRequired,
};
export default LogTopicSearch;
================================================
FILE: src/client/LogTopic/index.js
================================================
export { default as LogTopicList } from './LogTopicList';
export { default as LogTopicEditor } from './LogTopicEditor';
export { default as LogTopicSearch } from './LogTopicSearch';
export { default as LogTopicOptions } from './LogTopicOptions';
export { default as LogTopicDetailsHeader } from './LogTopicDetailsHeader';
================================================
FILE: src/client/Reminders/ReminderItem.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';
import { BsList } from 'react-icons/bs';
import { LogEvent } from '../../common/data_types';
import {
Coordinator, DateContext, Dropdown, Highlightable, Icon, InputLine,
} from '../Common';
import { LogEventEditor } from '../LogEvent';
import { LogStructureEditor } from '../LogStructure';
class ReminderItem extends React.Component {
constructor(props) {
super(props);
this.state = { isHighlighted: false };
this.dropdownRef = React.createRef();
}
onEditButtonClick() {
Coordinator.invoke('modal-editor', {
dataType: 'log-structure',
EditorComponent: LogStructureEditor,
valueKey: 'logStructure',
value: this.props.logStructure,
});
}
onCompleteReminder(logEvent = null) {
const { todayLabel } = this.context;
const { logStructure } = this.props;
const wasLogEventProvided = !!logEvent;
if (!logEvent) {
logEvent = LogEvent.createVirtual({ date: todayLabel, logStructure });
}
if (logStructure.eventNeedsEdit && !wasLogEventProvided) {
// This modal is only closed after the reminder-complete RPC.
this.closeModal = Coordinator.invoke('modal-editor', {
dataType: 'log-event',
EditorComponent: LogEventEditor,
valueKey: 'logEvent',
value: logEvent,
onSave: (updatedLogEvent) => this.onCompleteReminder(updatedLogEvent),
});
return;
}
window.api.send('reminder-complete', { logStructure, logEvent, todayLabel })
.then(() => {
if (this.closeModal) {
this.closeModal();
delete this.closeModal;
}
});
}
onDismissReminder() {
const { todayLabel } = this.context;
const { logStructure } = this.props;
window.api.send('reminder-dismiss', { logStructure, todayLabel });
}
renderRight() {
const { logStructure } = this.props;
if (!this.state.isHighlighted) {
return (
{logStructure.reminderScore.value}
);
}
const actions = [
{
__id__: 'done',
name: 'Mark as Complete',
perform: (_event) => this.onCompleteReminder(),
},
{
__id__: 'dismiss',
name: 'Dismiss Reminder',
perform: (_event) => this.onDismissReminder(),
},
{
__id__: 'edit',
name: 'Edit Structure',
perform: (_event) => this.onEditButtonClick(),
},
{
__id__: 'info',
name: 'Debug Info',
perform: (_event) => Coordinator.invoke(
'modal-error',
JSON.stringify(logStructure, null, 4),
),
},
];
return (
action.perform(event)}
ref={this.dropdownRef}
>
{
if (this.dropdownRef.current) {
this.dropdownRef.current.show();
}
}}
/>
);
}
render() {
const { logStructure } = this.props;
return (
this.setState({ isHighlighted })}
>
{
if (event.shiftKey) {
this.onDismissReminder();
} else {
this.onCompleteReminder();
}
}}
style={{ marginRight: 'none' }}
tabIndex={-1}
/>
{logStructure.reminderText || logStructure.name}
{this.renderRight()}
);
}
}
ReminderItem.propTypes = {
logStructure: PropTypes.Custom.LogStructure.isRequired,
};
ReminderItem.contextType = DateContext;
export default ReminderItem;
================================================
FILE: src/client/Reminders/ReminderList.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { SidebarSection } from '../Common';
import ReminderItem from './ReminderItem';
class ReminderList extends React.Component {
renderContent() {
if (this.props.logStructures.length === 0) {
return All done for now!
;
}
return this.props.logStructures.map((logStructure) => (
));
}
render() {
return (
{this.renderContent()}
);
}
}
ReminderList.propTypes = {
name: PropTypes.string.isRequired,
logStructures: PropTypes.arrayOf(PropTypes.Custom.LogStructure.isRequired).isRequired,
};
export default ReminderList;
================================================
FILE: src/client/Reminders/ReminderSidebar.js
================================================
import React from 'react';
import { DataLoader, DateContext } from '../Common';
import PropTypes from '../prop-types';
import ReminderList from './ReminderList';
class ReminderSidebar extends React.Component {
constructor(props) {
super(props);
this.state = { logStructureGroups: null };
}
componentDidMount() {
const { todayLabel } = this.props;
this.dataLoader = new DataLoader({
getInput: () => ({ name: 'reminder-sidebar', args: { todayLabel } }),
onData: (logStructureGroups) => this.setState({ logStructureGroups }),
});
}
componentDidUpdate() {
this.dataLoader.reload();
}
componentWillUnmount() {
this.dataLoader.stop();
}
render() {
if (this.state.logStructureGroups === null) {
return 'Loading ...';
}
return this.state.logStructureGroups.map((reminderGroup) => (
));
}
}
ReminderSidebar.propTypes = {
todayLabel: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
};
export default DateContext.Wrapper(ReminderSidebar);
================================================
FILE: src/client/Reminders/index.js
================================================
// eslint-disable-next-line import/prefer-default-export
export { default as ReminderSidebar } from './ReminderSidebar';
================================================
FILE: src/client/Settings/SettingsEditor.js
================================================
import assert from 'assert';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import {
HelpIcon, Selector, TextInput, TooltipElement,
} from '../Common';
import PropTypes from '../prop-types';
const SETTINGS_ITEMS = [
{
key: 'display_overdue_and_upcoming_events',
label: 'Display Overdue And Upcoming Events',
type: 'boolean',
},
{
key: 'display_settings_section',
label: 'Display Settings Section',
type: 'boolean',
},
{
key: 'display_two_details_sections',
label: 'Display Two Details Sections',
type: 'boolean',
},
{
key: 'today_offset_hours',
label: 'Today Offset Hours',
type: 'integer',
description: (
'Adjust the time at which the day starts / ends. Eg - If you frequently stay awake until 2am or so, '
+ 'you can set this value to "3", so the app does not move on to the next day until 3am.'
),
},
{
key: 'bullet_list_page_size',
label: 'Bullet List Page Size',
type: 'integer',
},
];
class SettingsEditor extends React.Component {
getSetting(key, defaultValue = null) {
return this.props.settings[key] || defaultValue;
}
setSetting(key, value) {
const settings = { ...this.props.settings };
settings[key] = value;
this.props.onChange(settings);
}
renderSettingsItems() {
return SETTINGS_ITEMS.map((item) => {
let inputElement = null;
if (item.type === 'boolean') {
inputElement = (
this.setSetting(item.key, value)}
/>
);
} if (item.type === 'integer') {
inputElement = (
this.setSetting(item.key, value)}
/>
);
}
assert(inputElement, `unknown settings item type: ${item.type}`);
let tooltip;
if (item.description) {
tooltip = (
{item.description}
);
}
return (
{item.label}
{tooltip}
{inputElement}
);
});
}
render() {
const results = [
{this.renderSettingsItems()}
,
];
Object.entries(this.props.plugins).forEach(([name, api]) => {
const key = api.getSettingsKey();
if (key === null) {
return;
}
const props = {
disabled: this.props.disabled,
value: this.getSetting(key),
onChange: (newValue) => this.setSetting(key, newValue),
};
results.push({api.getSettingsComponent(props)}
);
});
return results;
}
}
SettingsEditor.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
settings: PropTypes.objectOf(PropTypes.any.isRequired).isRequired,
plugins: PropTypes.Custom.Plugins.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
export default SettingsEditor;
================================================
FILE: src/client/Settings/SettingsModal.js
================================================
import React from 'react';
import Button from 'react-bootstrap/Button';
import InputGroup from 'react-bootstrap/InputGroup';
import Modal from 'react-bootstrap/Modal';
import { LeftRight } from '../Common';
import { suppressUnlessShiftKey } from '../Common/Utils';
import PropTypes from '../prop-types';
import SettingsEditor from './SettingsEditor';
class SettingsModal extends React.Component {
constructor(props) {
super(props);
this.state = {
settings: props.settings,
isSaving: false,
};
}
onSave() {
this.setState({ isSaving: true });
window.api.send('settings-set', this.state.settings)
.then(() => this.setState({ isSaving: false }));
}
render() {
return (
{
this.setState({ settings: this.props.settings });
this.props.onClose();
}}
onEscapeKeyDown={suppressUnlessShiftKey}
>
Settings
this.setState({ settings })}
/>
this.onSave()}
style={{ width: '50px' }}
>
Save
);
}
}
SettingsModal.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
settings: PropTypes.objectOf(PropTypes.any.isRequired).isRequired,
plugins: PropTypes.Custom.Plugins.isRequired,
isShown: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default SettingsModal;
================================================
FILE: src/client/Settings/SettingsSection.js
================================================
import React from 'react';
import { LeftRight, SettingsContext, SidebarSection } from '../Common';
import PropTypes from '../prop-types';
import SettingsModal from './SettingsModal';
class SettingsSection extends React.Component {
constructor(props) {
super(props);
this.state = {
isShown: false,
};
}
componentDidMount() {
window.onkeydown = (event) => {
if (event.shiftKey && event.metaKey && event.key === 's') {
this.setState({ isShown: true });
}
};
}
componentWillUnmount() {
delete window.onkeydown;
}
render() {
const settings = this.context;
const settingsSection = settings.display_settings_section ? (
this.setState({ isShown: true })}>Settings
) : null;
return (
<>
this.setState({ isShown: false })}
/>
{settingsSection}
>
);
}
}
SettingsSection.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
settings: PropTypes.objectOf(PropTypes.any.isRequired).isRequired,
plugins: PropTypes.Custom.Plugins.isRequired,
};
SettingsSection.contextType = SettingsContext;
export default SettingsSection;
================================================
FILE: src/client/Settings/index.js
================================================
// eslint-disable-next-line import/prefer-default-export
export { default as SettingsSection } from './SettingsSection';
================================================
FILE: src/client/__tests__/Colors.test.js
================================================
const fs = require('fs');
const path = require('path');
const walkSync = require('walk-sync');
test('verify_no_random_colors', async () => {
const rootPath = 'src/';
const excludeNames = ['index.css'];
let excludeCount = 0;
walkSync(rootPath, ['**/*.css']).forEach((fileName) => {
const filePath = path.join(rootPath, fileName);
if (excludeNames.some((name) => filePath.endsWith(name))) {
excludeCount += 1;
} else {
const fileData = fs.readFileSync(filePath).toString();
expect(fileData.includes('#')).toBeFalsy();
}
});
expect(excludeCount).toEqual(excludeNames.length);
});
================================================
FILE: src/client/index.css
================================================
body {
--background-color: #111;
--component-color: #222;
--component-highlight-color: #333;
--text-color: white;
--text-disabled-color: #9197a3;
--link-color: #3498DB;
--topic-color: #00bc8c;
--input-text-color: #fff;
--input-background-color: #333;
--input-disabled-background-color: #222;
--suggestion-highlight-color: #375a7f;
--input-background-token-color: #444;
--warning-color: #ff5281;
--font-size: 13px;
--font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: var(--background-color);
color: var(--text-color);
font-size: var(--font-size);
font-family: var(--font-family);
}
a,
a:hover {
color: var(--link-color);
cursor: pointer;
}
a.topic,
a.topic:hover {
color: var(--topic-color);
}
pre {
color: var(--text-color);
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.monospace {
font-family: Consolas;
}
================================================
FILE: src/client/index.html
================================================
GLADOS
================================================
FILE: src/client/index.js
================================================
import './Bootstrap';
import './index.css';
import './prop-types'; // Load PropTypes.Custom
import React from 'react';
import ReactDOM from 'react-dom';
import io from 'socket.io-client';
import { isVirtualItem } from '../common/data_types';
import SocketRPC from '../common/SocketRPC';
import { Application } from './Application';
import { Coordinator } from './Common';
function getCookies() {
const cookies = {};
document.cookie.split('; ').forEach((item) => {
const [key, value] = item.split('=');
cookies[key] = decodeURIComponent(value);
});
return cookies;
}
window.main = function main() {
const cookies = getCookies();
window.api = SocketRPC.client(
io(`${cookies.host}:${cookies.port}`),
(name, input, output) => {
// The "log-event-created" event is used to auto-add the "Log Level: Minor+"
// item to LogEventSearch typeahead, to make sure that the new item is visible.
if (name === 'log-event-upsert' && isVirtualItem(input)) {
Coordinator.broadcast('log-event-created', output);
} else if (name === 'reminder-complete') {
Coordinator.broadcast('log-event-created', output.logEvent);
}
},
// TODO: Eliminate this catch-all.
(_name, _input, error) => Coordinator.invoke('modal-error', error),
);
const plugins = {};
const pluginsContext = require.context('../plugins', true, /client\.js$/);
const pluginPatterns = JSON.parse(cookies.plugins).map((pattern) => new RegExp(pattern));
pluginsContext.keys()
.filter((filePath) => pluginPatterns.some((regex) => filePath.match(regex)))
.forEach((filePath) => {
const exports = pluginsContext(filePath);
const name = filePath.split('/').slice(1, -1).join('/');
plugins[name] = exports.default;
});
ReactDOM.render( , document.getElementById('root'));
};
================================================
FILE: src/client/prop-types.js
================================================
import PropTypes from 'prop-types';
const DateRange = PropTypes.shape({
startDate: PropTypes.string.isRequired,
endDate: PropTypes.string.isRequired,
});
const EnumOptions = PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}).isRequired,
);
const Item = PropTypes.shape({
__type__: PropTypes.string.isRequired,
__id__: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
});
const LogTopic = PropTypes.shape({
__id__: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
});
const LogKey = PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
isOptional: PropTypes.bool,
parentTopic: LogTopic,
});
const LogStructureGroup = PropTypes.shape({
__id__: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
});
const LogStructure = PropTypes.shape({
__id__: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
eventKeys: PropTypes.arrayOf(LogKey.isRequired).isRequired,
});
const LogEvent = PropTypes.shape({
__id__: PropTypes.number.isRequired,
// eslint-disable-next-line react/forbid-prop-types
title: PropTypes.object,
logStructure: LogStructure,
});
const Plugins = PropTypes.objectOf(
PropTypes.func.isRequired,
);
PropTypes.Custom = {
DateRange,
EnumOptions,
Item,
LogTopic,
LogStructureGroup,
LogKey,
LogStructure,
LogEvent,
Plugins,
};
export default PropTypes;
================================================
FILE: src/common/AsyncUtils.js
================================================
export function asyncSequence(items, method) {
if (!items) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let index = 0;
const results = [];
const next = () => {
// console.info(index, 'of', items.length);
if (index === items.length) {
resolve(results);
} else {
method(items[index], index, items)
.then((result) => {
results.push(result);
index += 1;
next();
})
.catch((error) => reject(error));
}
};
next();
});
}
export function asyncFilter(items, method) {
return new Promise((resolve, reject) => {
Promise.all(items.map((item) => method(item)))
.then((decisions) => {
const results = [];
decisions.forEach((decision, index) => {
if (decision) {
results.push(items[index]);
}
});
resolve(results);
})
.catch(reject);
});
}
export function callbackToPromise(method, ...args) {
return new Promise((resolve, reject) => {
method(...args, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
================================================
FILE: src/common/DateUtils.js
================================================
import assert from 'assert';
import {
addDays, format, isValid, parse, set, subDays,
} from 'date-fns';
const MS_IN_HOURS = 60 * 60 * 1000;
const LABEL_FORMAT = 'yyyy-MM-dd';
const MonthsOfTheYear = [
{ name: 'January', days: 31 },
{ name: 'February', days: 29 },
{ name: 'March', days: 31 },
{ name: 'April', days: 30 },
{ name: 'May', days: 31 },
{ name: 'June', days: 30 },
{ name: 'July', days: 31 },
{ name: 'August', days: 31 },
{ name: 'September', days: 30 },
{ name: 'October', days: 31 },
{ name: 'November', days: 30 },
{ name: 'December', days: 31 },
];
const DaysOfTheWeek = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const timeValues = {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
};
// Section: Date Utilities
class DateUtils {
static getContext(settings) {
let timestamp = new Date().valueOf();
if (settings) {
timestamp -= (parseFloat(settings.today_offset_hours) || 0) * MS_IN_HOURS;
}
const todayDate = set(new Date(timestamp), timeValues);
return {
todayDate,
todayLabel: DateUtils.getLabel(todayDate),
};
}
static getDate(label) {
return set(parse(label, LABEL_FORMAT, new Date()), timeValues);
}
static getLabel(date) {
return format(date, LABEL_FORMAT);
}
static maybeSubstitute(todayDate, path, name) {
if (typeof path[name] !== 'string') {
// do nothing
} else if (path[name] === '{yesterday}') {
path[name] = DateUtils.getLabel(subDays(todayDate, 1));
} else if (path[name] === '{today}') {
path[name] = DateUtils.getLabel(todayDate);
} else if (path[name] === '{tomorrow}') {
path[name] = DateUtils.getLabel(addDays(todayDate, 1));
} else if (!isValid(DateUtils.getDate(path[name]))) {
assert(false, path[name]);
}
}
}
DateUtils.MonthsOfTheYear = MonthsOfTheYear;
DateUtils.DaysOfTheWeek = DaysOfTheWeek;
export default DateUtils;
================================================
FILE: src/common/RichTextUtils.js
================================================
import assert from 'assert';
import deepEqual from 'deep-equal';
import {
convertFromRaw, convertToRaw,
EditorState, Modifier, SelectionState,
} from 'draft-js';
import { draftToMarkdown, markdownToDraft } from 'markdown-draft-js';
const StorageType = {
MARKDOWN: 'markdown:',
DRAFTJS: 'draftjs:',
};
function toString(value) {
if (typeof value === 'undefined') {
return 'undefined';
}
return JSON.stringify(value, null, 4);
}
const DRAFTJS_MENTION_PLUGIN_NAME = 'mention';
const DRAFTJS_MENTION_ENTITY_TYPE = 'mention';
const MARKDOWN_MENTION_PREFIX = 'mention';
const LINK_ENTITY_TYPE = 'LINK';
const draftToMarkdownOptions = {
entityItems: {
mention: {
open(entity) {
return '[';
},
close(entity) {
return `](${MARKDOWN_MENTION_PREFIX}:${entity.data.mention.__type__}:${entity.data.mention.__id__})`;
},
},
},
};
function postProcessDraftRawContent(rawContent) {
Object.values(rawContent.entityMap).forEach((entity) => {
if (entity.type === 'LINK') {
delete entity.data.href;
if (entity.data.url.startsWith(MARKDOWN_MENTION_PREFIX)) {
const parts = entity.data.url.split(':');
entity.type = 'mention';
entity.mutability = 'SEGMENTED';
entity.data = {
mention: { __type__: parts[1], __id__: parseInt(parts[2], 10) },
};
}
}
});
}
class RichTextUtils {
// eslint-disable-next-line consistent-return
static extractPlainText(value) {
if (!value) {
return '';
}
const content = convertFromRaw(value);
const blocks = content.getBlocksAsArray();
assert(blocks.length === 1, 'expected single line');
return blocks[0].getText();
}
static equals(left, right) {
if (left === right) return true;
if (left === null || right === null) return false;
const replaceKey = (block) => { delete block.key; delete block.data; };
left.blocks.forEach(replaceKey);
right.blocks.forEach(replaceKey);
return deepEqual(left, right);
}
// eslint-disable-next-line consistent-return
static deserialize(value, type) {
if (!value) {
if (type === StorageType.MARKDOWN) {
return '';
} if (type === StorageType.DRAFTJS) {
return null;
}
} else if (value.startsWith(StorageType.MARKDOWN)) {
const payload = value.substring(StorageType.MARKDOWN.length);
if (type === StorageType.MARKDOWN) {
return payload;
} if (type === StorageType.DRAFTJS) {
const rawContent = markdownToDraft(value);
postProcessDraftRawContent(rawContent);
return rawContent;
}
} else if (value.startsWith(StorageType.DRAFTJS)) {
const payload = value.substring(StorageType.DRAFTJS.length);
if (type === StorageType.MARKDOWN) {
return draftToMarkdown(JSON.parse(payload), draftToMarkdownOptions);
} if (type === StorageType.DRAFTJS) {
return JSON.parse(payload);
}
}
assert(false, `Invalid deserialize type: ${toString(type)} for ${toString(value)}`);
}
// eslint-disable-next-line consistent-return
static serialize(value, type) {
if (!value) {
return '';
} if (type === StorageType.MARKDOWN) {
if (typeof value === 'object') {
value = draftToMarkdown(value, draftToMarkdownOptions);
}
return StorageType.MARKDOWN + value;
} if (type === StorageType.DRAFTJS) {
if (typeof value === 'string') {
value = markdownToDraft(value);
postProcessDraftRawContent(value);
}
if (value) {
Object.values(value.entityMap).forEach((entity) => {
if (entity.type === DRAFTJS_MENTION_ENTITY_TYPE) {
// Do not save unnecessary fields.
const original = entity.data[DRAFTJS_MENTION_PLUGIN_NAME];
entity.data[DRAFTJS_MENTION_PLUGIN_NAME] = {
__type__: original.__type__,
__id__: original.__id__,
name: original.name,
};
}
});
return StorageType.DRAFTJS + JSON.stringify(value);
}
return '';
}
assert(false, `Invalid serialize type: ${toString(type)}`);
}
static fromEditorState(editorState) {
const content = editorState.getCurrentContent();
return content.hasText() ? convertToRaw(content) : null;
}
static toEditorState(value) {
const editorState = value
? EditorState.createWithContent(convertFromRaw(value))
: EditorState.createEmpty();
return EditorState.moveSelectionToEnd(editorState);
}
static getSelectionData(editorState) {
const selectionState = editorState.getSelection();
const blocks = editorState.getCurrentContent().getBlocksAsArray();
let anchorIndex = null; let
focusIndex = null;
blocks.forEach((block, index) => {
if (block.getKey() === selectionState.getAnchorKey()) {
anchorIndex = index;
}
if (block.getKey() === selectionState.getFocusKey()) {
focusIndex = index;
}
});
return {
anchorIndex,
anchorOffset: selectionState.getAnchorOffset(),
focusIndex,
focusOffset: selectionState.getFocusOffset(),
hasFocus: selectionState.hasFocus,
};
}
static setSelectionData(editorState, data) {
const blocks = editorState.getCurrentContent().getBlocksAsArray();
const anchorKey = blocks[data.anchorIndex].getKey();
const focusKey = blocks[data.focusIndex].getKey();
let selectionState = SelectionState.createEmpty();
selectionState = selectionState.merge({
anchorKey,
anchorOffset: data.anchorOffset,
focusKey,
focusOffset: data.focusOffset,
hasFocus: data.hasFocus,
});
return EditorState.acceptSelection(editorState, selectionState);
}
static fixCursorBug(prevEditorState, nextEditorState) {
// https://github.com/facebook/draft-js/issues/1198
const prevSelection = prevEditorState.getSelection();
const nextSelection = nextEditorState.getSelection();
if (
prevSelection.getAnchorKey() === nextSelection.getAnchorKey()
&& prevSelection.getAnchorOffset() === 0
&& nextSelection.getAnchorOffset() === 1
&& prevSelection.getFocusKey() === nextSelection.getFocusKey()
&& prevSelection.getFocusOffset() === 0
&& nextSelection.getFocusOffset() === 1
&& prevSelection.getHasFocus() === false
&& nextSelection.getHasFocus() === false
) {
const fixedSelection = nextSelection.merge({ hasFocus: true });
return EditorState.acceptSelection(nextEditorState, fixedSelection);
}
return nextEditorState;
}
static convertPlainTextToDraftContent(value, symbolToItems) {
if (!value) {
return value || '';
}
let markdown = '';
for (let ii = 0; ii < value.length; ii += 1) {
if (value[ii] in symbolToItems) {
const symbol = value[ii];
ii += 1;
const index = parseInt(value[ii], 10);
const item = symbolToItems[symbol][index];
markdown += `[${item.name}](mention:${item.__type__}:${item.__id__})`;
} else {
markdown += value[ii];
}
}
const content = markdownToDraft(markdown);
postProcessDraftRawContent(content);
return content;
}
static convertDraftContentToPlainText(value, symbolToItems) {
const markdown = RichTextUtils.deserialize(
value,
StorageType.MARKDOWN,
);
const mapping = {};
Object.entries(symbolToItems).forEach(([symbol, items]) => {
items.forEach((item, index) => {
if (item) {
const key = `${item.__type__}:${item.__id__}`;
if (!(key in mapping)) {
mapping[key] = symbol + index;
}
}
});
});
const regex1 = new RegExp(`(?:\\[.*?\\]\\(${MARKDOWN_MENTION_PREFIX}:.*?\\)|[^\\[]*)`, 'g');
const regex2 = new RegExp(`^\\[(.*?)\\]\\(${MARKDOWN_MENTION_PREFIX}:(.*?)\\)$`);
return Array.from(markdown.matchAll(regex1))
.map(([part]) => {
const result = part.match(regex2);
if (result) {
return mapping[result[2]];
}
return part;
})
.join('');
}
static extractMentions(content, type) {
// There's no way to extract the list of entity-keys from the contentState API.
// And so I'm just accessing the raw data here.
const result = {};
if (!content) {
return result;
}
Object.values(content.entityMap)
.filter((entity) => entity.type === DRAFTJS_MENTION_ENTITY_TYPE)
.forEach((entity) => {
const item = entity.data[DRAFTJS_MENTION_PLUGIN_NAME];
if (item.__type__ === type) {
result[item.__id__] = item;
}
});
return result;
}
static updateDraftContent(content, oldItems, newItems, evaluateExpressions = false) {
if (!content) {
return content;
}
if (!newItems) {
newItems = oldItems;
}
let contentState = convertFromRaw(content);
const keyToIndex = {};
oldItems.forEach((oldItem, index) => {
const key = `${oldItem.__type__}:${oldItem.__id__}`;
keyToIndex[key] = index;
});
const pendingEntities = [];
contentState.getBlocksAsArray().forEach((contentBlock) => {
const currentBlockKey = contentBlock.getKey();
let currentEntityKey;
let currentEntity;
contentBlock.findEntityRanges((charMetadata) => {
currentEntityKey = charMetadata.getEntity();
if (currentEntityKey) {
currentEntity = contentState.getEntity(currentEntityKey);
return currentEntity.getType() === DRAFTJS_MENTION_ENTITY_TYPE;
}
return false;
}, (start, end) => {
const prevItem = currentEntity.getData()[DRAFTJS_MENTION_PLUGIN_NAME];
const key = `${prevItem.__type__}:${prevItem.__id__}`;
if (key in keyToIndex) {
let nextItem = newItems[keyToIndex[key]];
if (typeof nextItem === 'object') {
if (
nextItem.__type__
&& prevItem.__id__ === nextItem.__id__
&& prevItem.name === nextItem.name
) {
return; // no change
}
if (Array.isArray(nextItem)) { // String List
nextItem = JSON.stringify(nextItem);
} else {
// The symbol is forwarded only for testing!
nextItem = { ...nextItem, symbol: prevItem.symbol };
}
}
pendingEntities.push([currentBlockKey, start, end, currentEntityKey, nextItem]);
}
});
});
pendingEntities.reverse().forEach(([blockKey, start, end, entityKey, item]) => {
let selectionState = SelectionState.createEmpty(blockKey);
selectionState = selectionState.merge({
anchorOffset: start,
focusOffset: end,
hasFocus: true,
});
if (typeof item === 'object') {
if (item.__type__) { // Item
contentState = Modifier.replaceText(
contentState,
selectionState,
item.name,
null,
entityKey,
).replaceEntityData(
entityKey,
{ [DRAFTJS_MENTION_PLUGIN_NAME]: item },
);
} else { // Rich Text
const innerContentState = convertFromRaw(item);
const innerContentBlocks = innerContentState.getBlocksAsArray();
assert(innerContentBlocks.length === 1, 'expected single line');
const innerContentBlock = innerContentBlocks[0];
contentState = Modifier.replaceText(
contentState,
selectionState,
innerContentBlock.getText(),
null,
null,
);
let currentEntityKey;
innerContentBlock.findEntityRanges((charMetadata) => {
currentEntityKey = charMetadata.getEntity();
return !!currentEntityKey;
}, (innerStart, innerEnd) => {
const currentEntity = innerContentState.getEntity(currentEntityKey);
contentState = contentState.createEntity(
currentEntity.getType(),
currentEntity.getMutability(),
currentEntity.getData(),
);
const innerSelectionState = selectionState.merge({
anchorOffset: selectionState.anchorOffset + innerStart,
focusOffset: selectionState.anchorOffset + innerEnd,
});
contentState = Modifier.applyEntity(
contentState,
innerSelectionState,
contentState.getLastCreatedEntityKey(),
);
});
}
} else {
// item is a string
contentState = Modifier.replaceText(
contentState,
selectionState,
item,
null,
null,
);
}
});
if (evaluateExpressions) {
contentState = RichTextUtils.evaluateDraftContentExpressions(contentState);
}
return convertToRaw(contentState);
}
static addPrefixToDraftContent(contentState, items) {
const blocks = contentState.getBlocksAsArray();
assert(blocks.length === 1, 'expected single line');
let selectionState = SelectionState.createEmpty(blocks[0].getKey());
selectionState = selectionState.merge({
anchorOffset: 0,
focusOffset: 0,
hasFocus: true,
});
items.forEach((item) => {
let delta;
if (typeof item === 'string') {
contentState = Modifier.insertText(
contentState,
selectionState,
item,
null,
null,
);
delta += item.length;
} else {
contentState = contentState.createEntity(
DRAFTJS_MENTION_ENTITY_TYPE,
'SEGMENTED',
{ [DRAFTJS_MENTION_PLUGIN_NAME]: item },
);
contentState = Modifier.insertText(
contentState,
selectionState,
item.name,
null,
contentState.getLastCreatedEntityKey(),
);
delta += item.name.length;
}
selectionState = selectionState.merge({
anchorOffset: selectionState.anchorOffset + delta,
focusOffset: selectionState.focusOffset + delta,
});
});
return contentState;
}
static removePrefixFromDraftContext(content, prefix) {
let contentState = convertFromRaw(content);
const blocks = contentState.getBlocksAsArray();
assert(blocks.length === 1, 'expected single line');
let selectionState = SelectionState.createEmpty(blocks[0].getKey());
selectionState = selectionState.merge({
anchorOffset: 0,
focusOffset: prefix.length,
hasFocus: true,
});
// https://draftjs.org/docs/api-reference-modifier/#removerange
contentState = Modifier.removeRange(
contentState,
selectionState,
'forward',
);
return convertToRaw(contentState);
}
static evaluateDraftContentExpressions(contentState) {
const pendingUpdates = [];
contentState.getBlocksAsArray().forEach((contentBlock) => {
const currentBlockKey = contentBlock.getKey();
const currentBlockText = contentBlock.getText();
for (let startIndex = 0, endIndex = -1; startIndex < currentBlockText.length;) {
const originalStartIndex = startIndex;
if (currentBlockText[startIndex] === '{') {
endIndex = currentBlockText.indexOf('}', startIndex);
assert(endIndex !== -1, 'expected to find } after {');
const expression = currentBlockText.substring(startIndex + 1, endIndex);
startIndex = endIndex + 1;
try {
// eslint-disable-next-line no-eval
const result = eval(expression).toString();
pendingUpdates.push({
blockKey: currentBlockKey,
startIndex: originalStartIndex,
endIndex: startIndex,
text: result,
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(expression, error);
}
} else if (currentBlockText[startIndex] === '[') {
try {
endIndex = currentBlockText.indexOf(']', startIndex);
assert(endIndex !== -1, 'expected to find ] after [');
const linkText = currentBlockText.substring(startIndex + 1, endIndex);
startIndex = endIndex + 1;
assert(currentBlockText[startIndex] === '(', 'expected to find ( after ]');
endIndex = currentBlockText.indexOf(')', startIndex);
assert(endIndex !== -1, 'expected to find ) after (');
const linkHref = currentBlockText.substring(startIndex + 1, endIndex);
startIndex = endIndex + 1;
contentState = contentState.createEntity(
LINK_ENTITY_TYPE,
'IMMUTABLE',
{ url: linkHref },
);
pendingUpdates.push({
blockKey: currentBlockKey,
startIndex: originalStartIndex,
endIndex: startIndex,
text: linkText,
entityKey: contentState.getLastCreatedEntityKey(),
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(error);
}
} else {
startIndex += 1;
}
}
});
pendingUpdates.reverse().forEach(({
blockKey, startIndex, endIndex, text, entityKey,
}) => {
let selectionState = SelectionState.createEmpty(blockKey);
selectionState = selectionState.merge({
anchorOffset: startIndex,
focusOffset: endIndex,
hasFocus: true,
});
contentState = Modifier.replaceText(
contentState,
selectionState,
text,
null,
entityKey || null,
);
});
return contentState;
}
}
RichTextUtils.StorageType = StorageType;
export default RichTextUtils;
================================================
FILE: src/common/SocketRPC.js
================================================
import assert from 'assert';
const SERVER_SIDE = 'server_side';
const CLIENT_SIDE = 'client_side';
const GENERAL_REQUEST = 'general-request-';
const GENERAL_RESPONSE = 'general-response-';
const GENERAL_SUBSCRIPTION = 'general-subscription';
const LOG_SUBSCRIPTION = 'log-subscription';
function _remove(list, value) {
const index = list.indexOf(value);
if (index !== -1) list.splice(index, 1);
}
export default class SocketRPC {
static clients = [];
static server(socket, actions) {
const instance = new SocketRPC(SERVER_SIDE, socket);
actions.registerBroadcast(instance);
instance.registerActions(actions);
return instance;
}
static client(socket, thenCallback, catchCallback) {
const instance = new SocketRPC(CLIENT_SIDE, socket, thenCallback, catchCallback);
instance.registerSubscriptions();
return instance;
}
constructor(type, socket, thenCallback, catchCallback) {
this.type = type;
this.socket = socket;
if (type === SERVER_SIDE) {
SocketRPC.clients.push(this);
this.socket.on('disconnect', () => _remove(SocketRPC.clients, this));
} else if (type === CLIENT_SIDE) {
this.counter = 0;
this.subscriptions = {};
this.thenCallback = thenCallback;
this.catchCallback = catchCallback;
}
}
// Normal RPC
send(name, request) {
assert(this.type === CLIENT_SIDE);
const promise = new Promise((resolve, reject) => {
this.counter += 1;
const { counter } = this;
const responseEventName = GENERAL_RESPONSE + counter.toString();
this.socket.once(responseEventName, (wrapper) => {
const { response, error } = wrapper;
if (!error) {
resolve(response);
} else {
reject(error);
}
});
this.socket.emit(GENERAL_REQUEST, { counter, name, request });
});
return promise
.then((data) => {
this.thenCallback(name, request, data);
return data;
})
.catch((error) => {
this.catchCallback(name, request, error);
throw error; // this can be ignored
});
}
registerActions(actions) {
assert(this.type === SERVER_SIDE);
this.socket.on(GENERAL_REQUEST, async (wrapper) => {
const { counter, name, request } = wrapper;
const complete = false;
const responseEventName = GENERAL_RESPONSE + counter.toString();
const resolve = (response) => {
assert(!complete, 'already completed');
this.socket.emit(responseEventName, { counter, response });
};
const reject = (error) => {
assert(!complete, 'already completed');
error = `${name}: ${JSON.stringify(request, null, 4)}\n\n${error}`;
this.socket.emit(responseEventName, { counter, error });
};
if (!actions.has(name)) {
reject(`Unknown action: ${name}`);
return;
}
try {
const result = await actions.invoke(name, request);
resolve(result || null);
} catch (error) {
reject(error.stack.toString());
}
});
}
// Subscriptions
registerSubscriptions() {
assert(this.type === CLIENT_SIDE);
this.socket.on(GENERAL_SUBSCRIPTION, async (wrapper) => {
const { name, data } = wrapper;
const futures = this.subscriptions[name];
if (futures) {
futures.forEach(({ resolve }) => resolve(data));
}
delete this.subscriptions[name];
});
this.socket.on(LOG_SUBSCRIPTION, async (wrapper) => {
const { args } = wrapper;
try {
const [level, ...moreArgs] = args;
// eslint-disable-next-line no-console
console[level](...moreArgs);
} catch {
// eslint-disable-next-line no-console
console.error(...args);
}
});
}
subscribe(name) {
assert(this.type === CLIENT_SIDE);
if (!(name in this.subscriptions)) {
this.subscriptions[name] = [];
}
let future;
const promise = new Promise((resolve, reject) => {
future = { resolve, reject };
this.subscriptions[name].push(future);
});
const cancel = () => _remove(this.subscriptions[name], future);
return { promise, cancel };
}
broadcast(name, data) {
assert(this.type === SERVER_SIDE);
SocketRPC.clients.forEach(
(client) => client.socket.emit(GENERAL_SUBSCRIPTION, { name, data }),
);
}
/**
* This is separate from the broadcast method because those are buffered
* until the transaction is successfully completed.
*/
log(...args) {
assert(this.type === SERVER_SIDE);
SocketRPC.clients.forEach((client) => client.socket.emit(LOG_SUBSCRIPTION, { args }));
}
}
================================================
FILE: src/common/__tests__/RichTextUtils.test.js
================================================
import RichTextUtils from '../RichTextUtils';
const { StorageType } = RichTextUtils;
const typeToValue = {
[StorageType.MARKDOWN]: 'markdown:Normal [Kirtivardhan Rathore](mention:log-topic:3) [Link](facebook.com) Text\n\n# Heading 1\n\n#### Heading 4\n\n```\nCode\n```\n\n> Quote\n\n- List Item 1\n- List Item 2',
[StorageType.DRAFTJS]: 'draftjs:{"blocks":[{"key":"3f9if","text":"Normal Kirtivardhan Rathore Link Text","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[{"offset":7,"length":20,"key":0},{"offset":28,"length":4,"key":1}],"data":{}},{"key":"c8ie","text":"Heading 1","type":"header-one","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"eq8v6","text":"Heading 4","type":"header-four","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"b674h","text":"Code","type":"code-block","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"12dll","text":"Quote","type":"blockquote","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"288di","text":"List Item 1","type":"unordered-list-item","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"3hmqp","text":"List Item 2","type":"unordered-list-item","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{"0":{"type":"mention","mutability":"SEGMENTED","data":{"mention":{"__type__":"log-topic","__id__":3}}},"1":{"type":"LINK","mutability":"MUTABLE","data":{"url":"facebook.com"}}}}',
};
function verify(inputType, outputType) {
const expectedValue = typeToValue[outputType];
const actualValue = RichTextUtils.serialize(
RichTextUtils.deserialize(typeToValue[inputType], inputType),
outputType,
);
if (outputType === StorageType.DRAFTJS) {
const value1 = RichTextUtils.deserialize(expectedValue, StorageType.DRAFTJS);
const value2 = RichTextUtils.deserialize(actualValue, StorageType.DRAFTJS);
expect(RichTextUtils.equals(value1, value2)).toBeTruthy();
} else {
expect(actualValue).toEqual(expectedValue);
}
}
test('test_storage_type_conversion', () => {
verify(StorageType.MARKDOWN, StorageType.MARKDOWN);
verify(StorageType.MARKDOWN, StorageType.DRAFTJS);
verify(StorageType.DRAFTJS, StorageType.MARKDOWN);
verify(StorageType.DRAFTJS, StorageType.DRAFTJS);
});
================================================
FILE: src/common/data_types/LogEvent.js
================================================
import assert from 'assert';
import RichTextUtils from '../RichTextUtils';
import DataTypeBase from './base';
import LogKey from './LogKey';
import LogStructure from './LogStructure';
import { getVirtualID } from './utils';
import { validateRecursive } from './validation';
const { LogLevel } = LogStructure;
class LogEvent extends DataTypeBase {
static createVirtual({
date = null,
title = null,
details = null,
logLevel = LogLevel.getIndex(LogLevel.NORMAL),
logStructure = null,
isFavorite = false,
isComplete = true,
}) {
if (typeof date !== 'string' || date.match(/\(/)) {
// In case the filters are gt(null) or lt(null) or dateRange, default to null.
date = null;
}
// Abstraction leak! The LogEventSearch component filters to logLevels = [2,3] by default.
if (Array.isArray(logLevel)) {
[logLevel] = logLevel;
}
const logEvent = {
__type__: 'log-event',
date,
orderingIndex: null,
__id__: getVirtualID(),
title,
details,
logLevel,
logStructure,
isFavorite,
isComplete,
};
LogEvent.addDefaultStructureValues(logEvent);
LogEvent.trigger(logEvent);
return logEvent;
}
static addDefaultStructureValues(logEvent) {
if (logEvent.logStructure) {
logEvent.logStructure.eventKeys = logEvent.logStructure.eventKeys.map((logKey) => ({
...logKey,
value: logKey.value
|| LogKey.Type[logKey.type].getDefault(logKey)
|| null,
}));
}
}
static async updateWhere(where) {
if (where.date && typeof where.date === 'object') {
where.date = {
[this.database.Op.gte]: where.date.startDate,
[this.database.Op.lte]: where.date.endDate,
};
} else if (typeof where.date === 'string') {
const reResult = where.date.match(/^(\w+)\(([\w-]+)\)$/);
if (reResult) {
const operator = this.database.Op[reResult[1]];
const value = reResult[1] === 'null' ? null : reResult[2];
where.date = { [operator]: value };
}
}
if (where.title) {
where.title = {
[this.database.Op.like]: `%${where.title}%`,
};
}
if (where.details) {
where.details = {
[this.database.Op.like]: `%${where.details}%`,
};
}
await DataTypeBase.updateWhere.call(this, where, {
date: 'date',
title: 'title',
details: 'details',
logStructure: 'structure_id',
isFavorite: 'is_favorite',
isComplete: 'is_complete',
logLevel: 'log_level',
});
}
static trigger(logEvent) {
if (logEvent.logStructure) {
const getLogKeyValue = (logKey) => logKey.value || (logKey.isOptional ? '' : logKey);
logEvent.logStructure.eventKeys.forEach((logKey, index) => {
if (!logKey.template) {
return;
}
const previousEventKeys = logEvent.logStructure.eventKeys.slice(0, index);
logKey.value = RichTextUtils.extractPlainText(
RichTextUtils.updateDraftContent(
logKey.template,
previousEventKeys,
previousEventKeys.map(getLogKeyValue),
true, // evaluateExpressions
),
);
});
logEvent.title = RichTextUtils.updateDraftContent(
logEvent.logStructure.eventTitleTemplate,
logEvent.logStructure.eventKeys,
logEvent.logStructure.eventKeys.map((logKey) => logKey.value || (logKey.isOptional ? '' : logKey)),
true, // evaluateExpressions
);
logEvent.logLevel = logEvent.logStructure.logLevel;
}
}
static async updateLogTopicsInTitleAndDetails(inputLogEvent) {
const originalLogTopics = Object.values({
...RichTextUtils.extractMentions(inputLogEvent.title, 'log-topic'),
...RichTextUtils.extractMentions(inputLogEvent.details, 'log-topic'),
});
const updatedLogTopics = await Promise.all(
originalLogTopics.map((originalTopic) => this.invoke.call(
this,
'log-topic-load-partial',
originalTopic,
)),
);
inputLogEvent.title = RichTextUtils.updateDraftContent(
inputLogEvent.title,
originalLogTopics,
updatedLogTopics,
);
inputLogEvent.details = RichTextUtils.updateDraftContent(
inputLogEvent.details,
originalLogTopics,
updatedLogTopics,
);
return updatedLogTopics.map((logTopic) => logTopic.__id__);
}
static async updateLogTopics(inputLogEvent) {
const promises = [];
promises.push(LogEvent.updateLogTopicsInTitleAndDetails.call(this, inputLogEvent));
if (inputLogEvent.logStructure) {
inputLogEvent.logStructure.eventKeys.forEach((inputLogKey) => {
promises.push(LogKey.updateLogTopics.call(this, inputLogKey));
});
}
const listOfTopicIDs = await Promise.all(promises);
return listOfTopicIDs.flat();
}
static async validate(inputLogEvent) {
const results = [];
results.push([
'.title',
!!inputLogEvent.title,
'must be non-empty.',
]);
if (inputLogEvent.logStructure) {
const logStructureResults = await validateRecursive.call(
this,
LogStructure,
'.logStructure',
inputLogEvent.logStructure,
);
results.push(...logStructureResults);
results.push([
'.logStructure.eventAllowDetails',
inputLogEvent.logStructure.eventAllowDetails
? true
: inputLogEvent.details === null,
'does not allow .details',
]);
const logKeyResults = await Promise.all(
inputLogEvent.logStructure.eventKeys.map(
async (inputLogKey, index) => LogKey.validateValue.call(
this,
inputLogKey,
index,
),
),
);
results.push(...logKeyResults.filter((result) => result));
}
if (inputLogEvent.isComplete) {
results.push([
'.date',
inputLogEvent.date !== null,
'should not be null.',
]);
}
return results;
}
static async load(id) {
const logEvent = await this.database.findByPk('LogEvent', id);
let outputLogStructure = null;
if (logEvent.structure_id) {
outputLogStructure = await LogStructure.load.call(this, logEvent.structure_id);
const structureValues = JSON.parse(logEvent.structure_values);
outputLogStructure.eventKeys.forEach((logKey, index) => {
logKey.value = structureValues[index] || null;
});
} else {
assert(logEvent.structure_values === null);
}
return {
__type__: 'log-event',
__id__: logEvent.id,
date: logEvent.date,
isComplete: logEvent.is_complete,
orderingIndex: logEvent.ordering_index,
title: RichTextUtils.deserialize(
logEvent.title,
RichTextUtils.StorageType.DRAFTJS,
),
details: RichTextUtils.deserialize(
logEvent.details,
RichTextUtils.StorageType.DRAFTJS,
),
logLevel: logEvent.log_level,
isFavorite: logEvent.is_favorite,
logStructure: outputLogStructure,
};
}
static async save(inputLogEvent) {
let logEvent = await this.database.findItem('LogEvent', inputLogEvent);
DataTypeBase.broadcast.call(this, 'log-event-list', logEvent, { date: inputLogEvent.date });
// Before the serialization process, since the input is modified.
const targetLogTopicIDs = await LogEvent.updateLogTopics.call(this, inputLogEvent);
const shouldResetOrderingIndex = logEvent ? (
logEvent.date !== inputLogEvent.date
|| logEvent.is_complete !== inputLogEvent.isComplete
) : true;
const orderingIndexWhere = {
date: inputLogEvent.date,
is_complete: inputLogEvent.isComplete,
};
const orderingIndex = await DataTypeBase.getOrderingIndex
.call(this, shouldResetOrderingIndex ? null : logEvent, orderingIndexWhere);
let logValues;
if (inputLogEvent.logStructure) {
logValues = inputLogEvent.logStructure.eventKeys.map(
(eventKey) => eventKey.value || null,
);
}
const updated = {
date: inputLogEvent.date,
ordering_index: orderingIndex,
title: RichTextUtils.serialize(
inputLogEvent.title,
RichTextUtils.StorageType.DRAFTJS,
),
details: RichTextUtils.serialize(
inputLogEvent.details,
RichTextUtils.StorageType.DRAFTJS,
),
log_level: inputLogEvent.logLevel,
is_favorite: inputLogEvent.isFavorite,
is_complete: inputLogEvent.isComplete,
structure_id: inputLogEvent.logStructure ? inputLogEvent.logStructure.__id__ : null,
structure_values: logValues ? JSON.stringify(logValues) : null,
};
logEvent = await this.database.createOrUpdateItem('LogEvent', logEvent, updated);
await this.database.setEdges(
'LogEventToLogTopic',
'source_event_id',
logEvent.id,
'target_topic_id',
Object.values(targetLogTopicIDs).reduce((result, topicID) => {
// eslint-disable-next-line no-param-reassign
result[topicID] = {};
return result;
}, {}),
);
await this.invoke.call(
this,
'structure-value-typeahead-index-$refresh',
{ structure_id: logEvent.structure_id },
);
this.broadcast('reminder-sidebar');
return logEvent.id;
}
static async delete(id) {
const logEvent = await this.database.deleteByPk('LogEvent', id);
DataTypeBase.broadcast.call(this, 'log-event-list', logEvent, ['date']);
await this.invoke.call(
this,
'structure-value-typeahead-index-$refresh',
{ structure_id: logEvent.structure_id },
);
return { __id__: logEvent.id };
}
}
LogEvent.LogLevel = LogLevel;
export default LogEvent;
================================================
FILE: src/common/data_types/LogKey.js
================================================
import RichTextUtils from '../RichTextUtils';
import Enum from './enum';
import { getPartialItem, getVirtualID } from './utils';
import { validateNonEmptyString } from './validation';
const LogKeyType = Enum([
{
value: 'string',
label: 'String',
validator: async () => true,
getDefault: () => '',
},
{
value: 'string_list',
label: 'String List',
validator: async (value) => Array.isArray(value),
getDefault: () => [],
},
{
value: 'integer',
label: 'Integer',
validator: async (value) => !!value.match(/^\d+$/),
getDefault: () => '',
},
{
value: 'number',
label: 'Number',
validator: async (value) => !!value.match(/^\d+(?:\.\d+)?$/),
getDefault: () => '',
},
{
value: 'time',
label: 'Time',
validator: async (value) => !!value.match(/^\d{2}:\d{2}$/),
getDefault: () => '',
},
{
value: 'yes_or_no',
label: 'Yes / No',
validator: async (value) => !!value.match(/^(?:yes|no)$/),
getDefault: () => 'no',
},
{
value: 'enum',
label: 'Enum',
validator: async (value, logKey) => logKey.enumValues.includes(value),
getDefault: (logKey) => logKey.enumValues[0],
},
{
value: 'log_topic',
label: 'Topic',
validator: async (value, logKey, that) => {
const logTopic = await that.invoke.call(that, 'log-topic-load', value);
return logTopic.parentLogTopic.__id__ === logKey.parentLogTopic.__id__;
},
getDefault: () => null,
},
{
value: 'rich_text_line',
label: 'Rich Text Line',
validator: async (value) => true,
getDefault: () => null,
},
{
value: 'link',
label: 'Link',
validator: async (value) => true,
getDefault: () => '',
},
]);
class LogKey {
static createVirtual() {
return {
__type__: 'log-structure-key',
__id__: getVirtualID(),
name: '',
type: LogKeyType.STRING,
isOptional: false,
template: null,
enumValues: [],
parentLogTopic: null,
};
}
static async validate(inputLogKey) {
const results = [];
results.push(validateNonEmptyString('.name', inputLogKey.name));
results.push(validateNonEmptyString('.type', inputLogKey.type));
if (inputLogKey.type === LogKeyType.ENUM) {
results.push([
'.enumValues',
inputLogKey.enumValues.length > 0,
'must be provided!',
]);
} if (inputLogKey.type === LogKeyType.LOG_TOPIC) {
results.push([
'.parentLogTopic',
inputLogKey.parentLogTopic,
'must be provided!',
]);
}
return results;
}
static async validateValue(inputLogKey, index) {
if (inputLogKey.isOptional && !inputLogKey.value) return null;
const name = `.logKeys[${index}].value`;
if (!inputLogKey.value) return [name, false, 'must be non-empty.'];
const KeyOption = LogKeyType[inputLogKey.type];
let isValid = await KeyOption.validator(inputLogKey.value, inputLogKey, this);
if (!isValid && KeyOption.maybeFix) {
const fixedValue = KeyOption.maybeFix(inputLogKey.value, inputLogKey);
if (fixedValue) {
inputLogKey.value = fixedValue;
isValid = true;
}
}
return [name, isValid, 'fails validation for specified type.'];
}
static async load(rawLogKey, index) {
let parentLogTopic = null;
if (rawLogKey.parent_topic_id) {
// Normally, we would use "log-topic-load" here, but it does a lot of extra work.
const logTopic = await this.database.findByPk('LogTopic', rawLogKey.parent_topic_id);
parentLogTopic = {
__type__: 'log-topic',
__id__: logTopic.id,
name: logTopic.name,
};
}
return {
__type__: 'log-structure-key',
__id__: index,
name: rawLogKey.name,
type: rawLogKey.type,
template: rawLogKey.template || null,
isOptional: rawLogKey.is_optional || false,
enumValues: rawLogKey.enum_values || [],
parentLogTopic,
};
}
static save(inputLogKey) {
const result = {
name: inputLogKey.name,
type: inputLogKey.type,
};
if (inputLogKey.isOptional) {
result.is_optional = true;
}
if (inputLogKey.template) {
result.template = inputLogKey.template;
}
if (inputLogKey.type === LogKeyType.ENUM && inputLogKey.enumValues) {
result.enum_values = inputLogKey.enumValues;
}
if (inputLogKey.type === LogKeyType.LOG_TOPIC && inputLogKey.parentLogTopic) {
result.parent_topic_id = inputLogKey.parentLogTopic.__id__;
}
return result;
}
static async updateLogTopicsInLogTopicType(inputLogKey) {
const originalLogTopics = [];
originalLogTopics.push(inputLogKey.parentLogTopic);
if (inputLogKey.value) {
originalLogTopics.push(inputLogKey.value);
}
const updatedLogTopics = await Promise.all(
originalLogTopics.map((originalLogTopic) => this.invoke.call(
this,
'log-topic-load-partial',
originalLogTopic,
)),
);
inputLogKey.parentLogTopic = getPartialItem(updatedLogTopics[0]);
if (inputLogKey.value) {
inputLogKey.value = getPartialItem(updatedLogTopics[1]);
}
return updatedLogTopics.map((logTopic) => logTopic.__id__);
}
static async updateLogTopicsInRichTextLineType(inputLogKey) {
const originalLogTopics = Object.values(RichTextUtils.extractMentions(inputLogKey.value, 'log-topic'));
const updatedLogTopics = await Promise.all(
originalLogTopics.map((originalLogTopic) => this.invoke.call(
this,
'log-topic-load-partial',
originalLogTopic,
)),
);
inputLogKey.value = RichTextUtils.updateDraftContent(
inputLogKey.value,
originalLogTopics,
updatedLogTopics,
);
return updatedLogTopics.map((logTopic) => logTopic.__id__);
}
static async updateLogTopics(inputLogKey) {
if (inputLogKey.type === LogKeyType.LOG_TOPIC) {
return LogKey.updateLogTopicsInLogTopicType.call(this, inputLogKey);
} if (inputLogKey.type === LogKeyType.RICH_TEXT_LINE) {
return LogKey.updateLogTopicsInRichTextLineType.call(this, inputLogKey);
}
return [];
}
}
LogKey.Type = LogKeyType;
export default LogKey;
================================================
FILE: src/common/data_types/LogStructure.js
================================================
import { asyncSequence } from '../AsyncUtils';
import RichTextUtils from '../RichTextUtils';
import DataTypeBase from './base';
import Enum from './enum';
import LogKey from './LogKey';
import LogStructureFrequency from './LogStructureFrequency';
import LogStructureGroup from './LogStructureGroup';
import { getPartialItem, getVirtualID, isVirtualItem } from './utils';
import { validateRecursive, validateRecursiveList } from './validation';
const LogLevel = Enum([
{
value: 'minor',
label: 'Minor (1)',
index: 1,
},
{
value: 'normal',
label: 'Normal (2)',
index: 2,
},
{
value: 'major',
label: 'Major (3)',
index: 3,
},
]);
LogLevel.getIndex = (value) => LogLevel[value].index;
LogLevel.getValue = (index) => LogLevel.Options[index - 1].value;
class LogStructure extends DataTypeBase {
static createVirtual({ logStructureGroup, name = '' }) {
return {
__type__: 'log-structure',
__id__: getVirtualID(),
logStructureGroup,
name,
details: null,
eventAllowDetails: false,
eventKeys: [],
eventTitleTemplate: null,
eventNeedsEdit: false,
isPeriodic: false,
reminderText: null,
frequency: null,
frequencyArgs: null,
warningDays: null,
suppressUntilDate: null,
logLevel: LogLevel.getIndex(LogLevel.NORMAL),
isFavorite: false,
isDeprecated: false,
};
}
static async updateWhere(where) {
await DataTypeBase.updateWhere.call(this, where, {
__id__: 'id',
logStructureGroup: 'group_id',
name: 'name',
isPeriodic: 'is_periodic',
isFavorite: 'is_favorite',
isDeprecated: 'is_deprecated',
});
}
static trigger(logStructure) {
// TODO: If an eventKey is deleted, remove it from the content.
const options = [getPartialItem(logStructure), ...logStructure.eventKeys];
if (logStructure.name && !logStructure.eventTitleTemplate) {
logStructure.eventTitleTemplate = RichTextUtils.convertPlainTextToDraftContent('$0', {
$: [logStructure],
});
}
logStructure.eventTitleTemplate = RichTextUtils.updateDraftContent(
logStructure.eventTitleTemplate,
options,
options,
);
if (logStructure.eventKeys.length) {
logStructure.eventNeedsEdit = true;
}
}
static async updateLogTopicsInTitleTemplateAndDetails(inputLogStructure) {
const originalLogTopics = Object.values({
...RichTextUtils.extractMentions(inputLogStructure.eventTitleTemplate, 'log-topic'),
...RichTextUtils.extractMentions(inputLogStructure.details, 'log-topic'),
});
const updatedLogTopics = await Promise.all(
originalLogTopics.map((originalTopic) => this.invoke.call(
this,
'log-topic-load-partial',
originalTopic,
)),
);
inputLogStructure.eventTitleTemplate = RichTextUtils.updateDraftContent(
inputLogStructure.eventTitleTemplate,
originalLogTopics,
updatedLogTopics,
);
inputLogStructure.details = RichTextUtils.updateDraftContent(
inputLogStructure.details,
originalLogTopics,
updatedLogTopics,
);
return updatedLogTopics.map((logTopic) => logTopic.__id__);
}
static async updateLogTopics(inputLogStructure) {
const promises = [];
promises.push(
LogStructure.updateLogTopicsInTitleTemplateAndDetails.call(this, inputLogStructure),
);
inputLogStructure.eventKeys.forEach((inputLogKey) => {
promises.push(LogKey.updateLogTopics.call(this, inputLogKey));
});
const listOfTopicIDs = await Promise.all(promises);
return listOfTopicIDs.flat();
}
static async validate(inputLogStructure) {
const results = [];
if (inputLogStructure.logStructureGroup) {
const logStructureGroupResults = await validateRecursive.call(
this,
LogStructureGroup,
'.logStructureGroup',
inputLogStructure.logStructureGroup,
);
results.push(...logStructureGroupResults);
} else {
results.push([
'.logStructureGroup',
false,
'must be provided!',
]);
}
results.push(...await validateRecursiveList.call(
this,
LogKey,
'.eventKeys',
inputLogStructure.eventKeys,
));
results.push([
'.eventTitleTemplate',
inputLogStructure.__id__ in RichTextUtils.extractMentions(
inputLogStructure.eventTitleTemplate,
'log-structure',
),
'must mention the structure!',
]);
if (inputLogStructure.isPeriodic) {
results.push([
'.isPeriodic',
inputLogStructure.frequency !== null
&& inputLogStructure.suppressUntilDate !== null,
'requires frequency & suppressUntilDate is set.',
]);
} else {
results.push([
'.isPeriodic',
inputLogStructure.frequency === null
&& inputLogStructure.suppressUntilDate === null,
'requires frequency & suppressUntilDate to be unset.',
]);
}
return results;
}
static async load(id) {
const logStructure = await this.database.findByPk('LogStructure', id);
const outputLogStructureGroup = await this.invoke.call(
this,
'log-structure-group-load',
{ __id__: logStructure.group_id },
);
const eventKeys = await Promise.all(
JSON.parse(logStructure.event_keys).map(
(eventKey, index) => LogKey.load.call(this, eventKey, index + 1),
),
);
return {
__type__: 'log-structure',
__id__: logStructure.id,
logStructureGroup: outputLogStructureGroup,
name: logStructure.name,
details: RichTextUtils.deserialize(
logStructure.details,
RichTextUtils.StorageType.DRAFTJS,
),
eventAllowDetails: logStructure.event_allow_details,
eventKeys,
eventTitleTemplate: RichTextUtils.deserialize(
logStructure.event_title_template,
RichTextUtils.StorageType.DRAFTJS,
),
eventNeedsEdit: logStructure.event_needs_edit,
isPeriodic: logStructure.is_periodic,
reminderText: logStructure.reminder_text,
frequency: logStructure.frequency,
frequencyArgs: logStructure.frequency_args,
warningDays: logStructure.warning_days,
suppressUntilDate: logStructure.suppress_until_date,
logLevel: logStructure.log_level,
isFavorite: logStructure.is_favorite,
isDeprecated: logStructure.is_deprecated,
};
}
static async save(inputLogStructure) {
const logStructure = await this.database.findItem('LogStructure', inputLogStructure);
const originalLogStructure = logStructure ? { ...logStructure.dataValues } : null;
DataTypeBase.broadcast.call(
this,
'log-structure-list',
logStructure,
{ group_id: inputLogStructure.logStructureGroup.__id__ },
);
// Before the serialization process, since the input is modified.
const targetLogTopicIDs = await LogStructure.updateLogTopics.call(this, inputLogStructure);
const orderingIndex = await DataTypeBase.getOrderingIndex.call(this, logStructure);
const updated = {
group_id: inputLogStructure.logStructureGroup.__id__,
ordering_index: orderingIndex,
name: inputLogStructure.name,
details: RichTextUtils.serialize(
inputLogStructure.details,
RichTextUtils.StorageType.DRAFTJS,
),
event_allow_details: inputLogStructure.eventAllowDetails,
event_keys: JSON.stringify(inputLogStructure.eventKeys.map(
(eventKey) => LogKey.save.call(this, eventKey),
)),
event_title_template: RichTextUtils.serialize(
inputLogStructure.eventTitleTemplate,
RichTextUtils.StorageType.DRAFTJS,
),
event_needs_edit: inputLogStructure.eventNeedsEdit,
is_periodic: inputLogStructure.isPeriodic,
reminder_text: inputLogStructure.reminderText,
frequency: inputLogStructure.frequency,
frequency_args: inputLogStructure.frequencyArgs,
warning_days: inputLogStructure.warningDays,
suppress_until_date: inputLogStructure.suppressUntilDate,
log_level: inputLogStructure.logLevel,
is_favorite: inputLogStructure.isFavorite,
is_deprecated: inputLogStructure.isDeprecated,
};
// Fetch affected logEvents BEFORE updating the database.
// Why? To prevent loading the new log-structure from the log-event.
let inputLogEvents = null;
if (originalLogStructure) {
let shouldRegenerateLogEvents = false;
if (!shouldRegenerateLogEvents) {
shouldRegenerateLogEvents = (
originalLogStructure.name !== updated.name
|| originalLogStructure.event_keys !== updated.event_keys
);
}
if (!shouldRegenerateLogEvents) {
const originalTitleTemplate = RichTextUtils.deserialize(
originalLogStructure.event_title_template,
RichTextUtils.StorageType.DRAFTJS,
);
shouldRegenerateLogEvents = !RichTextUtils.equals(
originalTitleTemplate,
inputLogStructure.eventTitleTemplate,
);
}
if (!shouldRegenerateLogEvents) {
shouldRegenerateLogEvents = (
originalLogStructure.log_level !== updated.log_level
|| originalLogStructure.allow_event_details !== updated.allow_event_details
);
}
if (shouldRegenerateLogEvents) {
inputLogEvents = await this.invoke.call(
this,
'log-event-list',
{ where: { logStructure: inputLogStructure } },
);
}
}
const updatedLogStructure = await this.database.createOrUpdateItem('LogStructure', logStructure, updated);
await this.database.setEdges(
'LogStructureToLogTopic',
'source_structure_id',
updatedLogStructure.id,
'target_topic_id',
Object.values(targetLogTopicIDs).reduce((result, topicID) => {
// eslint-disable-next-line no-param-reassign
result[topicID] = {};
return result;
}, {}),
);
if (
!originalLogStructure
|| inputLogStructure.eventKeys.some((eventKey) => isVirtualItem(eventKey))
) {
// On creation of logStructure or update of eventKeys,
// replace the virtual IDs in the title template.
const originalItems = [inputLogStructure, ...inputLogStructure.eventKeys];
const updatedItems = originalItems.map((item, index) => ({
...getPartialItem(item),
__id__: index || updatedLogStructure.id,
}));
const updatedTitleTemplate = RichTextUtils.updateDraftContent(
inputLogStructure.eventTitleTemplate,
originalItems,
updatedItems,
);
const transaction = this.database.getTransaction();
const fields2 = {
event_title_template: RichTextUtils.serialize(
updatedTitleTemplate,
RichTextUtils.StorageType.DRAFTJS,
),
};
await updatedLogStructure.update(fields2, { transaction });
}
if (inputLogEvents) {
await asyncSequence(inputLogEvents, async (inputLogEvent) => {
// Update the logEvent to support eventKey addition, reorder, deletion.
const mapping = {};
inputLogEvent.logStructure.eventKeys.forEach((eventKey) => {
mapping[eventKey.__id__] = eventKey;
});
inputLogEvent.logStructure = {
...inputLogStructure,
eventKeys: inputLogStructure.eventKeys.map((eventKey) => ({
...eventKey,
value: (mapping[eventKey.__id__] || eventKey).value,
})),
};
return this.invoke.call(this, 'log-event-upsert', inputLogEvent);
});
}
await this.invoke.call(
this,
'structure-value-typeahead-index-$refresh',
{ structure_id: updatedLogStructure.id },
);
this.broadcast('reminder-sidebar');
return updatedLogStructure.id;
}
static async delete(id) {
const logStructure = await this.database.deleteByPk('LogStructure', id);
DataTypeBase.broadcast.call(this, 'log-structure-list', logStructure, ['group_id']);
await this.invoke.call(
this,
'structure-value-typeahead-index-$refresh',
{ structure_id: logStructure.id },
);
return { id: logStructure.id };
}
}
LogStructure.Frequency = LogStructureFrequency;
LogStructure.LogLevel = LogLevel;
export default LogStructure;
================================================
FILE: src/common/data_types/LogStructureFrequency.js
================================================
import {
addDays, addYears,
compareAsc,
getDay,
isFriday, isMonday, isSaturday, isSunday,
setDate, setMonth,
subDays, subYears,
} from 'date-fns';
import DateUtils from '../DateUtils';
import Enum from './enum';
const FrequencyRawOptions = [
{
value: 'everyday',
label: 'Everyday',
getPreviousMatch(date) {
return subDays(date, 1);
},
getNextMatch(date) {
return addDays(date, 1);
},
},
{
value: 'weekdays',
label: 'Weekdays',
getPreviousMatch(date) {
if (isMonday(date)) {
return subDays(date, 3);
} if (isSunday(date)) {
return subDays(date, 2);
}
return subDays(date, 1);
},
getNextMatch(date) {
if (isFriday(date)) {
return addDays(date, 3);
} if (isSaturday(date)) {
return addDays(date, 2);
}
return addDays(date, 1);
},
},
{
value: 'weekends',
label: 'Weekends',
getPreviousMatch(date) {
if (isSunday(date)) {
return subDays(date, 1);
}
return subDays(date, getDay(date));
},
getNextMatch(date) {
if (isSaturday(date)) {
return addDays(date, 1);
}
return addDays(date, 6 - getDay(date));
},
},
// TODO: Add more as needed.
];
DateUtils.DaysOfTheWeek.forEach((day, index) => {
FrequencyRawOptions.push({
value: day.toLowerCase(),
label: day,
getPreviousMatch(date) {
const diff = (getDay(date) - index + 7) % 7;
return subDays(date, diff || 7);
},
getNextMatch(date) {
const diff = (index - getDay(date) + 7) % 7;
return addDays(date, diff || 7);
},
});
});
function parseYearlyFrequencyArgs(args) {
let [month, dayOfTheMonth] = args.split('-');
month = parseInt(month, 10) - 1; // 0 = January
dayOfTheMonth = parseInt(dayOfTheMonth, 10);
return { month, dayOfTheMonth };
}
FrequencyRawOptions.push({
value: 'yearly',
label: 'Yearly',
getPreviousMatch(date, args) {
const { month, dayOfTheMonth } = parseYearlyFrequencyArgs(args);
let target = setDate(setMonth(date, month), dayOfTheMonth);
if (compareAsc(date, target) <= 0) {
target = subYears(target, 1);
}
return target;
},
getNextMatch(date, args) {
const { month, dayOfTheMonth } = parseYearlyFrequencyArgs(args);
let target = setDate(setMonth(date, month), dayOfTheMonth);
if (compareAsc(date, target) >= 0) {
target = addYears(target, 1);
}
return target;
},
});
export default Enum(FrequencyRawOptions);
================================================
FILE: src/common/data_types/LogStructureGroup.js
================================================
import DataTypeBase from './base';
import { getVirtualID } from './utils';
import { validateNonEmptyString } from './validation';
class LogStructureGroup extends DataTypeBase {
static createVirtual() {
return {
__type__: 'log-structure-group',
__id__: getVirtualID(),
name: '',
};
}
static async updateWhere(where) {
await DataTypeBase.updateWhere.call(this, where, {
__id__: 'id',
});
}
static async validate(inputLogStructureGroup) {
const results = [];
results.push(validateNonEmptyString('.name', inputLogStructureGroup.name));
return results;
}
static async load(id) {
const logStructureGroup = await this.database.findByPk('LogStructureGroup', id);
return {
__type__: 'log-structure-group',
__id__: logStructureGroup.id,
name: logStructureGroup.name,
};
}
static async save(inputLogStructureGroup) {
const originalLogStructureGroup = await this.database.findItem(
'LogStructureGroup',
inputLogStructureGroup,
);
const orderingIndex = await DataTypeBase.getOrderingIndex.call(
this,
originalLogStructureGroup,
);
const fields = {
ordering_index: orderingIndex,
name: inputLogStructureGroup.name,
};
const updatedLogStructureGroup = await this.database.createOrUpdateItem('LogStructureGroup', originalLogStructureGroup, fields);
if (originalLogStructureGroup) {
await LogStructureGroup.updateLogStructures.call(this, inputLogStructureGroup);
}
this.broadcast('log-structure-group-list');
return updatedLogStructureGroup.id;
}
static async updateLogStructures(inputLogStructureGroup) {
const inputLogStructures = await this.invoke.call(
this,
'log-structure-list',
{ where: { logStructureGroup: inputLogStructureGroup } },
);
await Promise.all(inputLogStructures.map(
async (inputLogStructure) => this.invoke.call(this, 'log-structure-upsert', inputLogStructure),
));
}
static async delete(id) {
const logStructureGroup = await this.database.deleteByPk('LogStructureGroup', id);
this.broadcast('log-structure-group-list');
return { __id__: logStructureGroup.id };
}
}
export default LogStructureGroup;
================================================
FILE: src/common/data_types/LogTopic.js
================================================
import { asyncSequence } from '../AsyncUtils';
import RichTextUtils from '../RichTextUtils';
import DataTypeBase from './base';
import LogKey from './LogKey';
import { getVirtualID } from './utils';
import { validateNonEmptyString, validateRecursiveList } from './validation';
class LogTopic extends DataTypeBase {
static createVirtual({ parentLogTopic = null, name = '' } = {}) {
if (parentLogTopic && parentLogTopic.childKeys) {
parentLogTopic.childKeys.forEach((inputLogKey) => {
if (!inputLogKey.isOptional) {
inputLogKey.value = LogKey.Type[inputLogKey.type].getDefault(inputLogKey);
}
});
}
return {
__type__: 'log-topic',
__id__: getVirtualID(),
parentLogTopic,
name,
details: null,
childKeys: null,
childCount: 0,
isFavorite: false,
isDeprecated: false,
};
}
static async updateWhere(where) {
await DataTypeBase.updateWhere.call(this, where, {
__id__: 'id',
isFavorite: 'is_favorite',
isDeprecated: 'is_deprecated',
parentLogTopic: 'parent_topic_id',
});
}
static trigger(inputLogTopic) {
if (inputLogTopic.parentLogTopic && inputLogTopic.parentLogTopic.childNameTemplate) {
const { childKeys } = inputLogTopic.parentLogTopic;
inputLogTopic.name = RichTextUtils.extractPlainText(
RichTextUtils.updateDraftContent(
inputLogTopic.parentLogTopic.childNameTemplate,
childKeys,
childKeys.map((logKey) => logKey.value || (logKey.isOptional ? '' : logKey)),
true, // evaluateExpressions
),
);
}
// Do nothing by default.
}
static async updateLogTopicInDetails(inputLogTopic) {
const originalLogTopics = Object.values(
RichTextUtils.extractMentions(inputLogTopic.details, 'log-topic'),
);
const updatedLogTopics = await Promise.all(
originalLogTopics.map((originalTopic) => this.invoke.call(
this,
'log-topic-load-partial',
originalTopic,
)),
);
inputLogTopic.details = RichTextUtils.updateDraftContent(
inputLogTopic.details,
originalLogTopics,
updatedLogTopics,
);
return updatedLogTopics.map((logTopic) => logTopic.__id__);
}
static async updateLogTopics(inputLogTopic) {
const promises = [];
promises.push(LogTopic.updateLogTopicInDetails.call(this, inputLogTopic));
if (inputLogTopic.parentLogTopic && inputLogTopic.parentLogTopic.childKeys) {
inputLogTopic.parentLogTopic.childKeys.forEach((inputLogKey) => {
promises.push(LogKey.updateLogTopics.call(this, inputLogKey));
});
}
const listOfTopicIDs = await Promise.all(promises);
return listOfTopicIDs.flat();
}
static async validate(inputLogTopic) {
const results = [];
results.push(validateNonEmptyString('.name', inputLogTopic.name));
if (inputLogTopic.childKeys) {
results.push(...await validateRecursiveList.call(
this,
LogKey,
'.childKeys',
inputLogTopic.childKeys,
));
}
if (inputLogTopic.parentLogTopic && inputLogTopic.parentLogTopic.childKeys) {
const logKeyResults = await Promise.all(
inputLogTopic.parentLogTopic.childKeys.map(
async (inputLogKey, index) => LogKey.validateValue.call(
this,
inputLogKey,
index,
),
),
);
results.push(...logKeyResults.filter((result) => result));
}
return results;
}
static async loadPartial(id) {
const logTopic = await this.database.findByPk('LogTopic', id);
return {
__type__: 'log-topic',
__id__: logTopic.id,
name: logTopic.name,
};
}
static async load(id) {
const logTopic = await this.database.findByPk('LogTopic', id);
let outputParentLogTopic = null;
if (logTopic.parent_topic_id) {
// Intentionally loading only partial data.
const parentLogTopic = await this.database.findByPk(
'LogTopic',
logTopic.parent_topic_id,
);
let outputParentChildKeys = null;
if (parentLogTopic.child_keys) {
outputParentChildKeys = await Promise.all(
JSON.parse(parentLogTopic.child_keys).map(
(logKey, index) => LogKey.load.call(this, logKey, index + 1),
),
);
const parentValues = JSON.parse(logTopic.parent_values);
outputParentChildKeys.forEach((logKey, index) => {
logKey.value = parentValues[index] || null;
});
}
outputParentLogTopic = {
__type__: 'log-topic',
__id__: parentLogTopic.id,
name: parentLogTopic.name,
childKeys: outputParentChildKeys,
childNameTemplate: RichTextUtils.deserialize(
parentLogTopic.child_name_template,
RichTextUtils.StorageType.DRAFTJS,
),
};
}
let outputChildKeys = null;
if (logTopic.child_keys) {
outputChildKeys = await Promise.all(
JSON.parse(logTopic.child_keys).map(
(logKey, index) => LogKey.load.call(this, logKey, index + 1),
),
);
}
return {
__type__: 'log-topic',
__id__: logTopic.id,
parentLogTopic: outputParentLogTopic,
name: logTopic.name,
details: RichTextUtils.deserialize(
logTopic.details,
RichTextUtils.StorageType.DRAFTJS,
),
childKeys: outputChildKeys,
childNameTemplate: RichTextUtils.deserialize(
logTopic.child_name_template,
RichTextUtils.StorageType.DRAFTJS,
),
childCount: logTopic.child_count,
isFavorite: logTopic.is_favorite,
isDeprecated: logTopic.is_deprecated,
};
}
static async save(inputLogTopic) {
let logTopic = await this.database.findItem('LogTopic', inputLogTopic);
const original = {};
if (logTopic) {
original.id = logTopic.id;
original.name = logTopic.name;
original.parent_topic_id = logTopic.parent_topic_id;
original.child_name_template = logTopic.child_name_template;
original.child_keys = logTopic.child_keys;
}
if (original.id && original.name !== inputLogTopic.name) {
// Update the name first, so that all referencing items
// that reference this topic can see the new name.
await this.database.update('LogTopic', {
id: original.id,
name: inputLogTopic.name,
});
}
// Before the serialization process, since the input is modified.
const targetLogTopicIDs = await LogTopic.updateLogTopics.call(this, inputLogTopic);
const orderingIndex = await DataTypeBase.getOrderingIndex.call(this, logTopic);
let childKeys;
if (inputLogTopic.childKeys) {
childKeys = inputLogTopic.childKeys.map(
(logKey) => LogKey.save.call(this, logKey),
);
}
let parentValues;
if (inputLogTopic.parentLogTopic && inputLogTopic.parentLogTopic.childKeys) {
parentValues = inputLogTopic.parentLogTopic.childKeys.map(
(logKey) => logKey.value || null,
);
}
const updated = {
parent_topic_id: inputLogTopic.parentLogTopic
? inputLogTopic.parentLogTopic.__id__
: null,
ordering_index: orderingIndex,
name: inputLogTopic.name,
details: RichTextUtils.serialize(
inputLogTopic.details,
RichTextUtils.StorageType.DRAFTJS,
),
child_keys: childKeys ? JSON.stringify(childKeys) : null,
child_name_template: RichTextUtils.serialize(
inputLogTopic.childNameTemplate,
RichTextUtils.StorageType.DRAFTJS,
),
parent_values: parentValues ? JSON.stringify(parentValues) : null,
child_count: 'invalid', // will be set below
is_favorite: inputLogTopic.isFavorite,
is_deprecated: inputLogTopic.isDeprecated,
};
DataTypeBase.broadcast.call(
this,
'log-topic-list',
logTopic,
{ parent_topic_id: updated.parent_topic_id },
);
let shouldUpdateChildTopics = false;
if (!shouldUpdateChildTopics && original.child_keys !== updated.child_keys) {
shouldUpdateChildTopics = true;
}
if (!shouldUpdateChildTopics) {
const originalChildNameTemplate = RichTextUtils.deserialize(
original.child_name_template,
RichTextUtils.StorageType.DRAFTJS,
);
if (!RichTextUtils.equals(originalChildNameTemplate, inputLogTopic.childNameTemplate)) {
shouldUpdateChildTopics = true;
}
}
let childLogTopics;
if (shouldUpdateChildTopics) {
childLogTopics = await this.invoke.call(
this,
'log-topic-list',
{ where: { parentLogTopic: inputLogTopic } },
);
updated.child_count = childLogTopics.length;
} else {
updated.child_count = await LogTopic.count.call(
this,
{ parent_topic_id: inputLogTopic.__id__ },
);
}
logTopic = await this.database.createOrUpdateItem('LogTopic', logTopic, updated);
await this.database.setEdges(
'LogTopicToLogTopic',
'source_topic_id',
logTopic.id,
'target_topic_id',
Object.values(targetLogTopicIDs).reduce((result, topicID) => {
// eslint-disable-next-line no-param-reassign
result[topicID] = {};
return result;
}, {}),
);
if (original.parent_topic_id !== updated.parent_topic_id) {
// Update counts on parent log topics.
const maybeUpdate = async (id) => {
if (!id) {
return;
}
const parentLogTopic = await this.invoke.call(this, 'log-topic-load', { __id__: id });
await this.invoke.call(this, 'log-topic-upsert', parentLogTopic);
};
await Promise.all([
maybeUpdate(original.parent_topic_id),
maybeUpdate(updated.parent_topic_id),
]);
}
if (original.id && original.name !== updated.name) {
// Update names on references items.
await Promise.all([
LogTopic.updateOtherEntities.call(
this,
'LogEventToLogTopic',
logTopic.id,
'source_event_id',
'log-event',
),
LogTopic.updateOtherEntities.call(
this,
'LogStructureToLogTopic',
logTopic.id,
'source_structure_id',
'log-structure',
),
LogTopic.updateOtherEntities.call(
this,
'LogTopicToLogTopic',
logTopic.id,
'source_topic_id',
'log-topic',
),
]);
}
if (shouldUpdateChildTopics) {
await asyncSequence(childLogTopics, async (childLogTopic) => {
// Update the childLogTopics to support logKey addition, reorder, deletion.
const mapping = {};
if (childLogTopic.parentLogTopic.childKeys) {
childLogTopic.parentLogTopic.childKeys.forEach((logKey) => {
mapping[logKey.__id__] = logKey;
});
}
childLogTopic.parentLogTopic = {
...inputLogTopic,
childKeys: inputLogTopic.childKeys.map((logKey) => ({
...logKey,
value: (mapping[logKey.__id__] || logKey).value,
})),
};
return this.invoke.call(this, 'log-topic-upsert', childLogTopic);
});
}
await this.invoke.call(
this,
'topic-value-typeahead-index-$refresh',
{ parent_topic_id: logTopic.parent_topic_id },
);
return logTopic.id;
}
static async updateOtherEntities(
junctionTableName,
targetTopicID,
junctionSourceName,
entityType,
) {
const edges = await this.database.getEdges(
junctionTableName,
'target_topic_id',
targetTopicID,
);
const inputItems = await Promise.all(
edges.map((edge) => this.invoke.call(
this,
`${entityType}-load`,
{ __id__: edge[junctionSourceName] },
)),
);
await Promise.all(inputItems.map(
(inputItem) => this.invoke.call(this, `${entityType}-upsert`, inputItem),
));
}
static async delete(id) {
const logTopic = await this.database.deleteByPk('LogTopic', id);
if (logTopic.parent_topic_id) {
const parentLogTopic = await this.invoke.call(
this,
'log-topic-load',
{ __id__: logTopic.parent_topic_id },
);
await this.invoke.call(this, 'log-topic-upsert', parentLogTopic);
}
DataTypeBase.broadcast.call(this, 'log-topic-list', logTopic, ['parent_topic_id']);
await this.invoke.call(
this,
'topic-value-typeahead-index-$refresh',
{ parent_topic_id: logTopic.parent_topic_id },
);
return { __id__: logTopic.id };
}
static async sort(input) {
const items = await this.database.findAll(
this.DataType.name,
input.where,
[['name', 'ASC']],
null, // limit
);
await Promise.all(items.map(
(item, index) => this.database.update(
this.DataType.name,
{ id: item.id, ordering_index: index },
),
));
this.broadcast(`${input.dataType}-list`, { where: input.where });
}
}
export default LogTopic;
================================================
FILE: src/common/data_types/__tests__/LogStructureFrequency.test.js
================================================
import { addDays, compareAsc } from 'date-fns';
import DateUtils from '../../DateUtils';
import LogStructureFrequency from '../LogStructureFrequency';
test('test_previous_and_next_match_methods', async () => {
// Verify the symmetry of the 2 frequency methods.
const { todayDate } = DateUtils.getContext();
LogStructureFrequency.Options.forEach((frequencyOption) => {
if (frequencyOption.value === LogStructureFrequency.YEARLY) {
return;
}
for (let offset = 0; offset < 7; offset += 1) {
const startDate = addDays(todayDate, offset);
const forwardDate = frequencyOption.getNextMatch(startDate);
const middleDate = frequencyOption.getPreviousMatch(forwardDate);
const backwardDate = frequencyOption.getPreviousMatch(middleDate);
const endDate = frequencyOption.getNextMatch(backwardDate);
expect(compareAsc(middleDate, endDate)).toEqual(0);
}
});
function check(frequency, date1, method, date2, args = null) {
const result = LogStructureFrequency[frequency][method](DateUtils.getDate(date1), args);
expect(DateUtils.getLabel(result)).toEqual(date2);
}
check('everyday', '2020-07-29', 'getPreviousMatch', '2020-07-28');
check('everyday', '2020-07-29', 'getNextMatch', '2020-07-30');
check('weekdays', '2020-08-03', 'getPreviousMatch', '2020-07-31');
check('weekdays', '2020-08-04', 'getPreviousMatch', '2020-08-03');
check('weekends', '2020-07-28', 'getNextMatch', '2020-08-01');
check('weekends', '2020-08-01', 'getNextMatch', '2020-08-02');
check('thursday', '2020-08-01', 'getPreviousMatch', '2020-07-30');
check('thursday', '2020-07-30', 'getNextMatch', '2020-08-06');
check('yearly', '2020-08-15', 'getPreviousMatch', '2020-08-12', '08-12');
check('yearly', '2020-08-12', 'getNextMatch', '2021-08-12', '08-12');
});
================================================
FILE: src/common/data_types/api.js
================================================
export default class DataTypeAPI {
// The process of adding a new data type should be consistent.
static createVirtual() { // create
// Usage? Client.
// Used by the client when it wants to create a new object of this type.
throw new Error('not implemented');
}
static trigger(_item) { // update
// Usage? Client & Server.
// Invoked whenever the object is updated by any user action.
throw new Error('not implemented');
}
static async validate(_item) {
// Usage? Client & Server.
// Take the canonical representation and return a list of violations.
throw new Error('not implemented');
}
static async where(_fields) { // filter, search
// Usage? Server.
// Translate a client-side query into a valid database query.
throw new Error('not implemented');
}
static async load(_id) {
// Usage? Server.
// Load the object from the database, and build the canonical representation.
throw new Error('not implemented');
}
static async save(_item) {
// Usage? Server.
// Take the canonical representation and write it to the database.
throw new Error('not implemented');
}
static async delete(_id) {
// Usage? Server.
// Delete the specified object from the database.
throw new Error('not implemented');
}
}
================================================
FILE: src/common/data_types/base.js
================================================
import assert from 'assert';
import { asyncSequence } from '../AsyncUtils';
import DataTypeAPI from './api';
import { isItem } from './utils';
function getDataType(name) {
return name.split(/(?=[A-Z])/).map((word) => word.toLowerCase()).join('-');
}
export default class DataTypeBase extends DataTypeAPI {
static async getValidationErrors(inputItem) {
const { DataType } = this;
const result = await DataType.validate.call(this, inputItem);
let prefix = DataType.name;
prefix = prefix[0].toLowerCase() + prefix.substring(1);
for (let jj = 0; jj < result.length; jj += 1) {
result[jj][0] = prefix + result[jj][0];
}
return result
.filter((item) => !item[1])
.map((item) => `${item[0]} ${item[2]}`);
}
static async updateLogTopicsWhere(where) {
// Special case! The logTopics filter is handled via junction tables,
// unlike the remaining fields that can be queried normally.
const junctionTableName = `${this.DataType.name}ToLogTopic`;
const junctionSourceName = {
LogTopic: 'source_topic_id',
LogStructure: 'source_structure_id',
LogEvent: 'source_event_id',
}[this.DataType.name];
assert(junctionSourceName);
const logTopicIds = where.logTopics.map((item) => item.__id__);
const edges = await this.database.getEdges(
junctionTableName,
'target_topic_id',
logTopicIds,
);
let itemIds;
if (logTopicIds.length > 1) {
// assuming AND operation, not OR
const counters = {};
edges.forEach((edge) => {
const id = edge[junctionSourceName];
counters[id] = (counters[id] || 0) + 1;
});
itemIds = Object.entries(counters)
.filter((pair) => pair[1] === logTopicIds.length)
.map((pair) => parseInt(pair[0], 10));
} else {
itemIds = edges.map((edge) => edge[junctionSourceName]);
}
delete where.logTopics;
assert(!where.id);
where.id = itemIds;
}
static async updateWhere(where, mapping) {
await asyncSequence(Object.keys(where), async (fieldName) => {
if (fieldName === 'logTopics') {
await DataTypeBase.updateLogTopicsWhere.call(this, where);
} else if (fieldName in mapping) {
const newFieldName = mapping[fieldName];
let value = where[fieldName];
value = isItem(value) ? value.__id__ : value;
where[newFieldName] = value;
if (fieldName !== newFieldName) {
delete where[fieldName];
}
} else {
assert(false, `undefined where mapping: ${fieldName}`);
}
});
}
static trigger(item) {
// Do nothing by default.
}
static async list(where, limit) {
const order = [['ordering_index', 'DESC']];
if (this.DataType.name === 'LogEvent') {
order.unshift(['date', 'DESC']);
}
const items = await this.database.findAll(this.DataType.name, where, order, limit);
return Promise.all(
items.reverse().map((item) => this.DataType.load.call(this, item.id)),
);
}
static async count(where) {
return this.database.count(this.DataType.name, where);
}
// eslint-disable-next-line no-unused-vars
static async typeahead({ query, where }) {
if (
{
LogTopic: true,
LogStructure: true,
}[this.DataType.name]
) {
where = { ...where, is_deprecated: false };
}
const options = await this.database.findAll(
this.DataType.name,
{ ...where, name: { [this.database.Op.substring]: query } },
);
const dataType = getDataType(this.DataType.name);
const items = options.map((option) => ({
__type__: dataType,
__id__: option.id,
name: option.name,
})).sort((left, right) => left.name.localeCompare(right.name));
const first = [];
const second = [];
const third = [];
query = query.toLowerCase();
items.forEach((item) => {
if (item.name === query) {
first.push(item);
} else if (item.name.toLowerCase().startsWith(query)) {
second.push(item);
} else { // item.name.toLowerCase().includes(query)
third.push(item);
}
});
return [...first, ...second, ...third];
}
// eslint-disable-next-line no-unused-vars
static async reorder(input) {
// The client-side does not know the underscore names used in the database.
// Is it possible to add a mysql index to prevent conflicts?
const items = await Promise.all(input.ordering.map(
(id, index) => this.database.update(
this.DataType.name,
{ id, ordering_index: index },
),
));
this.broadcast(`${input.dataType}-list`, { where: input.where });
return items.map((item) => item.id);
}
static async getOrderingIndex(item, where = {}) {
if (item) {
return item.ordering_index;
}
return this.database.count(this.DataType.name, where, null);
}
static async broadcast(queryName, prevItem, fields) {
if (!this.DataType) {
return;
}
if (Array.isArray(fields)) {
fields.forEach((fieldName) => {
const prevValue = prevItem ? prevItem[fieldName] : null;
this.broadcast(queryName, { where: { [fieldName]: prevValue } });
});
} else {
Object.entries(fields).forEach(([fieldName, nextValue]) => {
if (prevItem) {
const prevValue = prevItem[fieldName];
this.broadcast(queryName, { where: { [fieldName]: prevValue } });
}
this.broadcast(queryName, { where: { [fieldName]: nextValue } });
});
}
}
}
================================================
FILE: src/common/data_types/enum.js
================================================
import assert from 'assert';
export default function Enum(Options) {
const result = { Options };
Options.forEach((option) => {
assert(option.value === option.value.toLowerCase());
const key = option.value.toUpperCase().replace(/-/g, '_');
result[key] = option.value;
result[option.value] = option;
});
return result;
}
================================================
FILE: src/common/data_types/index.js
================================================
import LogEvent from './LogEvent';
import LogKey from './LogKey';
import LogStructure from './LogStructure';
import LogStructureGroup from './LogStructureGroup';
import LogTopic from './LogTopic';
export {
LogTopic,
LogStructureGroup,
LogStructure,
LogKey,
LogEvent,
};
const Mapping = {
'log-topic': LogTopic,
'log-structure-group': LogStructureGroup,
'log-structure': LogStructure,
'log-event': LogEvent,
};
export function getDataTypeMapping() {
return Mapping;
}
export { default as Enum } from './enum';
export * from './utils';
================================================
FILE: src/common/data_types/utils.js
================================================
let virtualID = 0;
export function getVirtualID() {
virtualID -= 1;
return virtualID;
}
export function isItem(item) {
return item && typeof item.__id__ === 'number';
}
export function isVirtualItem(item) {
return item && item.__id__ < 0;
}
export function isRealItem(item) {
return item && item.__id__ > 0;
}
export function getPartialItem(item) {
return item ? { __type__: item.__type__, __id__: item.__id__, name: item.name } : null;
}
export function getNextID(items) {
let nextId = -1;
// eslint-disable-next-line no-loop-func
while (items.some((item) => item.__id__ === nextId)) {
nextId -= 1;
}
return nextId;
}
================================================
FILE: src/common/data_types/validation.js
================================================
// A collection utilities used in the validate() methods of different data types.
export function validateNonEmptyString(name, value) {
if (typeof value !== 'string') {
return [
name,
false,
'must be a string.',
];
}
return [
name,
value.length > 0,
'must be non-empty.',
];
}
export function validateIndex(name, value) {
if (typeof value !== 'number') {
return [
name,
false,
'must be a number.',
];
}
return [
name,
value > 0,
'must be >= 0.',
];
}
export function validateEnumValue(name, value, Enum) {
return [
name,
!!Enum[value],
'must be valid.',
];
}
export function validateDateLabel(name, label) {
return [
name,
!!label.match(/^\d{4}-\d{2}-\d{2}$/),
'is an invalid date.',
];
}
export async function validateRecursive(DataType, name, item) {
const result = await DataType.validate.call(this, item);
const prefix = name;
for (let jj = 0; jj < result.length; jj += 1) {
result[jj][0] = prefix + result[jj][0];
}
return result;
}
export async function validateRecursiveList(DataType, name, items) {
if (!Array.isArray(items)) {
return [
name,
false,
`must be an Array<${DataType.name}>`,
];
}
const results = await Promise.all(
items.map((item) => DataType.validate.call(this, item)),
);
for (let ii = 0; ii < results.length; ii += 1) {
const prefix = `${name}[${ii}]`;
for (let jj = 0; jj < results[ii].length; jj += 1) {
results[ii][jj][0] = prefix + results[ii][jj][0];
}
}
return results.flat();
}
================================================
FILE: src/common/polyfill.js
================================================
function getTimePrefix() {
const now = new Date();
return `[${now.toLocaleDateString()} ${now.toLocaleTimeString()}]`;
}
function addTimePrefix(name) {
// eslint-disable-next-line no-console
const original = console[name];
// eslint-disable-next-line no-console
console[name] = (...args) => {
original(getTimePrefix(), ...args);
};
}
if (typeof global === 'object') {
addTimePrefix('error');
addTimePrefix('info');
addTimePrefix('log');
addTimePrefix('warning');
}
================================================
FILE: src/demo/components/Application.js
================================================
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import DetailsSection from './DetailsSection';
import IndexSection from './IndexSection';
import ModalDialog from './ModalDialog';
import SidebarSection from './SidebarSection';
export default class Application extends BaseWrapper {
constructor(webdriver) {
super(webdriver, true);
this.webdriver = webdriver;
}
async switchToTab(name) {
const element = await this.webdriver.findElement(
By.xpath(`//div[contains(@class, 'sidebar-section')]/div[text() = '${name}']`),
);
await this.moveToAndClick(element);
}
async getSidebarSection(...args) {
return SidebarSection.get(this.webdriver, ...args);
}
async getIndexSection() {
await this.waitUntil(async () => IndexSection.get(this.webdriver));
return IndexSection.get(this.webdriver);
}
async getDetailsSection(...args) {
return DetailsSection.get(this.webdriver, ...args);
}
async getModalDialog(...args) {
return ModalDialog.get(this.webdriver, ...args);
}
async getTopic(name, index) {
const elements = await this.webdriver.findElements(By.xpath(`//a[contains(@class, 'topic') and text() = '${name}']`));
const element = BaseWrapper.getItemByIndex(elements, index);
return new BaseWrapper(this.webdriver, element);
}
async getLink(name, index = 0) {
const elements = await this.webdriver.findElements(By.xpath(`//a[text() = '${name}']`));
const element = BaseWrapper.getItemByIndex(elements, index);
return new BaseWrapper(this.webdriver, element);
}
// Random Specific Items
async isDetailsSectionActive() {
const detailSection = await this.getDetailsSection(0);
return detailSection.isActive();
}
async performCreateNew(bulletList) {
const headerItem = await bulletList.getHeader();
await headerItem.perform('Create New');
await this.waitUntil(async () => !!(await this.getModalDialog(0)));
return this.getModalDialog(0);
}
async performInputName(name) {
const modalDialog = await this.getModalDialog(0);
const nameInput = await modalDialog.getTextInput('Name');
await nameInput.typeSlowly(name);
await modalDialog.performSave();
}
async clearDatabase() {
await this.webdriver.executeScript("return window.api.send('database-clear')");
}
// General Utility
async waitUntil(conditionMethod) {
await this.webdriver.wait(conditionMethod);
await this.wait();
}
async scrollToBottom(className, index) {
// Required for lower resolution demo videos.
const injectedMethod = (innerClassName, innerIndex) => {
const node = document.getElementsByClassName(innerClassName)[innerIndex];
let prevScrollTop = null;
return (function loop() {
if (prevScrollTop === node.scrollTop) {
return Promise.resolve();
}
prevScrollTop = node.scrollTop;
node.scrollBy(0, 10);
return new Promise((resolve) => {
setTimeout(() => resolve(loop()), 10);
});
}());
};
await this.webdriver.executeScript(`return (${injectedMethod.toString()})(${JSON.stringify(className)}, ${index});`);
}
}
================================================
FILE: src/demo/components/BaseWrapper.js
================================================
import assert from 'assert';
import { By, Key } from 'selenium-webdriver';
import { asyncSequence } from '../../common/AsyncUtils';
export default class BaseWrapper {
constructor(webdriver, element) {
assert(element, 'missing element');
this.webdriver = webdriver;
this.element = element;
}
// eslint-disable-next-line class-methods-use-this
async getInput() {
// Override this method to forward actions to the returned element.
return null;
}
async sendKeys(...items) {
const redirectInput = await this.getInput();
if (redirectInput) {
await redirectInput.sendKeys(...items);
return;
}
await asyncSequence(items, async (item) => {
let keys;
// Do not require application logic to use Selenium API.
if (typeof item === 'string') {
keys = Key[item];
} else if (Array.isArray(item)) {
keys = Key.chord(...item.map((key) => Key[key]));
} else {
assert(false, `invalid item: ${item}`);
}
await this.element.sendKeys(keys);
});
await this.wait();
}
async typeSlowly(text) {
const redirectInput = await this.getInput();
if (redirectInput) {
await redirectInput.typeSlowly(text);
return;
}
await asyncSequence(Array.from(text), async (char) => {
await this.element.sendKeys(char);
// Dont need to add additional delay here.
});
await this.wait();
}
async _moveTo(element) {
await this.webdriver.actions().move({ origin: element || this.element }).perform();
}
async moveTo(element) {
await this._moveTo(element);
await this.wait();
}
async _click(element) {
await this.webdriver.actions().click(element || this.element).perform();
}
async click(element) {
await this._click(element);
await this.wait();
}
async moveToAndClick(element) {
await this.moveTo(element);
await this.click(element);
}
// eslint-disable-next-line class-methods-use-this
wait(milliseconds = 250) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
static getItemByIndex(items, index) {
return items[index < 0 ? items.length + index : index];
}
static async getElementByClassName(element, className, index = 0) {
const classAttribute = await element.getAttribute('class');
if (classAttribute.includes(className)) {
return element;
}
const elements = await element.findElements(By.className(className));
return elements[index] || null;
}
}
================================================
FILE: src/demo/components/BulletList.js
================================================
/* eslint-disable max-classes-per-file */
import assert from 'assert';
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import { TextEditor } from './Inputs';
export default class BulletList extends BaseWrapper {
static async get(webdriver, index) {
const elements = await webdriver.findElements(By.className('bullet-list'));
const element = BaseWrapper.getItemByIndex(elements, index);
return element ? new this(webdriver, element) : null;
}
async getHeader() {
const element = await this.element.findElement(By.xpath('./div[1]'));
// eslint-disable-next-line no-use-before-define
return new BulletListItem(this.webdriver, element);
}
async _getItems() {
return this.element.findElements(By.xpath("./div[2]/div[contains(@class, 'highlightable')]"));
}
async getItem(index) {
const elements = await this._getItems();
// eslint-disable-next-line no-use-before-define
return new BulletListItem(this.webdriver, BaseWrapper.getItemByIndex(elements, index));
}
async getItemCount() {
const elements = await this._getItems();
return elements.length;
}
async getAdder() {
const element = this.element.findElement(By.xpath('./div[3]'));
return TextEditor.get(this.webdriver, element);
}
}
class BulletListItem extends BaseWrapper {
async _getButton(title) {
await this._moveTo(this.element);
const button = this.element.findElement(By.xpath(
`.//div[contains(@class, 'icon') and @title='${title}']`,
));
await this._moveTo(button);
return button;
}
async perform(name) {
const button = await this._getButton(name);
await this.moveToAndClick(button);
}
async performAction(name) {
await this._moveTo(this.element);
const actionButton = await this._getButton('Actions');
await this._moveTo(actionButton);
await this.webdriver.wait(async () => (await actionButton.findElements(By.className('dropdown-item'))).length > 0);
const actionElement = await actionButton.findElement(
By.xpath(`.//a[contains(@class, 'dropdown-item') and text() = '${name}']`),
);
await this.moveToAndClick(actionElement);
}
async getSubList() {
const items = await this.element.findElements(By.xpath(
'./following-sibling::*[1]'
+ "//div[contains(@class, 'bullet-list')]",
));
return items.length ? new BulletList(this.webdriver, items[0]) : null;
}
async move(direction) {
assert(['UP', 'DOWN'].includes(direction));
const reorderButton = await this._getButton('Reorder');
await this._moveTo(reorderButton);
await this.wait();
await this.sendKeys(['SHIFT', direction]);
}
}
================================================
FILE: src/demo/components/DetailsSection.js
================================================
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import { TextEditor } from './Inputs';
export default class DetailsSection extends BaseWrapper {
static async get(webdriver, index) {
const elements = await webdriver.findElements(By.className('details-section'));
const element = BaseWrapper.getItemByIndex(elements, index);
return element ? new this(webdriver, element) : null;
}
async isActive() {
const elements = await this.element.findElements(By.xpath('.//input[@placeholder = \'Details ...\']'));
return elements.length === 0;
}
async getInput() {
return TextEditor.get(this.webdriver, this.element);
}
async perform(name) {
const button = await this.element.findElement(By.xpath(`.//button[@title = '${name}']`));
await this.moveToAndClick(button);
}
}
================================================
FILE: src/demo/components/IndexSection.js
================================================
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import BulletList from './BulletList';
import { TypeaheadSelector } from './Inputs';
export default class IndexSection extends BaseWrapper {
static async get(webdriver) {
const elements = await webdriver.findElements(By.className('index-section'));
return elements.length ? new this(webdriver, elements[0]) : null;
}
async getTypeahead() {
const inputElement = await this.element.findElement(
By.xpath("./div[1]//div[contains(@class, 'rbt')]"),
);
return new TypeaheadSelector(this.webdriver, inputElement);
}
async getBulletList(index) {
const items = await this.element.findElements(By.xpath(
"./div[contains(@class, 'scrollable-section')]"
+ "/div[contains(@class, 'bullet-list')]",
));
const item = BaseWrapper.getItemByIndex(items, index);
return item ? new BulletList(this.webdriver, item) : null;
}
}
================================================
FILE: src/demo/components/Inputs.js
================================================
/* eslint-disable max-classes-per-file */
import assert from 'assert';
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
export class Selector extends BaseWrapper {
static async get(webdriver, element) {
const actual = await BaseWrapper.getElementByClassName(element, 'selector');
return actual ? new this(webdriver, actual) : null;
}
async pickOption(name) {
/*
const optionElements = await this.element.findElements(By.tagName('option'));
const optionLabels = await Promise.all(optionElements.map(element => element.getText()));
const index = optionLabels.findIndex(optionLabel => optionLabel === name);
await this.moveToAndClick(optionElements[index]);
// Error = [object HTMLOptionElement] has no size and location
*/
await this.element.sendKeys(name);
await this.wait();
}
}
export class TypeaheadSelector extends BaseWrapper {
static async get(webdriver, element) {
const actual = await BaseWrapper.getElementByClassName(element, 'rbt');
return actual ? new this(webdriver, actual) : null;
}
async getTokens() {
const items = await this.element.findElements(By.xpath(".//div[contains(@class, 'rbt-token')]"));
return Promise.all(items.map(async (token) => {
const names = await token.getText();
return names.split('\n')[0];
}));
}
async removeToken(name) {
const removeButton = await this.element.findElement(By.xpath(
`.//div[contains(@class, 'rbt-token') and text() = '${name}']`
+ '/button[contains(@class, \'rbt-close\')]',
));
await this.moveToAndClick(removeButton);
}
async getInput() {
const wrappers = await this.element.findElements(By.xpath(".//div[contains(@class, 'rbt-input-wrapper')]"));
if (wrappers.length) {
// multi-selector
const inputElement = await wrappers[0].findElement(By.xpath('.//input[1]'));
return new BaseWrapper(this.webdriver, inputElement);
}
// single-selector
const inputElement = await this.element.findElement(By.xpath('./div[1]/input[1]'));
return new BaseWrapper(this.webdriver, inputElement);
}
async _getSuggestions() {
const elements = await this.element.findElements(By.xpath(
".//div[contains(@class, 'menu-options') or contains(@class, 'rbt-menu')]"
+ "/a[contains(@class, 'dropdown-item')]",
));
const names = await Promise.all(elements.map((token) => token.getText()));
return { elements, names };
}
async pickSuggestion(label) {
await this.webdriver.wait(async () => {
const { names } = await this._getSuggestions();
return names.some((item) => item.startsWith(label));
});
const { elements, names } = await this._getSuggestions();
const index = names.findIndex((item) => item.startsWith(label));
await this.moveToAndClick(elements[index]);
}
}
export class TextEditor extends BaseWrapper {
static async get(webdriver, element) {
const actual = await BaseWrapper.getElementByClassName(element, 'text-editor');
return actual ? new this(webdriver, actual) : null;
}
async getInput() {
return new BaseWrapper(this.webdriver, this.element.findElement(
By.xpath(".//div[contains(@class, 'public-DraftEditor-content')]"),
));
}
async getSuggestions() {
const tokens = await this.element.findElements(
By.xpath(".//div[contains(@class, 'mention-suggestions')]/div/div"),
);
return Promise.all(tokens.map((token) => token.getText()));
}
async pickSuggestion(indexOrLabel) {
await this.webdriver.wait(async () => (await this.getSuggestions()).length > 0);
await this.wait();
const offset = typeof indexOrLabel === 'number'
? indexOrLabel
: (await this.getSuggestions()).indexOf(indexOrLabel);
assert(offset !== -1);
for (let ii = 1; ii < offset; ii += 1) {
// eslint-disable-next-line no-await-in-loop
await this.sendKeys('DOWN');
}
await this.sendKeys('ENTER');
}
}
export class LogStructureKey extends BaseWrapper {
static async get(webdriver, element, index) {
const containers = await element.findElements(By.xpath('.//div[contains(@class, \'log-structure-key\')]'));
const container = BaseWrapper.getItemByIndex(containers, index);
return new this(webdriver, container);
}
async getTypeSelector() {
return Selector.get(this.webdriver, this.element, 0);
}
async getNameInput() {
return new BaseWrapper(this.webdriver, await this.element.findElement(By.tagName('input')));
}
async getTemplateInput() {
return new TextEditor(this.webdriver, this.element);
}
}
================================================
FILE: src/demo/components/ModalDialog.js
================================================
import assert from 'assert';
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import {
LogStructureKey, Selector, TextEditor, TypeaheadSelector,
} from './Inputs';
export default class ModalDialog extends BaseWrapper {
static async get(webdriver, index) {
const elements = await webdriver.findElements(By.className('modal-dialog'));
const element = BaseWrapper.getItemByIndex(elements, index);
return element ? new this(webdriver, element) : null;
}
async _clickAndWaitForClose(buttonElement) {
await this.webdriver.wait(async () => buttonElement.isEnabled());
await this.click(buttonElement);
await this.webdriver.wait(async () => {
try {
await this.element.isDisplayed();
return false;
} catch (error) {
assert(error.name === 'StaleElementReferenceError');
return true;
}
});
await this.wait();
}
async performClose() {
const buttonElement = await this.element.findElement(By.xpath(
'//div[contains(@class, \'modal-content\')]/div[1]'
+ '//button',
));
await this._clickAndWaitForClose(buttonElement);
}
async _getElement(name) {
return this.element.findElement(By.xpath(
'//div[contains(@class, \'modal-content\')]/div[2]'
+ '//div[contains(@class, \'input-group\')]'
+ `/span[contains(@class, 'input-group-text') and text() = '${name}']`
+ '/../*[2]',
));
}
async getTextInput(name) {
const element = await this._getElement(name);
return new BaseWrapper(this.webdriver, element);
}
async getTextEditor(name) {
const element = await this._getElement(name);
return TextEditor.get(this.webdriver, element);
}
async getTypeahead(name) {
const element = await this._getElement(name);
return TypeaheadSelector.get(this.webdriver, element);
}
async getSelector(name) {
const element = await this._getElement(name);
return Selector.get(this.webdriver, element);
}
async performSave() {
const buttonElement = await this.element.findElement(By.xpath(
'//div[contains(@class, \'modal-content\')]/div[3]'
+ '//button[text() = \'Save\']',
));
await this._clickAndWaitForClose(buttonElement);
}
// Methods specific to Log Structures.
async addLogStructureKey() {
const button = await this.element.findElement(By.xpath(
".//button[contains(@class, 'log-structure-add-key')]",
));
await this.moveToAndClick(button);
return this.getLogStructureKey(-1);
}
async getLogStructureKey(index) {
return LogStructureKey.get(this.webdriver, this.element, index);
}
// Methods specific to Debug Info
async getDebugInfo() {
const element = await this.element.findElement(By.tagName('pre'));
return element.getText();
}
}
================================================
FILE: src/demo/components/ReminderItem.js
================================================
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
export default class ReminderItem extends BaseWrapper {
async getCheckbox() {
const checkbox = await this.element.findElement(By.xpath(".//input[@type = 'checkbox']"));
return new BaseWrapper(this.webdriver, checkbox);
}
async pickMenuItem(label) {
await this.moveTo();
const rightElement = await this.element.findElement(By.xpath(".//div[contains(@class, 'icon')]"));
await this.moveTo(rightElement);
await this.wait();
await this.webdriver.wait(async () => (await this.element.findElements(By.className('dropdown-item'))).length > 0);
const optionElement = await this.element.findElement(
By.xpath(`.//a[contains(@class, 'dropdown-item') and text() = '${label}']`),
);
await this.moveToAndClick(optionElement);
}
}
================================================
FILE: src/demo/components/SidebarSection.js
================================================
import { By } from 'selenium-webdriver';
import BaseWrapper from './BaseWrapper';
import ReminderItem from './ReminderItem';
export default class SidebarSection extends BaseWrapper {
static async get(webdriver, name) {
const element = await webdriver.findElement(By.xpath(
'//div[contains(@class, \'sidebar-section\')]'
+ '/div[contains(@class, \'cursor\')]'
+ `/div/div[contains(text(), '${name}')]`
+ '/../../..',
));
return new this(webdriver, element);
}
async getItems() {
const items = await this.element.findElements(
By.xpath(".//div[contains(@class, 'highlightable')]//div[contains(@class, 'input-line')]"),
);
return Promise.all(items.map((item) => item.getText()));
}
async getReminderItems() {
const items = await this.element.findElements(By.xpath(".//div[contains(@class, 'reminder-item')]"));
return items.map((item) => new ReminderItem(this.webdriver, item));
}
}
================================================
FILE: src/demo/components/index.js
================================================
import Application from './Application';
export default Application;
================================================
FILE: src/demo/index.js
================================================
/* eslint-disable no-console */
import { assert } from 'console';
import fs from 'fs';
import path from 'path';
import { Builder } from 'selenium-webdriver';
import yargs from 'yargs';
import runLessons from './lessons';
import { ProcessWrapper, StreamIntender } from './process';
const DEVICE_TO_WINDOW_SPEC = {
desktop: { width: 1920, height: 1080 },
mobile: { width: 1280, height: 720 },
};
async function main(argv) {
console.info('Initializing ...');
if (!argv.verbose) {
console.info(`${argv.indent}Note: Child process output hidden! (hint: --verbose)`);
}
const config = JSON.parse(fs.readFileSync(argv.configPath));
const dataDir = path.dirname(config.database.storage);
try {
fs.accessSync(dataDir);
} catch (_e) {
fs.mkdirSync(dataDir);
}
console.info(`${argv.indent}Prepared data directory!`);
const [serverCommand, ...serverArgs] = 'yarn run server -c'.split(' ').concat(argv.configPath);
const databaseResetProcess = new ProcessWrapper({
command: serverCommand,
argv: serverArgs.concat('-a', 'database-reset'),
stream: new StreamIntender(process.stdout, `${argv.indent + argv.indent}[database-reset] `),
verbose: argv.verbose,
});
await databaseResetProcess.start();
await databaseResetProcess.waitUntilExit();
console.info(`${argv.indent}Reset Database!`);
const serverProcess = new ProcessWrapper({
command: serverCommand,
argv: serverArgs,
stream: new StreamIntender(process.stdout, `${argv.indent + argv.indent}[server] `),
verbose: argv.verbose,
});
await serverProcess.start();
await serverProcess.waitUntilOutput('Server running');
console.info(`${argv.indent}Test server started!`);
const webdriver = new Builder().forBrowser('chrome').build();
const spec = DEVICE_TO_WINDOW_SPEC[argv.device];
assert(spec, `unknown device: ${argv.device}`);
await webdriver.manage().window().setRect({
width: spec.width, height: spec.height, x: 0, y: 0,
});
await webdriver.get(`http://${config.server.host}:${config.server.port}`);
console.info(`${argv.indent}Webdriver started!`);
let recordingProcess;
if (argv.record) {
const rect = await webdriver.manage().window().getRect();
recordingProcess = new ProcessWrapper({
command: 'ffmpeg',
argv: (
'-f avfoundation '
+ '-i 1: ' // ffmpeg -f avfoundation -list_devices true -i ""
+ '-pix_fmt yuv420p '
+ `-vf crop=${rect.width}:${rect.height}:${rect.x}:${rect.y} `
+ `-y ${argv.videoPath}`
).split(' '),
stream: new StreamIntender(process.stdout, `${argv.indent + argv.indent}[screen-recording] `),
verbose: argv.verbose,
});
await recordingProcess.start();
await recordingProcess.waitUntilOutput(argv.videoPath);
console.info(`${argv.indent}Screen recording started!`);
} else {
console.info(`${argv.indent}Skipped screen recording! (hint: --record)`);
}
console.info('Initialized!');
try {
await runLessons(webdriver, argv);
} catch (error) {
console.error(error);
}
console.info('Terminating ...');
if (argv.record) {
await recordingProcess.stop();
}
await webdriver.quit();
await serverProcess.stop();
if (argv.record && argv.videoPath.endsWith('.mkv')) {
// Directly generating an MP4 file does not work for some reason.
console.info(`${argv.indent}Converting to mp4 format ...`);
const formatConversionProcess = new ProcessWrapper({
command: 'ffmpeg',
argv: (
`-i ${argv.videoPath} `
+ '-codec copy '
+ `-y ${argv.videoPath.replace('mkv', 'mp4')}`
).split(' '),
stream: new StreamIntender(process.stdout, `${argv.indent + argv.indent}[format-conversion] `),
verbose: argv.verbose,
});
await formatConversionProcess.start();
await formatConversionProcess.waitUntilExit();
}
console.info('Terminated!');
}
const { argv } = yargs
// General
.option('config-path', { alias: 'c', default: './config/demo.glados.json' })
.demandOption('config-path')
.option('verbose')
.option('indent', { default: '\t' })
// Recording
.option('record')
.option('videoPath', { default: './data/glados.demo.mkv' })
.option('device', { default: 'desktop' })
// Lessons
.option('filter')
.option('wait', { default: 0 });
main(argv).catch((error) => console.error(error));
================================================
FILE: src/demo/lessons/001-events.js
================================================
/* eslint-disable no-constant-condition */
export default async (app) => {
const indexSection = await app.getIndexSection();
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('This is an event.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
await adder.typeSlowly('Events are used to log what you did throughout the day.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 2);
}
if (true) {
const bulletList = await indexSection.getBulletList(1);
const adder = await bulletList.getAdder();
await adder.typeSlowly('Or what you plan to do.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
const bulletItem = await bulletList.getItem(-1);
await bulletItem.performAction('Complete');
}
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
const count = await bulletList.getItemCount();
await adder.typeSlowly('You can add details to an event.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === count + 1);
const bulletItem = await bulletList.getItem(count);
await bulletItem.perform('Edit');
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
const modalDialog = await app.getModalDialog(0);
const detailsInput = await modalDialog.getTextEditor('Details');
await detailsInput.typeSlowly('Unlike the event title, ');
await detailsInput.typeSlowly('the details section is not limited to one line.');
await detailsInput.sendKeys('ENTER');
await detailsInput.typeSlowly('This is where you can add a lot more context about what happened.');
await modalDialog.performSave();
await bulletItem.perform('Expand');
await bulletItem.perform('Collapse');
}
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
const count = await bulletList.getItemCount();
await adder.typeSlowly('Some events could be more important than others.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === count + 1);
const minorItem = await bulletList.getItem(-1);
await minorItem.perform('Edit');
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
const modalDialog = await app.getModalDialog(0);
const logLevelSelector = await modalDialog.getSelector('Log Level');
await logLevelSelector.pickOption('Minor (1)');
const detailTextEditor = await modalDialog.getTextEditor('Details');
await detailTextEditor.typeSlowly('Minor events are not displayed by default.');
await modalDialog.performSave();
await app.waitUntil(async () => await bulletList.getItemCount() === count);
const typeaheadSelector = await indexSection.getTypeahead();
await typeaheadSelector.typeSlowly('L');
const name = 'Log Level: Minor+';
await typeaheadSelector.pickSuggestion(name);
await app.waitUntil(async () => (await typeaheadSelector.getTokens())[0] === name);
await app.waitUntil(async () => await bulletList.getItemCount() === count + 1);
await adder.typeSlowly('While viewing all events, you can reorder them.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === count + 2);
const bulletItem = await bulletList.getItem(-1);
await bulletItem.move('UP');
await bulletItem.move('UP');
}
};
================================================
FILE: src/demo/lessons/002-topics.js
================================================
/* eslint-disable no-constant-condition */
export default async (app) => {
const indexSection = await app.getIndexSection();
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('Let us now create some topics.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
}
if (true) {
await app.switchToTab('Manage Topics');
const bulletList0 = await indexSection.getBulletList(0);
await app.performCreateNew(bulletList0);
await app.performInputName('Personal Projects');
await app.waitUntil(async () => await bulletList0.getItemCount() === 1);
const bulletItem1 = await bulletList0.getItem(0);
await bulletItem1.perform('Expand');
const bulletList1 = await bulletItem1.getSubList();
await app.performCreateNew(bulletList1);
await app.performInputName('GLADOS');
await app.waitUntil(async () => await bulletList1.getItemCount() === 1);
await app.performCreateNew(bulletList0);
await app.performInputName('People');
await app.waitUntil(async () => await bulletList0.getItemCount() === 2);
const bulletItem2 = await bulletList0.getItem(1);
await bulletItem2.perform('Expand');
const bulletList2 = await bulletItem2.getSubList();
await app.performCreateNew(bulletList2);
await app.performInputName('Kasturi Karkare');
await app.waitUntil(async () => await bulletList2.getItemCount() === 1);
}
if (true) {
await app.switchToTab('Manage Events');
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can reference topics from events.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 2);
await adder.typeSlowly('Created demo video for @G');
await adder.pickSuggestion(0);
await adder.typeSlowly('using Selenium.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 3);
await adder.typeSlowly('Conversation with @K');
await adder.pickSuggestion(0);
await adder.typeSlowly('about @G');
await adder.pickSuggestion(0);
await adder.sendKeys('BACK_SPACE');
await adder.typeSlowly('.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 4);
}
if (true) {
const bulletList = await indexSection.getBulletList(0);
const count = await bulletList.getItemCount();
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can click on a topic to show details on the right side.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === count + 1);
let topicElement = await app.getTopic('GLADOS', 0);
await topicElement.moveToAndClick();
await app.waitUntil(async () => app.isDetailsSectionActive());
const detailsSection = await app.getDetailsSection(0);
await detailsSection.typeSlowly('You can add details about a particular topic.');
await detailsSection.sendKeys('ENTER');
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('You can search for all events referencing this topic,');
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('by clicking on the magnifying glass icon above!');
await detailsSection.sendKeys('ENTER');
await detailsSection.sendKeys('ENTER');
await detailsSection.perform('Search');
const typeahead = await indexSection.getTypeahead();
await app.waitUntil(async () => (await typeahead.getTokens()).length === 1);
topicElement = await app.getTopic('GLADOS', 0);
await topicElement.moveTo();
topicElement = await app.getTopic('GLADOS', 1);
await topicElement.moveTo();
await typeahead.removeToken('GLADOS');
await app.waitUntil(async () => (await typeahead.getTokens()).length === 0);
await detailsSection.typeSlowly('You can mark this event as a favorite using the heart icon.');
await detailsSection.sendKeys('ENTER');
await detailsSection.perform('Favorite');
const favoriteTopics = await app.getSidebarSection('Favorite Topics');
await app.waitUntil(async () => (await favoriteTopics.getItems()).length === 1);
await detailsSection.typeSlowly('It now appears on the right sidebar.');
await detailsSection.sendKeys('ENTER');
await detailsSection.perform('Close');
await app.waitUntil(async () => !(await detailsSection.isActive()));
topicElement = await app.getTopic('GLADOS', -1);
topicElement.moveTo();
topicElement.click();
await app.waitUntil(async () => detailsSection.isActive());
}
};
================================================
FILE: src/demo/lessons/003-structures.js
================================================
/* eslint-disable no-constant-condition */
export default async (app) => {
const indexSection = await app.getIndexSection();
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can add structured data to your events.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
}
if (true) {
await app.switchToTab('Manage Structures');
const bulletList0 = await indexSection.getBulletList(0);
await app.performCreateNew(bulletList0);
await app.performInputName('Exercise');
const bulletItem1 = await bulletList0.getItem(0);
await bulletItem1.perform('Expand');
await app.waitUntil(async () => !!(await bulletItem1.getSubList()));
const bulletList1 = await bulletItem1.getSubList();
const modalDialog = await app.performCreateNew(bulletList1);
const nameInput = await modalDialog.getTextInput('Name');
await nameInput.typeSlowly('Running');
const key1 = await modalDialog.addLogStructureKey();
const key1type = await key1.getTypeSelector();
await key1type.pickOption('Number');
const key1name = await key1.getNameInput();
await key1name.typeSlowly('Distance (km)');
const templateInput = await modalDialog.getTextEditor('Event Title Template');
await templateInput.typeSlowly(': @D');
await templateInput.pickSuggestion(0);
await templateInput.typeSlowly('km / ');
const key2 = await modalDialog.addLogStructureKey();
const key2type = await key2.getTypeSelector();
await key2type.pickOption('Number');
const key2name = await key2.getNameInput();
await key2name.typeSlowly('Time (minutes)');
await templateInput.typeSlowly('@T');
await templateInput.pickSuggestion(0);
await templateInput.typeSlowly('minutes');
await modalDialog.performSave();
}
if (true) {
await app.switchToTab('Manage Events');
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('@R');
await adder.pickSuggestion(0);
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
const modalDialog = await app.getModalDialog(0);
const distanceInput = await modalDialog.getTypeahead('Distance (km)');
await distanceInput.typeSlowly('10');
const timeInput = await modalDialog.getTypeahead('Time (minutes)');
await timeInput.typeSlowly('60');
await modalDialog.performSave();
}
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can derive additional information from structured data.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 3);
const topicElement = await app.getTopic('Running', 0);
await topicElement.moveToAndClick();
await app.waitUntil(async () => app.isDetailsSectionActive());
const detailsSection = await app.getDetailsSection(0);
await detailsSection.perform('Edit');
const modalDialog = await app.getModalDialog(0);
const key3 = await modalDialog.addLogStructureKey();
const key3type = await key3.getTypeSelector();
await key3type.pickOption('Number');
const key3name = await key3.getNameInput();
await key3name.typeSlowly('Speed (kmph)');
const key3template = await key3.getTemplateInput();
await key3template.typeSlowly('{( @D');
await key3template.pickSuggestion(0);
await key3template.typeSlowly(' * 60 / @T');
await key3template.pickSuggestion(0);
await key3template.typeSlowly(').toFixed(2)}');
const templateInput = await modalDialog.getTextEditor('Event Title Template');
await templateInput.sendKeys('BACK_SPACE');
await templateInput.typeSlowly(' (@S');
await templateInput.pickSuggestion(0);
await templateInput.typeSlowly(' kmph)');
await modalDialog.performSave();
}
if (true) {
const bulletList = await indexSection.getBulletList(0);
const bulletItem = await bulletList.getItem(1);
await bulletItem.perform('Edit');
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
const modalDialog = await app.getModalDialog(0);
const timeInput = await modalDialog.getTypeahead('Time (minutes)');
await timeInput.sendKeys('BACK_SPACE', 'BACK_SPACE');
await timeInput.typeSlowly('50');
await modalDialog.performSave();
}
};
================================================
FILE: src/demo/lessons/004-reminders.js
================================================
/* eslint-disable no-constant-condition */
export default async (app) => {
const indexSection = await app.getIndexSection();
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can add "structure" to your day using reminders.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
}
if (true) {
await app.switchToTab('Manage Structures');
const bulletList0 = await indexSection.getBulletList(0);
await app.performCreateNew(bulletList0);
await app.performInputName('Daily Routine');
const bulletItem1 = await bulletList0.getItem(0);
await bulletItem1.perform('Expand');
await app.waitUntil(async () => !!(await bulletItem1.getSubList()));
const bulletList1 = await bulletItem1.getSubList();
await app.performCreateNew(bulletList1).then(async (modalDialog) => {
const nameInput = await modalDialog.getTextInput('Name');
await nameInput.typeSlowly('Woke up');
const templateInput = await modalDialog.getTextEditor('Event Title Template');
await templateInput.typeSlowly(' at ');
const logLevelSelector = await modalDialog.getSelector('Log Level');
await logLevelSelector.pickOption('Minor (1)');
const key1 = await modalDialog.addLogStructureKey();
const key1type = await key1.getTypeSelector();
await key1type.pickOption('Time');
const key1name = await key1.getNameInput();
await key1name.typeSlowly('Time');
await templateInput.typeSlowly('@T');
await templateInput.pickSuggestion(0);
await templateInput.sendKeys('BACK_SPACE');
const isPeriodicSelector = await modalDialog.getSelector('Is Periodic?');
await isPeriodicSelector.pickOption('Yes');
await modalDialog.performSave();
});
await app.performCreateNew(bulletList1).then(async (modalDialog) => {
const nameInput = await modalDialog.getTextInput('Name');
await nameInput.typeSlowly('Made Bed');
const isPeriodicSelector = await modalDialog.getSelector('Is Periodic?');
await isPeriodicSelector.pickOption('Yes');
const reminderTextInput = await modalDialog.getTextInput('Reminder Text');
await reminderTextInput.typeSlowly('Make Bed');
await modalDialog.performSave();
});
}
if (true) {
await app.switchToTab('Manage Events');
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('Reminders appear on the left sidebar.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 2);
const sidebarSection = await app.getSidebarSection('Daily Routine');
let reminderItems = await sidebarSection.getReminderItems();
await reminderItems[0].getCheckbox().then(async (checkbox) => {
await checkbox.moveTo();
await app.wait();
await checkbox.click();
const modalDialog = await app.getModalDialog(0);
const timeInput = await modalDialog.getTypeahead('Time');
await timeInput.typeSlowly('07:00');
await modalDialog.performSave();
});
reminderItems = await sidebarSection.getReminderItems();
await reminderItems[0].pickMenuItem('Mark as Complete');
}
};
================================================
FILE: src/demo/lessons/005-graphs.js
================================================
/* eslint-disable no-constant-condition */
export default async (app) => {
const indexSection = await app.getIndexSection();
if (true) {
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('You can view graphs of your events.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 1);
await adder.typeSlowly('Let us create a number of mock events to demonstrate that.');
await adder.sendKeys('ENTER');
await app.waitUntil(async () => await bulletList.getItemCount() === 2);
}
if (true) {
await app.switchToTab('Manage Structures');
const bulletList0 = await indexSection.getBulletList(0);
await app.performCreateNew(bulletList0);
await app.performInputName('Exercise');
const bulletItem1 = await bulletList0.getItem(0);
await bulletItem1.perform('Expand');
await app.waitUntil(async () => !!(await bulletItem1.getSubList()));
const bulletList1 = await bulletItem1.getSubList();
await app.performCreateNew(bulletList1).then(async (modalDialog) => {
const nameInput = await modalDialog.getTextInput('Name');
await nameInput.typeSlowly('Push Ups');
const templateInput = await modalDialog.getTextEditor('Event Title Template');
await templateInput.typeSlowly(': ');
const key1 = await modalDialog.addLogStructureKey();
const key1type = await key1.getTypeSelector();
await key1type.pickOption('Integer');
const key1name = await key1.getNameInput();
await key1name.typeSlowly('Count');
await templateInput.typeSlowly('@C');
await templateInput.pickSuggestion(0);
await templateInput.sendKeys('BACK_SPACE');
await modalDialog.performSave();
});
}
let logEventTemplate;
if (true) {
await app.switchToTab('Manage Events');
const bulletList = await indexSection.getBulletList(0);
const adder = await bulletList.getAdder();
await adder.typeSlowly('@P');
await adder.pickSuggestion(0);
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
await app.getModalDialog(0).then(async (modalDialog) => {
const countInput = await modalDialog.getTypeahead('Count');
await countInput.typeSlowly('50');
await modalDialog.performSave();
});
const typeaheadSelector = await indexSection.getTypeahead();
await typeaheadSelector.typeSlowly('P');
await typeaheadSelector.pickSuggestion('Push Ups');
await app.waitUntil(async () => (await typeaheadSelector.getTokens())[0] === 'Push Ups');
}
if (true) {
const topicElement = await app.getTopic('Push Ups', 0);
await topicElement.moveToAndClick();
await app.waitUntil(async () => app.isDetailsSectionActive());
const detailsSection = await app.getDetailsSection(0);
await detailsSection.typeSlowly('Using "Debug Info" to make RPCs to create similar events.');
await detailsSection.sendKeys('ENTER');
const bulletList = await indexSection.getBulletList(0);
const bulletItem = await bulletList.getItem(0);
await bulletItem.click();
await bulletItem.performAction('Debug Info');
await app.waitUntil(async () => !!(await app.getModalDialog(0)));
await app.getModalDialog(0).then(async (modalDialog) => {
logEventTemplate = JSON.parse(await modalDialog.getDebugInfo());
await modalDialog.performClose();
});
const timestamp = new Date(logEventTemplate.date).valueOf();
const msInDay = 24 * 60 * 60 * 1000;
const totalEvents = 30; // to avoid exceeding page size.
const initialValue = 10;
const finalValue = parseInt(logEventTemplate.logStructure.eventKeys[0].value, 10);
let count = await bulletList.getItemCount();
for (let index = 1; index < totalEvents; index += 1) {
count += 1;
logEventTemplate.__id__ = -count;
const newDate = new Date(timestamp - msInDay * index);
// eslint-disable-next-line prefer-destructuring
logEventTemplate.date = newDate.toISOString().split('T')[0];
// Skip weekends to avoid a straight line.
if ([0, 6].includes(newDate.getDay())) {
count -= 1;
// eslint-disable-next-line no-continue
continue;
}
// Extrapolating events using a linear equation.
// Options explored using https://www.desmos.com/calculator
let value = initialValue + Math.floor(
((finalValue - initialValue) * (totalEvents - index - 1)) / totalEvents,
);
// Add some randomness.
if (index < totalEvents - 1) {
const variation = Math.ceil(10 / 4);
value += Math.ceil(Math.random() * variation) - Math.ceil(variation / 2);
}
logEventTemplate.logStructure.eventKeys[0].value = value.toString();
// eslint-disable-next-line no-await-in-loop
await app.webdriver.executeScript(`window.api.send('log-event-upsert', ${JSON.stringify(logEventTemplate)})`);
// eslint-disable-next-line no-await-in-loop, no-loop-func
await app.waitUntil(async () => await bulletList.getItemCount() === count);
}
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('Note that weekends were skipped while creating the mock events.');
await detailsSection.sendKeys('ENTER');
}
if (true) {
await app.switchToTab('Explore Graphs');
await app.wait(2000);
const typeaheadSelector = await indexSection.getTypeahead();
await typeaheadSelector.typeSlowly('Gr');
await typeaheadSelector.pickSuggestion('Granularity: Day');
await app.wait(1000);
const detailsSection = await app.getDetailsSection(0);
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('The "Event Count" graph is an indicator of your consistency.');
await detailsSection.sendKeys('ENTER');
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('Additional graphs are generated for each numerical key of your structure,');
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('and can help see patterns in those values.');
await detailsSection.sendKeys('ENTER');
await detailsSection.sendKeys('ENTER');
await detailsSection.typeSlowly('Let us change the layout for better visibility.');
await detailsSection.sendKeys('ENTER');
const linkElement = await app.getLink('Left');
await linkElement.moveToAndClick();
await app.wait(1000);
await app.scrollToBottom('scrollable-section', 1);
await app.wait(1000);
}
};
================================================
FILE: src/demo/lessons.js
================================================
/* eslint-disable no-console */
import { asyncSequence } from '../common/AsyncUtils';
import Application from './components';
const lessonsContext = require.context('./lessons', false, /\.js$/);
export default async (webdriver, argv) => {
if (!argv.filter) {
console.info(`${argv.indent}Note: Running all lessons! (hint: --filter)`);
}
const resetUrl = await webdriver.getCurrentUrl();
const app = new Application(webdriver);
const lessonNames = lessonsContext.keys()
.filter((name) => {
if (!argv.filter) {
return true;
}
// Remove the "./" prefix and ".js" suffix.
return name.slice(2, -3).includes(argv.filter);
});
await asyncSequence(lessonNames, async (name) => {
const { default: lessonMethod } = lessonsContext(name);
console.info(`${argv.indent}Lesson: ${name}`);
try {
await app.clearDatabase();
await webdriver.get(resetUrl);
await lessonMethod(app);
await app.wait(1000); // 1 sec
} catch (error) {
console.error(error);
await app.wait(argv.wait * 1000); // Use this time to debug.
throw error;
}
});
await app.wait(argv.wait * 1000); // Use this time to debug.
};
================================================
FILE: src/demo/process.js
================================================
/* eslint-disable max-classes-per-file */
import assert from 'assert';
import childProcess from 'child_process';
class ProcessWrapper {
constructor(args) {
assert(args.command && Array.isArray(args.argv) && args.stream);
this.process = null;
this._lastDataTimestamp = null; // Set from _onData, for waitUntilPause
this._outputBuffer = ''; // Set from _onData, for waitUntilOutput
this._checkForOutput = null; // Set from waitUntilOutput
this._pauseInterval = null; // Set from waitUntilPause
this._args = args;
}
async start() {
const {
command, argv, stream, verbose,
} = this._args;
this._lastDataTimestamp = Date.now();
if (verbose) {
stream.write(`$ ${[command, ...argv].join(' ')}\n`);
}
this.process = childProcess.spawn(command, argv);
this.process.stdout.on('data', (data) => this._onData(data));
this.process.stderr.on('data', (data) => this._onData(data));
this.process.on('exit', () => {
this.process.stdin.end();
this.process.stdout.destroy();
this.process.stderr.destroy();
});
}
_onData(data) {
const { stream, verbose } = this._args;
this._lastDataTimestamp = Date.now();
data = data.toString();
if (verbose) {
stream.write(data);
}
this._outputBuffer += data;
if (this._checkForOutput) {
this._checkForOutput();
}
}
async waitUntilOutput(text) {
assert(!!this.process);
if (this._outputBuffer.includes(text)) {
return Promise.resolve();
}
return new Promise((resolve) => {
this._checkForOutput = () => {
if (this._outputBuffer.includes(text)) {
this._checkForOutput = null;
resolve();
}
};
});
}
async waitUntilPause(duration) {
assert(!!this.process);
return new Promise((resolve) => {
this._pauseInterval = setInterval(() => {
const delta = Date.now() - this._lastDataTimestamp;
if (delta > duration) {
clearInterval(this._pauseInterval);
this._pauseInterval = null;
resolve();
}
}, 100);
});
}
async waitUntilExit() {
assert(!!this.process);
return new Promise((resolve) => {
this.process.on('close', resolve);
});
}
async stop() {
assert(!!this.process);
return new Promise((resolve) => {
this.process.on('close', resolve);
this.process.kill();
});
}
}
class StreamIntender {
constructor(stream, prefix) {
this._stream = stream;
this._prefix = prefix;
this._pending = true;
}
write(data) {
let prefix = '';
if (this._pending) {
prefix += this._prefix;
this._pending = false;
}
data.split('\n').forEach((line, index, lines) => {
if (index < lines.length - 1) {
this._stream.write(`${prefix + line}\n`);
prefix = this._prefix;
} else if (line) {
this._stream.write(prefix + line);
} else {
this._pending = true;
}
});
}
}
export { ProcessWrapper, StreamIntender };
================================================
FILE: src/plugins/README.md
================================================
### Expectations
* File names ending with `actions.js` are supposed to export an object, whose keys are "action names" and values are the corresponding implementations, which are new RPCs that the server will now support.
* File names ending with `client.js` are supposed to export a class that extends the `[PluginClient](../client/Common/Plugins.js)` interface, which describes how to augment the UI.
### Activation
* In your `config.json` file, you will need to provide a list of regexes that match the plugin paths that you want to activate:
```
{
...
"plugins": [
"kaustubh/.*"
]
}
```
================================================
FILE: src/plugins/kaustubh/custom.actions.js
================================================
/* eslint-disable func-names */
/* eslint-disable camelcase */
/* eslint-disable max-len */
/* eslint-disable no-console */
/* eslint-disable no-constant-condition */
import assert from 'assert';
import { asyncSequence } from '../../common/AsyncUtils';
import { getPartialItem } from '../../common/data_types';
import RichTextUtils from '../../common/RichTextUtils';
const ActionsRegistry = {};
ActionsRegistry['check-consistency'] = async function () {
const results = [];
// These items only contain the __type__, __id__ & name.
const logTopicItems = await this.invoke.call(this, 'log-topic-typeahead', { query: '' });
if (false) {
// Update logTopics using latest topic-names
const logTopics = await this.invoke.call(this, 'log-topic-list');
await asyncSequence(logTopics, async (logTopic) => {
try {
logTopic.details = RichTextUtils.updateDraftContent(logTopic.details, logTopicItems);
await this.invoke.call(this, 'log-topic-upsert', logTopic);
} catch (error) {
results.push([logTopic, error.toString()]);
}
});
}
if (false) {
// Update logStructures using latest topic-names
const logStructures = await this.invoke.call(this, 'log-structure-list');
await asyncSequence(logStructures, async (logStructure) => {
try {
logStructure.eventTitleTemplate = RichTextUtils.updateDraftContent(logStructure.eventTitleTemplate, logTopicItems);
// TODO: Update topics in keys too.
await this.invoke.call(this, 'log-structure-upsert', logStructure);
} catch (error) {
results.push([logStructure, error.toString()]);
}
});
}
if (false) {
// Update logEvents using latest topic-names & structure-title-template.
const logEvents = await this.invoke.call(this, 'log-event-list');
await asyncSequence(logEvents, async (logEvent) => {
try {
logEvent.title = RichTextUtils.updateDraftContent(logEvent.title, logTopicItems);
logEvent.details = RichTextUtils.updateDraftContent(logEvent.details, logTopicItems);
await this.invoke.call(this, 'log-event-upsert', logEvent);
} catch (error) {
results.push([logEvent, error.toString()]);
}
});
}
return results;
};
ActionsRegistry['fix-birthdays-anniversaries'] = async function () {
// Update structures so that they each have similar behavior.
const GROUP_ID_TO_NAME = {
9: 'Birthdays',
13: 'Anniversaries',
};
const logStructureGroups = await this.invoke.call(
this,
'log-structure-group-list',
{ where: { __id__: Object.keys(GROUP_ID_TO_NAME) } },
);
return Promise.all(
logStructureGroups.map(async (logStructureGroup) => {
assert(
GROUP_ID_TO_NAME[logStructureGroup.__id__] === logStructureGroup.name,
logStructureGroup.name,
);
const logStructures = await this.invoke.call(
this,
'log-structure-list',
{ where: { logStructureGroup } },
);
return Promise.all(
logStructures.map(async (logStructure) => {
const nameRegexResult = logStructure.name.match(/^(\d{2}-\d{2})\w?$/);
assert(nameRegexResult, logStructure.name);
const expectedValues = {
isPeriodic: true,
frequency: 'yearly',
frequencyArgs: nameRegexResult[1],
reminderText:
RichTextUtils.extractPlainText(logStructure.eventTitleTemplate),
warningDays: 2,
};
let needsUpdate = false;
Object.keys(expectedValues).forEach((key) => {
if (logStructure[key] !== expectedValues[key]) {
logStructure[key] = expectedValues[key];
needsUpdate = true;
}
});
if (!needsUpdate) {
return logStructure;
}
return this.invoke.call(
this,
'log-structure-upsert',
logStructure,
);
}),
);
}),
);
};
ActionsRegistry['update-television-events'] = async function (data) {
// Used to change "Television" structure events
// to use a topic as a log-key, instead of a string.
const structure_id = data.log_structures
.filter((log_structure) => log_structure.name === 'Television')[0].id;
const parent_topic_id = data.log_topics
.filter((log_topic) => log_topic.name === 'Television Series')[0].id;
const value_index = 0;
data.log_structures.forEach((log_structure) => {
if (log_structure.id === structure_id) {
const keys = JSON.parse(log_structure.keys);
keys[0].type = 'log_topic';
keys[0].is_optional = false;
keys[0].parent_topic_id = parent_topic_id;
log_structure.keys = JSON.stringify(keys);
data.log_structures_to_log_topics.push({
source_structure_id: log_structure.id,
target_topic_id: parent_topic_id,
});
}
});
let maxTopicId = Math.max(...data.log_topics.map((log_topic) => log_topic.id));
const nameToTopicId = {};
data.log_topics.forEach((log_topic) => {
if (log_topic.parent_topic_id === parent_topic_id) {
nameToTopicId[log_topic.name] = log_topic.id;
}
});
data.log_events.forEach((log_event) => {
if (log_event.structure_id === structure_id) {
const values = JSON.parse(log_event.structure_values);
const series_name = values[value_index];
if (!nameToTopicId[series_name]) {
maxTopicId += 1;
const new_topic_id = maxTopicId;
data.log_topics.push({
id: new_topic_id,
mode_id: 1,
parent_topic_id,
ordering_index: 0,
name: series_name,
details: '',
child_count: 0,
is_favorite: 0,
is_deprecated: 0,
});
nameToTopicId[series_name] = new_topic_id;
}
const topic_id = nameToTopicId[series_name];
values[value_index] = {
__type__: 'log-topic',
__id__: topic_id,
name: series_name,
};
log_event.structure_values = JSON.stringify(values);
data.log_events_to_log_topics.push({
source_event_id: log_event.id,
target_topic_id: topic_id,
});
}
});
if (false) {
const name_to_count = {};
data.log_topics.forEach((log_topic) => {
if (!(log_topic.name in name_to_count)) {
name_to_count[log_topic.name] = 0;
}
name_to_count[log_topic.name] += 1;
});
const multiple_topics = Object.entries(name_to_count).filter((kvp) => kvp[1] > 1);
if (multiple_topics.length) console.info(multiple_topics);
}
const validate = async () => {
const logStructure = await this.invoke.call(this, 'log-structure-load', { __id__: structure_id });
await this.invoke.call(this, 'log-structure-upsert', logStructure);
};
return { data, validate };
};
ActionsRegistry['update-xyz-events'] = async function (data) {
const log_structure = data.log_structures.filter((item) => item.name === 'xyz')[0];
const keys = JSON.parse(log_structure.keys);
keys.splice(2, 1, ...[
{
name: 'xyz',
type: 'string',
is_optional: false,
template: null,
parent_topic_id: null,
},
{
name: 'xyz',
type: 'string',
is_optional: false,
template: null,
parent_topic_id: null,
},
]);
log_structure.keys = JSON.stringify(keys);
const mapping = {};
data.log_events.forEach((log_event) => {
if (log_event.structure_id === log_structure.id) {
const values = JSON.parse(log_event.structure_values);
const new_status_values = mapping[values[2]];
if (!new_status_values) {
throw log_event;
}
values.splice(2, 1, ...new_status_values);
log_event.structure_values = JSON.stringify(values);
}
});
return { data };
};
ActionsRegistry['add-structure-to-events'] = async function () {
// Used to add "Project Work" structure to events with the "GLADOS" topic.
const logTopic = await this.invoke.call(this, 'log-topic-load', { __id__: 4 });
const logStructure = await this.invoke.call(this, 'log-structure-load', { __id__: 120 });
const logEvents = await this.invoke.call(
this,
'log-event-list',
{ where: { logTopics: [logTopic], logStructure: null } },
);
const prefix = `${logTopic.name}: `;
await Promise.all(logEvents.map(async (logEvent) => {
const oldTitleText = RichTextUtils.extractPlainText(logEvent.title);
if (logEvent.logStructure || !oldTitleText.startsWith(prefix)) {
return;
}
logEvent.logStructure = {
...logStructure,
eventKeys: logStructure.eventKeys.map((logKey) => ({ ...logKey })),
};
logEvent.logStructure.eventKeys[0].value = getPartialItem(logTopic);
logEvent.logStructure.eventKeys[1].value = RichTextUtils.removePrefixFromDraftContext(
logEvent.title,
prefix,
);
// Warning! May need to disable this.database.setEdges in LogEvent.save() to avoid timeout.
logEvent = await this.invoke.call(this, 'log-event-upsert', logEvent);
const newTitleText = RichTextUtils.extractPlainText(logEvent.title);
console.info('Old:', oldTitleText);
console.info('New:', newTitleText);
}));
};
ActionsRegistry['convert-structure-to-topics'] = async function (data) {
const collections_container_topic_id = 458;
const original_structure_id = 87;
const original_structure = data.log_structures.find(
(log_structure) => log_structure.id === original_structure_id,
);
// Create replacement topic.
let last_topic_id = Math.max(...data.log_topics.map((log_topic) => log_topic.id));
last_topic_id += 1;
const collection_topic_id = last_topic_id;
data.log_topics.push({
id: last_topic_id,
parent_topic_id: collections_container_topic_id,
ordering_index: 24,
name: `${original_structure.name}s`,
details: '',
child_count: 0,
is_favorite: 0,
is_deprecated: 0,
child_keys: original_structure.event_keys,
parent_values: null,
});
// Modify existing structure to point to topic.
original_structure.event_keys = JSON.stringify([
{ name: 'Name', type: 'log_topic', parent_topic_id: collection_topic_id },
]);
// Update all events to use topic.
const nameToTopicId = {};
data.log_events.forEach((log_event) => {
if (log_event.structure_id === original_structure_id) {
const topicName = JSON.parse(log_event.structure_values)[0];
let topic_id;
if (topicName in nameToTopicId) {
topic_id = nameToTopicId[topicName];
} else {
last_topic_id += 1;
topic_id = last_topic_id;
data.log_topics.push({
id: last_topic_id,
parent_topic_id: collection_topic_id,
ordering_index: 0,
name: topicName,
details: log_event.details,
child_count: 0,
is_favorite: 0,
is_deprecated: 0,
child_keys: null,
parent_values: log_event.structure_values,
});
log_event.details = '';
nameToTopicId[topicName] = topic_id;
}
log_event.structure_values = JSON.stringify([
{
__type__: 'log-topic',
__id__: topic_id,
name: topicName,
},
]);
}
});
const nameToCount = {};
data.log_topics.forEach((log_topic) => {
if (log_topic.name in nameToCount) {
nameToCount[log_topic.name] += 1;
} else {
nameToCount[log_topic.name] = 1;
}
});
console.info(Object.entries(nameToCount).filter(([key, value]) => value > 1));
return { data };
};
export default ActionsRegistry;
================================================
FILE: src/plugins/kaustubh/long_term_goals/LongTermGoalGraph.js
================================================
import {
addDays, compareAsc, differenceInDays,
} from 'date-fns';
import React from 'react';
import { DateContext } from '../../../client/Common';
import { getGraphData, Granularity, GraphLineChart } from '../../../client/Graphs';
import PropTypes from '../../../client/prop-types';
import DateUtils from '../../../common/DateUtils';
const CURRENT_KEY = '__current__';
const TARGET_KEY = '__target__';
function CustomTooltip({ active, label, payload }) {
if (active && payload && payload.length) {
const output = [];
output.push(`Date: ${label}`);
payload.forEach((item) => {
output.push(`${item.name}: ${item.payload[item.dataKey]}`);
});
const { logEventTitles } = payload[0].payload;
if (logEventTitles.length) {
output.push('', ...logEventTitles);
}
return (
{output.map((line) => `${line}\n`).join('')}
);
}
return null;
}
CustomTooltip.propTypes = {
active: PropTypes.bool,
label: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
payload: PropTypes.any,
};
class LongTermGoalGraph extends React.Component {
constructor(props) {
super(props);
this.state = { graphData: null };
}
componentDidMount() {
this.fetchData();
}
async fetchData() {
this.setState({ graphData: null });
const { goal } = this.props;
const granularity = Granularity.DAY;
// Standard Graph Calculations
const dateRange = { ...goal.dateRange, endDate: this.props.todayDate };
const where = { date: dateRange, logStructure: goal.logStructure };
const logEvents = await window.api.send('log-event-list', { where });
const graphData = getGraphData(
goal.logStructure,
logEvents,
goal.dateRange,
granularity,
);
// Replace lines.
const selectedLine = graphData.lines.find((line) => line.name === goal.keyLabel);
graphData.lines = [
{ name: goal.newLabel, dataKey: CURRENT_KEY },
{ name: 'Target', dataKey: TARGET_KEY, color: 'var(--link-color)' },
];
// Limit samples to today.
graphData.samples = graphData.samples.filter((sample) => {
const sampleDate = DateUtils.getDate(sample.label);
return compareAsc(sampleDate, this.props.todayDate) <= 0;
});
// Compute progress and prorated target.
const startDate = DateUtils.getDate(goal.dateRange.startDate);
const endDate = DateUtils.getDate(goal.dateRange.endDate);
const totalDays = differenceInDays(endDate, startDate) + 1;
let partialSum = 0;
graphData.samples.forEach((sample) => {
const sampleDate = DateUtils.getDate(sample.label);
let nextSampleDate = addDays(sampleDate, 1);
const getGroupLabel = Granularity[granularity].getLabel;
while (getGroupLabel(sampleDate) === getGroupLabel(nextSampleDate)) {
nextSampleDate = addDays(nextSampleDate, 1);
}
let dayCount = differenceInDays(nextSampleDate, startDate);
dayCount = Math.max(dayCount, 0);
dayCount = Math.min(dayCount, totalDays);
sample[TARGET_KEY] = (parseFloat(goal.target) * (dayCount / totalDays)).toFixed(2);
if (compareAsc(sampleDate, this.props.todayDate) <= 0) {
partialSum += sample[selectedLine.valuesKey]
.reduce((result, value) => (result + value), 0);
sample[CURRENT_KEY] = partialSum;
// Only the last color assigned is applicable.
graphData.lines[0].color = sample[CURRENT_KEY] >= sample[TARGET_KEY] ? 'var(--topic-color)' : 'var(--warning-color)';
}
});
// Additional Props
graphData.tooltip = CustomTooltip;
this.setState({ graphData });
}
render() {
const { graphData } = this.state;
return graphData ? : null;
}
}
LongTermGoalGraph.propTypes = {
todayDate: PropTypes.instanceOf(Date).isRequired,
goal: PropTypes.shape({
logStructure: PropTypes.Custom.Item.isRequired,
keyLabel: PropTypes.string.isRequired,
newLabel: PropTypes.string.isRequired,
dateRange: PropTypes.Custom.DateRange.isRequired,
target: PropTypes.string.isRequired,
}).isRequired,
};
export default DateContext.Wrapper(LongTermGoalGraph);
================================================
FILE: src/plugins/kaustubh/long_term_goals/LongTermGoalsSettings.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import {
DateRangePicker, LeftRight, SortableList, TextInput, TypeaheadOptions, TypeaheadSelector,
} from '../../../client/Common';
import { getNextID } from '../../../common/data_types';
function addNewItem(items) {
const item = {
__id__: getNextID(items),
logStructure: null,
keyLabel: '',
newLabel: '',
dateRange: null,
target: '0',
};
return items.concat(item);
}
function renderRow(props) {
const { item } = props;
const children = props.children || [];
return (
{children.shift()}
props.onChange({ ...item, logStructure })}
/>
props.onChange({ ...item, keyLabel })}
/>
props.onChange({ ...item, newLabel })}
/>
{children.pop()}
props.onChange({ ...item, dateRange })}
/>
props.onChange({ ...item, target })}
/>
);
}
function LongTermGoalsSettings(props) {
const items = props.value || [];
return (
);
}
LongTermGoalsSettings.propTypes = {
disabled: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
};
export default LongTermGoalsSettings;
================================================
FILE: src/plugins/kaustubh/long_term_goals/client.js
================================================
import React from 'react';
import { PluginClient, PluginDisplayLocation } from '../../../client/Common';
import LongTermGoalGraph from './LongTermGoalGraph';
import LongTermGoalsSettings from './LongTermGoalsSettings';
export default class extends PluginClient {
static getSettingsKey() {
return 'long_term_goals';
}
static getSettingsComponent(props) {
return ;
}
static getDisplayLocation() {
return PluginDisplayLocation.TAB_SECTION;
}
static getTabData() {
return {
value: 'long_term_goals',
label: 'Long Term Goals',
};
}
static getDisplayComponent(props) {
return (props.settings || []).map((goal) => (
));
}
}
================================================
FILE: src/plugins/kaustubh/more_event_lists/MoreEventListsSettings.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import {
LeftRight, SortableList, TextInput,
} from '../../../client/Common';
import { getNextID } from '../../../common/data_types';
/*
FIlters I want?
Tomorrow.
Next 7 days.
Overdue.
Major events in the future.
Date: lt/gt
DateRange: startDate, EndDate
DatePicker / DateRangePicker are not useful.
Pure Text for Date? lt(today)
IsComplete?
Topics / Structures
*/
function renderRow(props) {
const { item } = props;
const children = props.children || [];
return (
{children.shift()}
props.onChange({ ...item, label })}
/>
{children.pop()}
);
}
function MoreEventListsSettings(props) {
const items = props.value || [];
return (
);
}
MoreEventListsSettings.propTypes = {
disabled: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
};
export default MoreEventListsSettings;
================================================
FILE: src/plugins/kaustubh/more_event_lists/client.js
================================================
import React from 'react';
import { PluginClient } from '../../../client/Common';
import MoreEventListsSettings from './MoreEventListsSettings';
export default class extends PluginClient {
static getSettingsKey() {
return 'more_event_lists';
}
static getSettingsComponent(props) {
return ;
}
static getDisplayLocation() {
return null;
}
static getDisplayComponent(props) {
return null;
}
}
================================================
FILE: src/plugins/kaustubh/time_sections/TimeSection.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { LeftRight, SidebarSection } from '../../../client/Common';
const { formatToTimeZone } = require('date-fns-timezone');
class TimeSection extends React.Component {
constructor(props) {
super(props);
const offset = new Date().valueOf() % 1000;
this.timeout = window.setTimeout(() => {
this.interval = window.setInterval(() => this.forceUpdate(), 1000);
}, 1000 - offset);
}
componentWillUnmount() {
window.clearTimeout(this.timeout);
window.clearInterval(this.interval);
}
render() {
return (
{this.props.label}
{formatToTimeZone(
new Date(),
'HH:mm:ss',
{ timeZone: this.props.timezone },
)}
);
}
}
TimeSection.propTypes = {
label: PropTypes.string.isRequired,
timezone: PropTypes.string.isRequired,
};
export default TimeSection;
================================================
FILE: src/plugins/kaustubh/time_sections/TimeSectionSettings.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import { listTimeZones } from 'timezone-support';
import {
LeftRight, Selector, SortableList, TextInput,
} from '../../../client/Common';
import { getNextID } from '../../../common/data_types';
const TIMEZONE_OPTIONS = [{ label: '(timezone)', value: '' }].concat(Selector.getStringListOptions(listTimeZones().sort()));
function renderRow(props) {
const { item } = props;
const children = props.children || [];
return (
{children.shift()}
props.onChange({ ...item, label })}
/>
props.onChange({ ...item, timezone })}
/>
{children.pop()}
);
}
function TimeSectionSettings(props) {
const items = props.value;
return (
);
}
TimeSectionSettings.propTypes = {
disabled: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
};
export default TimeSectionSettings;
================================================
FILE: src/plugins/kaustubh/time_sections/client.js
================================================
import React from 'react';
import { PluginClient } from '../../../client/Common';
import TimeSection from './TimeSection';
import TimeSectionSettings from './TimeSectionSettings';
export default class extends PluginClient {
static getSettingsKey() {
return 'timezones';
}
static getSettingsComponent(props) {
return ;
}
static getDisplayLocation() {
return 'right_sidebar_main_top';
}
static getDisplayComponent(props) {
return (props.settings || [])
.map((item) => (
));
}
}
================================================
FILE: src/plugins/kaustubh/topic_reminder_sections/TopicRemindersSection.js
================================================
import React from 'react';
import {
DataLoader, DateContext, Link, SidebarSection,
} from '../../../client/Common';
import PropTypes from '../../../client/prop-types';
class TopicRemindersSection extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => ({
name: 'topic-reminders',
args: {
todayLabel: this.props.todayLabel,
logStructureId: this.props.logStructureId,
thresholdDays: this.props.thresholdDays,
},
}),
onData: (result) => this.setState(result),
});
}
componentWillUnmount() {
this.dataLoader.stop();
}
renderContents() {
const { logTopicAndDayCounts } = this.state;
if (!logTopicAndDayCounts) {
return 'Loading ...';
}
return logTopicAndDayCounts.map(({ logTopic, dayCount }) => (
{logTopic.name}
{dayCount ? {`(${dayCount} days)`} : null}
));
}
render() {
const { logStructure } = this.state;
const suffix = logStructure ? `: ${logStructure.name}` : '';
return (
{this.renderContents()}
);
}
}
TopicRemindersSection.propTypes = {
todayLabel: PropTypes.string.isRequired,
logStructureId: PropTypes.number.isRequired,
thresholdDays: PropTypes.number.isRequired,
};
export default DateContext.Wrapper(TopicRemindersSection);
================================================
FILE: src/plugins/kaustubh/topic_reminder_sections/TopicRemindersSectionSettings.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import {
HelpIcon, LeftRight, SortableList, TextInput,
TooltipElement, TypeaheadOptions, TypeaheadSelector,
} from '../../../client/Common';
import { getNextID } from '../../../common/data_types';
function renderRow(props) {
const { item } = props;
const children = props.children || [];
return (
{children.shift()}
props.onChange({ ...item, logStructure })}
/>
props.onChange({ ...item, thresholdDays })}
/>
{children.pop()}
);
}
function TopicRemindersSettings(props) {
const helpText = (
'Add sections on the right sidebar for each structure selected here (eg - Conversation). '
+ 'For each structure, look at the details to get a list of topics (eg - people), and then '
+ 'check to see if there are any events with that structure and topic in the last X days. '
+ 'If not, show a reminder about that topic (eg - speak to someone every X days).'
);
const items = props.value;
return (
);
}
TopicRemindersSettings.propTypes = {
disabled: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
};
export default TopicRemindersSettings;
================================================
FILE: src/plugins/kaustubh/topic_reminder_sections/actions.js
================================================
/* eslint-disable func-names */
import { differenceInCalendarDays } from 'date-fns';
import DateUtils from '../../../common/DateUtils';
import RichTextUtils from '../../../common/RichTextUtils';
const ActionsRegistry = {};
ActionsRegistry['topic-reminders'] = async function ({
todayLabel,
logStructureId,
thresholdDays,
}) {
const logStructure = await this.invoke.call(
this,
'log-structure-load',
{ __id__: logStructureId },
);
const todayDate = DateUtils.getDate(todayLabel);
const logTopics = Object.values(
RichTextUtils.extractMentions(logStructure.details, 'log-topic'),
);
const logTopicAndDayCounts = await Promise.all(logTopics.map(async (logTopic) => {
// TODO: Fetch only the latest item from the database.
const logEvents = await this.invoke.call(
this,
'log-event-list',
{
where: { logStructure, logTopics: [logTopic], isComplete: true },
limit: 1,
},
);
const logEvent = logEvents.pop();
let dayCount = null;
let needsReminder = true;
if (logEvent) {
const lastDate = DateUtils.getDate(logEvent.date);
dayCount = differenceInCalendarDays(todayDate, lastDate);
needsReminder = dayCount > thresholdDays;
}
return needsReminder ? { logTopic, dayCount } : null;
}));
return { logStructure, logTopicAndDayCounts: logTopicAndDayCounts.filter((item) => item) };
};
export default ActionsRegistry;
================================================
FILE: src/plugins/kaustubh/topic_reminder_sections/client.js
================================================
import React from 'react';
import { PluginClient } from '../../../client/Common';
import TopicRemindersSection from './TopicRemindersSection';
import TopicRemindersSectionSettings from './TopicRemindersSectionSettings';
export default class extends PluginClient {
static getSettingsKey() {
return 'reminder_sections';
}
static getSettingsComponent(props) {
return ;
}
static getDisplayLocation() {
return 'right_sidebar_widgets_bottom';
}
static getDisplayComponent(props) {
return (props.settings || [])
.map((item) => (
));
}
}
================================================
FILE: src/plugins/kaustubh/topic_sections/TopicSection.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import { DataLoader, SidebarSection } from '../../../client/Common';
import RichTextUtils from '../../../common/RichTextUtils';
class TopicSection extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this.dataLoader = new DataLoader({
getInput: () => ({
name: 'log-topic-load',
args: { __id__: this.props.logTopicId },
}),
onData: (logTopic) => this.setState({ logTopic }),
});
}
componentWillUnmount() {
this.dataLoader.stop();
}
renderContent() {
const { logTopic } = this.state;
if (!logTopic) {
return 'Loading ...';
}
// TODO: Update the style of bullet items in the TextEditor, use that instead.
const details = RichTextUtils.deserialize(
RichTextUtils.serialize(
logTopic.details,
RichTextUtils.StorageType.DRAFTJS,
),
RichTextUtils.StorageType.MARKDOWN,
);
const lines = details.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.substr(2));
return lines.map((item) => (
{item}
));
}
render() {
const { logTopic } = this.state;
return (
{this.renderContent()}
);
}
}
TopicSection.propTypes = {
logTopicId: PropTypes.number.isRequired,
};
export default TopicSection;
================================================
FILE: src/plugins/kaustubh/topic_sections/TopicSectionSettings.js
================================================
import PropTypes from 'prop-types';
import React from 'react';
import InputGroup from 'react-bootstrap/InputGroup';
import {
HelpIcon, LeftRight, SortableList, TooltipElement, TypeaheadOptions, TypeaheadSelector,
} from '../../../client/Common';
import { getNextID } from '../../../common/data_types';
function renderRow(props) {
const { item } = props;
const children = props.children || [];
return (
{children.shift()}
props.onChange({ ...item, logTopic })}
/>
{children.pop()}
);
}
function TopicSectionSettings(props) {
const helpText = (
'Add sections on the right sidebar for each topic selected here, '
+ 'which display the bullet-points in the details of those topics.'
);
const items = props.value;
return (
);
}
TopicSectionSettings.propTypes = {
disabled: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
};
export default TopicSectionSettings;
================================================
FILE: src/plugins/kaustubh/topic_sections/client.js
================================================
import React from 'react';
import { PluginClient } from '../../../client/Common';
import TopicSection from './TopicSection';
import TopicSectionSettings from './TopicSectionSettings';
export default class extends PluginClient {
static getSettingsKey() {
return 'topic_sections';
}
static getSettingsComponent(props) {
return ;
}
static getDisplayLocation() {
return 'right_sidebar_widgets_top';
}
static getDisplayComponent(props) {
return (props.settings || [])
.map((item) => (
));
}
}
================================================
FILE: src/server/__tests__/Config.test.js
================================================
import fs from 'fs';
const CONFIG_FORMAT = {
'?lock': 'string',
database: {
dialect: 'string',
storage: 'string',
logging: 'boolean',
},
backup: {
location: 'string',
},
server: {
host: 'string',
port: 'number',
},
'?plugins': ['string'],
};
function check(pattern, value) {
if (Array.isArray(pattern)) {
expect(pattern.length).toEqual(1);
value.forEach((subvalue) => {
check(pattern[0], subvalue);
});
} else if (typeof pattern === 'object') {
expect(typeof value).toEqual('object');
expect(value).not.toBeNull();
Object.entries(pattern).forEach(([key, subpattern]) => {
if (key.startsWith('?')) {
key = key.slice(1);
if (Object.prototype.hasOwnProperty.call(value, key)) {
check(subpattern, value[key]);
}
} else {
expect(Object.prototype.hasOwnProperty.call(value, key)).toEqual(true);
check(subpattern, value[key]);
}
});
} else if (typeof pattern === 'string') {
if (pattern.startsWith('?') && value !== null) {
check(value, pattern.slice(1));
} else {
expect(typeof value).toEqual(pattern);
}
}
}
function ensureValidConfig(configPath) {
if (fs.existsSync(configPath)) {
const contents = fs.readFileSync(configPath);
check(CONFIG_FORMAT, JSON.parse(contents));
}
}
test('verify_config_structure', () => {
ensureValidConfig('config/example.glados.json');
ensureValidConfig('config/demo.glados.json');
ensureValidConfig('config.json');
});
================================================
FILE: src/server/actions/__tests__/Backup.test.js
================================================
import tmp from 'tmp';
import { LogTopic } from '../../../common/data_types';
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
function tmpDir() {
return new Promise((resolve, reject) => {
tmp.dir((error, path, _cleanup) => {
if (error) {
reject(error);
} else {
resolve(path);
}
});
}, { unsafeCleanup: true });
}
test('test_backup', async () => {
const actions = await TestUtils.getActions();
const tempDirPath = await tmpDir();
actions.config = { backup: { location: tempDirPath } };
await actions.invoke('backup-save');
await TestUtils.loadData({
logTopics: [
{ name: 'Hydrogen' },
{ name: 'Helium' },
{ name: 'Lithium' },
{ name: 'Beryllium' },
{ name: 'Boron' },
],
});
expect((await actions.invoke('log-topic-list')).length).toEqual(5);
await actions.invoke('backup-save');
expect((await actions.invoke('log-topic-list')).length).toEqual(5);
await actions.invoke('log-topic-upsert', LogTopic.createVirtual({ name: 'Carbon' }));
await actions.invoke('log-topic-upsert', LogTopic.createVirtual({ name: 'Nitrogen' }));
await actions.invoke('log-topic-upsert', LogTopic.createVirtual({ name: 'Oxygen' }));
expect((await actions.invoke('log-topic-list')).length).toEqual(8);
await actions.invoke('backup-load');
expect((await actions.invoke('log-topic-list')).length).toEqual(5);
});
================================================
FILE: src/server/actions/__tests__/Database.test.js
================================================
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
test('test_load_save_and_clear', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Mathematics' },
{ name: 'Physics', parentTopicName: 'Mathematics' },
{ name: 'Chemistry', parentTopicName: 'Physics' },
{ name: 'English' },
{ name: 'Computer Science', parentTopicName: 'Physics' },
],
});
const actions = await TestUtils.getActions();
const data = await actions.invoke('database-load');
data.log_topics = data.log_topics.slice(0, -2);
await actions.invoke('database-save', data);
const logTopics = await actions.invoke('log-topic-list');
expect(logTopics.length).toEqual(3);
data.log_topics = data.log_topics.slice(1); // violates foreign key constraint
await expect(actions.invoke('database-save', data)).rejects.toThrow();
});
test('test_data_format_version', async () => {
const actions = await TestUtils.getActions();
await actions.invoke('database-validate');
const data = await actions.invoke('database-load');
await actions.invoke('database-validate', { data });
data.settings[0].value += '+';
await expect(actions.invoke('database-validate', { data })).rejects.toThrow();
});
================================================
FILE: src/server/actions/__tests__/LogEvent.test.js
================================================
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
test('test_structure_constraint', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'TestGroup' },
],
logStructures: [
{
groupName: 'TestGroup',
name: 'Animals',
eventKeys: [
{ name: 'Size', type: 'string' },
{ name: 'Legs', type: 'integer' },
],
},
],
logEvents: [
{
date: '2020-06-28',
title: 'Cat',
structureName: 'Animals',
logValues: ['small', '4'],
},
],
});
/*
const actions = TestUtils.getActions();
await expect(() => actions.invoke('log-structure-delete', 1)).rejects.toThrow();
await actions.invoke('log-event-delete', 1);
await actions.invoke('log-structure-delete', 1);
*/
});
test('test_event_update', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'TestGroup' },
],
logStructures: [
{
groupName: 'TestGroup',
name: 'Animals',
eventKeys: [
{ name: 'Size', type: 'string' },
{ name: 'Legs', type: 'integer' },
],
},
],
logEvents: [
{
date: '2020-06-28',
title: 'Cat',
structureName: 'Animals',
logValues: ['small', '4'],
},
],
});
const actions = TestUtils.getActions();
const logEvent = await actions.invoke('log-event-load', { __id__: 1 });
logEvent.title = 'Dog';
logEvent.logStructure.eventKeys[0].value = 'medium';
await actions.invoke('log-event-upsert', logEvent);
});
test('test_log_event_value_typeahead', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'TestGroup' },
],
logStructures: [
{
groupName: 'TestGroup',
name: 'Animals',
eventKeys: [
{ name: 'Size', type: 'string' },
{ name: 'Legs', type: 'integer' },
],
},
],
logEvents: [
{
date: '2020-06-28',
title: 'Cat',
structureName: 'Animals',
logValues: ['small', '4'],
},
],
});
const actions = TestUtils.getActions();
let logValueSuggestions;
const logEvent = await actions.invoke('log-event-load', { __id__: 1 });
const input = { source: logEvent.logStructure, index: null, query: '' };
logValueSuggestions = await actions.invoke('value-typeahead', { ...input, index: 0 });
expect(logValueSuggestions).toEqual(['small']);
logValueSuggestions = await actions.invoke('value-typeahead', { ...input, index: 1 });
expect(logValueSuggestions).toEqual(['4']);
});
================================================
FILE: src/server/actions/__tests__/LogStructure.test.js
================================================
import { LogKey } from '../../../common/data_types';
import RichTextUtils from '../../../common/RichTextUtils';
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
test('test_key_updates', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Entertainment' },
],
logStructures: [
{
groupName: 'Entertainment',
name: 'Movie',
eventKeys: [
{ name: 'Title', type: 'string' },
{ name: 'Link', type: 'string' },
{ name: 'Rating', type: 'integer' },
],
eventeventTitleTemplate: '$0: [$1]($2)',
},
],
logEvents: [
{
date: '2020-08-23',
structureName: 'Movie',
logValues: ['The Martian', 'https://www.imdb.com/title/tt3659388/', '9'],
},
],
});
const actions = TestUtils.getActions();
const oldLogEvent = await actions.invoke('log-event-load', { __id__: 1 });
const oldValues = oldLogEvent.logStructure.eventKeys.map((logKey) => logKey.value);
const logStructure = await actions.invoke('log-structure-load', { __id__: 1 });
const newLogKey = {
...LogKey.createVirtual(),
name: 'Worthwhile?',
type: LogKey.Type.YES_OR_NO,
value: 'yes',
};
logStructure.eventKeys = [
logStructure.eventKeys[1],
logStructure.eventKeys[0],
newLogKey,
];
await actions.invoke('log-structure-upsert', logStructure);
const newLogEvent = await actions.invoke('log-event-load', { __id__: 1 });
const newValues = newLogEvent.logStructure.eventKeys.map((logKey) => logKey.value);
expect(newValues[0]).toEqual(oldValues[1]);
expect(newValues[1]).toEqual(oldValues[0]);
expect(newValues[2]).toEqual(newLogKey.value);
});
test('test_structure_deletion', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Misc' },
],
logStructures: [
{
groupName: 'Misc',
name: 'Testingwa',
eventTitleTemplate: '$0',
},
],
});
const actions = TestUtils.getActions();
await expect(() => actions.invoke('log-topic-delete', 1)).rejects.toThrow();
await actions.invoke('log-structure-delete', 1);
const logTopics = await actions.invoke('log-topic-list');
expect(logTopics.length).toEqual(0);
});
test('test_structure_title_template_expression', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Exercise' },
],
logStructures: [
{
groupName: 'Exercise',
name: 'Cycling',
eventKeys: [
{ name: 'Distance (miles)', type: 'integer' },
{ name: 'Time (minutes)', type: 'integer' },
],
eventTitleTemplate: '$0: $1 miles / $2 minutes',
},
],
logEvents: [
{
date: '2020-06-26',
structureName: 'Cycling',
logValues: ['15', '60'],
},
{
date: '2020-06-27',
structureName: 'Cycling',
logValues: ['15', '55'],
},
{
date: '2020-06-28',
structureName: 'Cycling',
logValues: ['15', '50'],
},
],
});
const actions = TestUtils.getActions();
let logEvents = await actions.invoke('log-event-list');
expect(logEvents.map((logEvent) => RichTextUtils.extractPlainText(logEvent.title))).toEqual([
'Cycling: 15 miles / 60 minutes',
'Cycling: 15 miles / 55 minutes',
'Cycling: 15 miles / 50 minutes',
]);
const { logStructure } = logEvents[0];
logStructure.eventTitleTemplate = RichTextUtils.convertPlainTextToDraftContent(
'$0: $1 miles / $2 minutes ({($1*60/$2).toFixed(2)} mph)',
{ $: [logStructure, ...logStructure.eventKeys] },
);
await actions.invoke('log-structure-upsert', logStructure);
logEvents = await actions.invoke('log-event-list');
expect(logEvents.map((logEvent) => RichTextUtils.extractPlainText(logEvent.title))).toEqual([
'Cycling: 15 miles / 60 minutes (15.00 mph)',
'Cycling: 15 miles / 55 minutes (16.36 mph)',
'Cycling: 15 miles / 50 minutes (18.00 mph)',
]);
});
test('test_structure_title_template_link', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Education' },
],
logStructures: [
{
groupName: 'Education',
name: 'Article',
eventKeys: [
{ name: 'Title', type: 'string' },
{ name: 'Link', type: 'string' },
],
eventTitleTemplate: '$0: [$1]($2)',
},
],
logEvents: [
{
date: '2020-08-23',
structureName: 'Article',
logValues: ['Facebook', 'https://facebook.com'],
},
],
});
const actions = TestUtils.getActions();
const logEvents = await actions.invoke('log-event-list');
expect(RichTextUtils.extractPlainText(logEvents[0].title)).toEqual('Article: Facebook');
});
test('test_structure_with_topic', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Books' },
{ name: 'Harry Potter', parentTopicName: 'Books' },
{ name: 'Foundation', parentTopicName: 'Books' },
],
logStructureGroups: [
{ name: 'Education' },
],
logStructures: [
{
groupName: 'Education',
name: 'Reading',
eventKeys: [
{ name: 'Book', type: 'log_topic', parentTopicName: 'Books' },
{ name: 'Progress', type: 'string' },
],
eventTitleTemplate: '$0: $1 ($2)',
},
],
logEvents: [
{
date: '2020-07-23',
structureName: 'Reading',
logValues: ['Harry Potter', '60'],
},
],
});
const actions = TestUtils.getActions();
await expect(() => actions.invoke('log-topic-delete', 2)).rejects.toThrow();
const logEvent = await actions.invoke('log-event-load', { __id__: 1 });
logEvent.logStructure.eventKeys[0].value = await actions.invoke('log-topic-load', { __id__: 3 });
await actions.invoke('log-event-upsert', logEvent);
await actions.invoke('log-topic-delete', 2);
});
================================================
FILE: src/server/actions/__tests__/LogTopic.test.js
================================================
import { asyncSequence } from '../../../common/AsyncUtils';
import { LogKey } from '../../../common/data_types';
import RichTextUtils from '../../../common/RichTextUtils';
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
test('test_log_topic_typeahead', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Anurag Dubey' },
{ name: 'Kaustubh Karkare' },
{ name: 'Vishnu Mohandas' },
{ name: 'philosophy' },
{ name: 'productivity' },
],
});
const actions = TestUtils.getActions();
let logTopics;
logTopics = await actions.invoke('log-topic-typeahead', { query: '' });
expect(logTopics.length).toEqual(5);
logTopics = await actions.invoke('log-topic-typeahead', { query: 'k' });
expect(logTopics.length).toEqual(1);
logTopics = await actions.invoke('log-topic-typeahead', { query: 'p' });
expect(logTopics.length).toEqual(2);
logTopics = await actions.invoke('log-topic-typeahead', { query: 'i' });
expect(logTopics.length).toEqual(3); // appears in 3 different items
logTopics = await actions.invoke('log-topic-typeahead', { query: 'x' });
expect(logTopics.length).toEqual(0);
});
test('test_update_propagation', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Hacky' },
{ name: 'Todo', details: 'Speak to a #1' },
],
logEvents: [
{ date: '{today}', title: 'Spoke to a #1' },
],
});
const actions = TestUtils.getActions();
let logEvent = await actions.invoke('log-event-load', { __id__: 1 });
expect(RichTextUtils.extractPlainText(logEvent.title)).toEqual('Spoke to a Hacky');
let logTopic = await actions.invoke('log-topic-load', { __id__: 2 });
expect(RichTextUtils.extractPlainText(logTopic.details)).toEqual('Speak to a Hacky');
const person = await actions.invoke('log-topic-load', { __id__: 1 });
person.name = 'Noob';
await actions.invoke('log-topic-upsert', person);
logEvent = await actions.invoke('log-event-load', logEvent);
expect(RichTextUtils.extractPlainText(logEvent.title)).toEqual('Spoke to a Noob');
logTopic = await actions.invoke('log-topic-load', logTopic);
expect(RichTextUtils.extractPlainText(logTopic.details)).toEqual('Speak to a Noob');
await expect(() => actions.invoke('log-topic-delete', person.__id__)).rejects.toThrow();
await actions.invoke('log-event-delete', logEvent.__id__);
await actions.invoke('log-topic-delete', logTopic.__id__);
await actions.invoke('log-topic-delete', person.__id__);
});
test('test_child_keys', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Movies' },
{ name: 'The Martian', parentTopicName: 'Movies' },
{ name: 'Inside Out', parentTopicName: 'Movies' },
{ name: 'Bhool Bhulaiyaa 2', parentTopicName: 'Movies' },
],
});
const actions = TestUtils.getActions();
let parentLogTopic = await actions.invoke('log-topic-load', { __id__: 1 });
const newLogKey = {
...LogKey.createVirtual(),
name: 'Worthwhile?',
type: LogKey.Type.YES_OR_NO,
// value: 'yes',
};
parentLogTopic.childKeys = [newLogKey];
await expect(() => actions.invoke('log-topic-upsert', parentLogTopic)).rejects.toThrow();
parentLogTopic.childKeys[0].value = 'yes';
parentLogTopic = await actions.invoke('log-topic-upsert', parentLogTopic);
let childLogTopic = await actions.invoke('log-topic-load', { __id__: 4 });
childLogTopic.parentLogTopic.childKeys[0].value = 'no';
childLogTopic = await actions.invoke('log-topic-upsert', childLogTopic);
});
test('test_counts', async () => {
await TestUtils.loadData({
logTopics: [
{ name: 'Parent1' },
{ name: 'Parent2' },
{ name: 'Child', parentTopicName: 'Parent1' },
],
});
const actions = TestUtils.getActions();
const parentLogTopicIds = [1, 2];
const expectChildCounts = async (counts) => {
await asyncSequence(
parentLogTopicIds,
async (id, index) => {
const parentLogTopic = await actions.invoke('log-topic-load', { __id__: id });
expect(parentLogTopic.childCount).toEqual(counts[index]);
},
);
};
await expectChildCounts([1, 0]);
let childLogTopic = await actions.invoke('log-topic-load', { __id__: 3 });
expect(childLogTopic.parentLogTopic.__id__).toEqual(1);
childLogTopic.parentLogTopic.__id__ = 2;
childLogTopic = await actions.invoke('log-topic-upsert', childLogTopic);
expect(childLogTopic.parentLogTopic.__id__).toEqual(2);
await expectChildCounts([0, 1]);
await actions.invoke('log-topic-delete', childLogTopic.__id__);
await expectChildCounts([0, 0]);
});
================================================
FILE: src/server/actions/__tests__/Reminders.test.js
================================================
import TestUtils from './TestUtils';
beforeEach(TestUtils.beforeEach);
afterEach(TestUtils.afterEach);
async function checkIfReminderIsShown(todayLabel, shown) {
const actions = TestUtils.getActions();
const results = await actions.invoke('reminder-sidebar', { todayLabel });
expect(results.length).toEqual(shown ? 1 : 0);
}
test('test_reminder_without_warning', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Daily Routine' },
],
logStructures: [
{
groupName: 'Daily Routine',
name: 'Exercise',
isPeriodic: true,
frequency: 'everyday',
warningDays: 0,
suppressUntilDate: '2020-08-07',
},
],
});
await checkIfReminderIsShown('2020-08-08', true);
await TestUtils.loadData({
logEvents: [
{
date: '2020-08-08',
structureName: 'Exercise',
},
],
});
await checkIfReminderIsShown('2020-08-08', false);
});
test('test_reminder_with_warning', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Birthdays' },
],
logStructures: [
{
groupName: 'Birthdays',
name: 'My Birthday',
eventTitleTemplate: '$0',
isPeriodic: true,
frequency: 'yearly',
frequencyArgs: '08-12',
warningDays: 7,
suppressUntilDate: '2020-01-01',
},
],
logEvents: [
{
date: '2019-08-12',
structureName: 'My Birthday',
},
],
});
await checkIfReminderIsShown('2020-08-01', false);
await checkIfReminderIsShown('2020-08-05', true);
await checkIfReminderIsShown('2020-08-12', true);
await checkIfReminderIsShown('2020-08-15', true);
await TestUtils.loadData({
logEvents: [
{
date: '2020-08-15',
structureName: 'My Birthday',
},
],
});
await checkIfReminderIsShown('2020-08-15', false);
});
async function checkReminderScore(todayLabel, value, deadline) {
const actions = TestUtils.getActions();
const logStructure = await actions.invoke('log-structure-load', { __id__: 1 });
const score = await actions.invoke('reminder-score', { logStructure, todayLabel });
expect(score.value).toEqual(value);
expect(score.deadline).toEqual(deadline);
}
test('test_reminder_score', async () => {
await TestUtils.loadData({
logStructureGroups: [
{ name: 'Weekly' },
],
logStructures: [
{
groupName: 'Weekly',
name: 'Weekly Report',
isPeriodic: true,
frequency: 'friday',
warningDays: 2, // warning starts on wednesday
suppressUntilDate: '2020-08-20',
},
],
});
const addEvent = (date) => TestUtils.loadData({
logEvents: [{ date, structureName: 'Weekly Report' }],
});
await checkReminderScore('2020-08-15', 0, null);
await checkReminderScore('2020-08-20', 0, null);
// week 1: event date = reminder date
await addEvent('2020-08-21'); // friday
await checkReminderScore('2020-08-21', 1, null); // friday
// week 2: event date < reminder date
await checkReminderScore('2020-08-25', 1, null); // tuesday
await checkReminderScore('2020-08-26', 1, '2020-09-01'); // wednesday
await addEvent('2020-08-27'); // thursday
await checkReminderScore('2020-08-28', 2, null); // friday
// week 3: event date > reminder date
await checkReminderScore('2020-09-04', 2, '2020-09-08'); // friday
await addEvent('2020-09-05'); // saturday
// week 4
await checkReminderScore('2020-09-06', 3, null); // sunday
await checkReminderScore('2020-09-11', 3, '2020-09-15'); // friday
// week 5
await checkReminderScore('2020-09-18', -1, '2020-09-22'); // friday
// week 6
await checkReminderScore('2020-09-25', -2, '2020-09-29'); // friday
await addEvent('2020-09-25'); // friday
await checkReminderScore('2020-09-26', 1, null); // saturday
});
================================================
FILE: src/server/actions/__tests__/TestUtils.js
================================================
import { asyncSequence } from '../../../common/AsyncUtils';
import { getVirtualID, LogKey } from '../../../common/data_types';
import DateUtils from '../../../common/DateUtils';
import RichTextUtils from '../../../common/RichTextUtils';
import Actions from '../../actions';
import Database from '../../database';
let actions = null;
function getBool(item, key, defaultValue) {
return typeof item[key] === 'undefined' ? defaultValue : item[key];
}
export default class TestUtils {
static async beforeEach() {
const config = {
dialect: 'sqlite',
storage: ':memory:',
logging: false,
};
const database = new Database(config);
actions = new Actions(null, database);
await actions.invoke('database-reset');
}
static getActions() {
return actions;
}
static async afterEach() {
if (actions) await actions.database.close();
}
static async loadData(data) {
const { todayDate } = DateUtils.getContext();
const logTopicMap = {};
const logTopics = [null];
const existingLogTopics = await actions.invoke('log-topic-list');
existingLogTopics.forEach((outputLogTopic) => {
logTopicMap[outputLogTopic.name] = outputLogTopic;
logTopics.push(outputLogTopic);
});
await asyncSequence(data.logTopics, async (inputLogTopic) => {
inputLogTopic.__id__ = getVirtualID();
if (inputLogTopic.parentTopicName) {
inputLogTopic.parentLogTopic = logTopicMap[inputLogTopic.parentTopicName];
delete inputLogTopic.parentTopicName;
}
inputLogTopic.details = RichTextUtils.convertPlainTextToDraftContent(
inputLogTopic.details || '',
{ '#': logTopics },
);
inputLogTopic.isFavorite = false;
inputLogTopic.isDeprecated = false;
inputLogTopic.hasStructure = false;
const outputLogTopic = await actions.invoke('log-topic-upsert', inputLogTopic);
logTopicMap[outputLogTopic.name] = outputLogTopic;
logTopics.push(outputLogTopic);
});
const logStructureGroupMap = {};
const existingLogStructureGroups = await actions.invoke('log-structure-group-list');
existingLogStructureGroups.forEach((outputLogStructureGroup) => {
logStructureGroupMap[outputLogStructureGroup.name] = outputLogStructureGroup;
});
await asyncSequence(data.logStructureGroups, async (inputLogStructureGroup) => {
inputLogStructureGroup.__id__ = getVirtualID();
const outputLogStructureGroup = await actions.invoke(
'log-structure-group-upsert',
inputLogStructureGroup,
);
logStructureGroupMap[outputLogStructureGroup.name] = outputLogStructureGroup;
});
const logStructureMap = {};
const existingLogStructures = await actions.invoke('log-structure-list');
existingLogStructures.forEach((outputLogStructure) => {
logStructureMap[outputLogStructure.name] = outputLogStructure;
});
await asyncSequence(data.logStructures, async (inputLogStructure) => {
inputLogStructure.__type__ = 'log-structure';
inputLogStructure.__id__ = getVirtualID();
inputLogStructure.logStructureGroup = logStructureGroupMap[inputLogStructure.groupName];
delete inputLogStructure.groupName;
inputLogStructure.details = '';
inputLogStructure.eventAllowDetails = true;
if (inputLogStructure.eventKeys) {
inputLogStructure.eventKeys.forEach((logKey, index) => {
logKey.__type__ = 'log-structure-key';
logKey.__id__ = index + 1;
if (logKey.parentTopicName) {
logKey.parentLogTopic = logTopicMap[logKey.parentTopicName];
delete logKey.parentTopicName;
}
});
} else {
inputLogStructure.eventKeys = [];
}
inputLogStructure.eventTitleTemplate = RichTextUtils.convertPlainTextToDraftContent(
inputLogStructure.eventTitleTemplate || '$0',
{ $: [inputLogStructure, ...inputLogStructure.eventKeys] },
);
inputLogStructure.eventNeedsEdit = inputLogStructure.eventNeedsEdit || false;
inputLogStructure.isFavorite = false;
inputLogStructure.isDeprecated = false;
inputLogStructure.isPeriodic = inputLogStructure.isPeriodic || false;
inputLogStructure.reminderText = inputLogStructure.reminderText || null;
inputLogStructure.frequency = inputLogStructure.frequency || null;
inputLogStructure.frequencyArgs = inputLogStructure.frequencyArgs || null;
inputLogStructure.warningDays = inputLogStructure.isPeriodic
? (inputLogStructure.warningDays || 0)
: null;
inputLogStructure.suppressUntilDate = inputLogStructure.suppressUntilDate || null;
DateUtils.maybeSubstitute(todayDate, inputLogStructure, 'suppressUntilDate');
inputLogStructure.logLevel = 0;
const outputLogStructure = await actions.invoke('log-structure-upsert', inputLogStructure);
logStructureMap[outputLogStructure.name] = outputLogStructure;
});
await asyncSequence(data.logEvents, async (inputLogEvent) => {
inputLogEvent.__id__ = getVirtualID();
DateUtils.maybeSubstitute(todayDate, inputLogEvent, 'date');
inputLogEvent.title = RichTextUtils.convertPlainTextToDraftContent(
inputLogEvent.title || '',
{ '#': logTopics },
);
inputLogEvent.details = RichTextUtils.convertPlainTextToDraftContent(
inputLogEvent.details || '',
{ '#': logTopics },
);
inputLogEvent.logLevel = 0;
inputLogEvent.isFavorite = false;
inputLogEvent.isComplete = getBool(inputLogEvent, 'isComplete', true);
if (inputLogEvent.structureName) {
inputLogEvent.logStructure = logStructureMap[inputLogEvent.structureName];
if (inputLogEvent.logValues) {
inputLogEvent.logValues.forEach((value, index) => {
const logKey = inputLogEvent.logStructure.eventKeys[index];
if (logKey.type === LogKey.Type.LOG_TOPIC) {
logKey.value = logTopicMap[value];
} else {
logKey.value = value;
}
});
}
}
await actions.invoke('log-event-upsert', inputLogEvent);
});
}
}
================================================
FILE: src/server/actions/backup.js
================================================
/* eslint-disable func-names */
import assert from 'assert';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { callbackToPromise } from '../../common/AsyncUtils';
function getDateAndTime() {
const date = new Date();
let dateLabel = date.getFullYear();
dateLabel += (`0${(date.getMonth() + 1)}`).substr(-2);
dateLabel += (`0${date.getDate()}`).substr(-2);
let timeLabel = (`0${date.getHours()}`).substr(-2);
timeLabel += (`0${date.getMinutes()}`).substr(-2);
timeLabel += (`0${date.getSeconds()}`).substr(-2);
return { date: dateLabel, time: timeLabel };
}
function parseDateAndTime(date, time) {
return `${date.substr(0, 4)
}-${date.substr(4, 2)
}-${date.substr(6, 2)
} ${time.substr(0, 2)
}:${time.substr(2, 2)
}:${time.substr(4, 2)}`;
}
function getFileName({ date, time, hash }) {
return `backup-${date}-${time}-${hash}.json`;
}
function parseFileName(filename) {
const matchResult = filename.match(/^backup-(\d+)-(\d+)-(\w+)\.json$/);
return {
hash: matchResult[3],
timetamp: parseDateAndTime(matchResult[1], matchResult[2]),
};
}
// Intermediate Operations.
const ActionsRegistry = {};
ActionsRegistry['backup-file-load'] = async function ({ filename }) {
const filedata = await callbackToPromise(
fs.readFile,
path.join(this.config.backup.location, filename),
);
return JSON.parse(filedata);
};
ActionsRegistry['backup-file-save'] = async function ({ data }) {
const { date, time } = getDateAndTime();
const dataSerialized = JSON.stringify(data, null, '\t');
const hash = crypto.createHash('md5').update(dataSerialized).digest('hex');
try {
const latestBackup = await this.invoke.call(this, 'backup-latest');
if (latestBackup && hash === latestBackup.hash) {
return { ...latestBackup, isUnchanged: true };
}
} catch (error) {
assert(error.message === 'no backups found');
}
const filename = getFileName({ date, time, hash });
await callbackToPromise(
fs.writeFile,
path.join(this.config.backup.location, filename),
dataSerialized,
);
this.broadcast('backup-latest');
return {
filename, date, time, hash,
};
};
ActionsRegistry['backup-transform-data'] = async function (data) {
// return this.invoke.call(this, 'transformation-method', data);
return { data };
};
// Actual API
ActionsRegistry['backup-save'] = async function ({ verbose } = {}) {
const data = await this.invoke.call(this, 'database-load');
const result = await this.invoke.call(this, 'backup-file-save', { data });
if (verbose) {
// eslint-disable-next-line no-console
console.info(`Saved ${result.filename}${result.isUnchanged ? ' (unchanged)' : ''}`);
}
return result;
};
ActionsRegistry['backup-latest'] = async function () {
let filenames = await callbackToPromise(fs.readdir, this.config.backup.location);
filenames = filenames.filter((filename) => filename.startsWith('backup-')).sort();
if (!filenames.length) {
return null;
}
assert(filenames.length, 'no backups found');
const filename = filenames[filenames.length - 1];
const components = parseFileName(filename);
return { filename, ...components };
};
ActionsRegistry['backup-load'] = async function ({ verbose } = {}) {
const latestBackup = await this.invoke.call(this, 'backup-latest');
assert(latestBackup, 'at least one backup is required');
let data = await this.invoke.call(this, 'backup-file-load', { filename: latestBackup.filename });
const transformationResult = await this.invoke.call(this, 'backup-transform-data', data);
data = transformationResult.data;
await this.invoke.call(this, 'database-validate', { data });
await this.invoke.call(this, 'database-save', data);
if (verbose) {
// eslint-disable-next-line no-console
console.info(`Loaded ${latestBackup.filename}`);
}
if (transformationResult.validate) {
await transformationResult.validate();
}
return latestBackup;
};
ActionsRegistry['backup-delete'] = async function ({ filename }) {
return callbackToPromise(fs.unlink, path.join(this.config.backup.location, filename));
};
export default ActionsRegistry;
================================================
FILE: src/server/actions/data_types.js
================================================
/* eslint-disable func-names */
import { getDataTypeMapping } from '../../common/data_types';
const ActionsRegistry = {};
Object.entries(getDataTypeMapping()).forEach((pair) => {
const [name, DataType] = pair;
ActionsRegistry[`${name}-list`] = async function (input) {
const context = { ...this, DataType };
input = input || {};
const where = input.where || {};
await DataType.updateWhere.call(context, where);
return DataType.list.call(context, where, input.limit);
};
ActionsRegistry[`${name}-typeahead`] = async function ({ query, where = {} }) {
const context = { ...this, DataType };
await DataType.updateWhere.call(context, where);
return DataType.typeahead.call(context, { query, where });
};
ActionsRegistry[`${name}-validate`] = async function (input) {
const context = { ...this, DataType };
return DataType.getValidationErrors.call(context, input);
};
ActionsRegistry[`${name}-load-partial`] = async function (input) {
const context = { ...this, DataType };
return DataType.loadPartial.call(context, input.__id__);
};
ActionsRegistry[`${name}-load`] = async function (input) {
const context = { ...this, DataType };
return DataType.load.call(context, input.__id__);
};
ActionsRegistry[`${name}-reorder`] = async function (input) {
const context = { ...this, DataType };
return DataType.reorder.call(context, input);
};
ActionsRegistry[`${name}-sort`] = async function (input) {
const context = { ...this, DataType };
await DataType.updateWhere.call(context, input.where);
return DataType.sort.call(context, input);
};
ActionsRegistry[`${name}-upsert`] = async function (input) {
const context = { ...this, DataType };
if (DataType.trigger) {
DataType.trigger.call(context, input);
}
const errors = await DataType.getValidationErrors.call(context, input);
if (errors.length) {
throw new Error(`${errors.join('\n')}\n${JSON.stringify(input, null, 4)}`);
}
const id = await DataType.save.call(context, input);
// This informs the client-side DataLoader.
this.broadcast(`${name}-load`, { __id__: id });
this.broadcast(`${name}-list`, { where: { __id__: id } });
return DataType.load.call(context, id);
};
ActionsRegistry[`${name}-delete`] = async function (id) {
const context = { ...this, DataType };
// This informs the client-side DataLoader.
this.broadcast(`${name}-load`, { __id__: id });
this.broadcast(`${name}-list`, { where: { __id__: id } });
return DataType.delete.call(context, id);
};
});
export default ActionsRegistry;
================================================
FILE: src/server/actions/database.js
================================================
/* eslint-disable func-names */
import assert from 'assert';
import toposort from 'toposort';
import { asyncSequence } from '../../common/AsyncUtils';
import { getDataFormatVersion } from '../models';
const ActionsRegistry = {};
ActionsRegistry['database-load'] = async function () {
// Since the database format might not be in-sync with the code,
// use the QueryInterface to load the data, instead of models.
const { sequelize } = this.database;
const api = sequelize.getQueryInterface();
const tableNames = await api.showAllTables();
const data = {};
await asyncSequence(tableNames, async (tableName) => {
data[tableName] = await sequelize.query(
`SELECT * FROM ${tableName}`,
{ type: sequelize.QueryTypes.SELECT },
);
});
return data;
};
ActionsRegistry['database-save'] = async function (data) {
await this.database.reset();
await asyncSequence(this.database.getModelSequence(), async (model) => {
const items = data[model.name] || [];
if (model.name !== 'log_topics') {
await asyncSequence(items, async (item) => {
try {
await model.create(item, { transaction: this.database.transaction });
} catch (error) {
// eslint-disable-next-line no-constant-condition
if (false) {
// eslint-disable-next-line no-console
console.error(model.name, item);
}
throw error;
}
});
} else {
await model.bulkCreate(items, { transaction: this.database.transaction });
}
});
};
const DATA_FORMAT_VERSION_KEY = '__data_format_version__';
ActionsRegistry['database-reset'] = async function ({ verbose = false } = {}) {
await this.database.reset();
await this.database.createOrUpdateItem('Settings', null, {
key: DATA_FORMAT_VERSION_KEY,
value: getDataFormatVersion(),
});
if (verbose) {
// eslint-disable-next-line no-console
console.info('Reset database!');
}
};
ActionsRegistry['database-validate'] = async function ({ data: backupData, verbose } = {}) {
const expectedValue = getDataFormatVersion();
let actualValue = null;
if (backupData) {
const item = backupData.settings.find((row) => row.key === DATA_FORMAT_VERSION_KEY);
actualValue = item.value;
} else {
const item = await this.database.findOne('Settings', { key: DATA_FORMAT_VERSION_KEY });
actualValue = item.value;
}
assert(
expectedValue === actualValue,
`Data format version mismatch! Expected = ${expectedValue}, Actual = ${actualValue}`,
);
if (verbose) {
// eslint-disable-next-line no-console
console.info('Data format version validated!');
}
};
ActionsRegistry['database-clear'] = async function () {
// For some reason, calling "database-reset" causes SQLITE_READONLY error.
// So this method is specifically designed for the demo videos.
const models = this.database.getModelSequence().slice().reverse();
await asyncSequence(models, async (model) => {
if (model.name === 'log_topics') {
// Since topics can reference other topics, the order of deletion matters.
// Using topological sort to avoid violating foreign key constraints.
const logTopics = await model.findAll();
const logTopicMap = {};
const nodes = [];
const edges = [];
logTopics.forEach((logTopic) => {
logTopicMap[logTopic.id] = logTopic;
nodes.push(logTopic.id);
if (logTopic.parent_topic_id) {
edges.push([logTopic.parent_topic_id, logTopic.id]);
}
});
const result = toposort.array(nodes, edges).reverse();
await asyncSequence(result, async (id) => {
await logTopicMap[id].destroy();
});
}
try {
await model.sync({ force: true });
} catch (error) {
throw new Error(`${model.name} // ${error.message}`);
}
});
};
export default ActionsRegistry;
================================================
FILE: src/server/actions/reminders.js
================================================
/* eslint-disable func-names */
import assert from 'assert';
import { addDays, compareAsc, subDays } from 'date-fns';
import { asyncFilter } from '../../common/AsyncUtils';
import { LogStructure } from '../../common/data_types';
import DateUtils from '../../common/DateUtils';
const ActionsRegistry = {};
ActionsRegistry['latest-log-event'] = async function (input) {
return this.database.findOne(
'LogEvent',
{
structure_id: input.logStructure.__id__,
date: { [this.database.Op.ne]: null },
},
[['date', 'DESC']],
);
};
ActionsRegistry['reminder-check'] = async function (input) {
// This action is invoked by "reminder-sidebar".
const { logStructure, todayLabel } = input;
assert(logStructure.isPeriodic);
// If the reminder is suppressed, return early.
const todayDate = DateUtils.getDate(todayLabel);
const suppressUntilDate = DateUtils.getDate(logStructure.suppressUntilDate);
if (compareAsc(todayDate, suppressUntilDate) <= 0) {
return false;
}
// If the warning start date is in the future, return early.
const option = LogStructure.Frequency[logStructure.frequency];
const lookaheadDate = addDays(todayDate, 1 + logStructure.warningDays);
const reminderDate = option.getPreviousMatch(lookaheadDate, logStructure.frequencyArgs);
const warningStartDate = subDays(reminderDate, logStructure.warningDays);
const isWarningActive = compareAsc(warningStartDate, todayDate) <= 0;
if (!isWarningActive) return false;
// If there was an event since the warning start date, return early.
const latestLogEvent = await this.invoke.call(this, 'latest-log-event', { logStructure });
if (latestLogEvent) {
const latestLogEventDate = DateUtils.getDate(latestLogEvent.date);
if (compareAsc(warningStartDate, latestLogEventDate) <= 0) {
return false;
}
}
return true;
};
ActionsRegistry['reminder-score'] = async function (input) {
// This action is invoked by "reminder-sidebar".
const { logStructure, todayLabel } = input;
assert(logStructure.isPeriodic);
// While the reminder-check is O(1), this operation is O(n).
const logEvents = await this.database.findAll(
'LogEvent',
{
structure_id: logStructure.__id__,
date: { [this.database.Op.ne]: null },
},
[['date', 'DESC']],
);
// The "window of opportunity" is defined as the start of the warning for one reminder,
// to the start of the warning for the next reminder.
const option = LogStructure.Frequency[logStructure.frequency];
const todayDate = DateUtils.getDate(todayLabel);
const nextReminderDate = option.getNextMatch(
// Using addDays here, so that deadlineDate will be in the future.
addDays(todayDate, logStructure.warningDays),
logStructure.frequencyArgs,
);
const deadlineDate = subDays(nextReminderDate, 1 + logStructure.warningDays);
// Start from the current window, and then go as far back as needed to compute the score.
let currentDate = addDays(todayDate, 1 + logStructure.warningDays);
let value = 0;
let deadline = null;
const dateRanges = [];
let firstIteration = true;
while (logEvents.length) {
const reminderDate = option.getPreviousMatch(currentDate, logStructure.frequencyArgs);
const warningStartDate = subDays(reminderDate, logStructure.warningDays);
let foundLogEventInReminderWindow = false;
while (logEvents.length) { // loop in case of multiple events in one window.
const logEventDate = DateUtils.getDate(logEvents[0].date);
if (compareAsc(logEventDate, currentDate) < 0) {
foundLogEventInReminderWindow = compareAsc(warningStartDate, logEventDate) <= 0;
break;
} else {
logEvents.shift();
}
}
let dateRange = `${DateUtils.getLabel(warningStartDate)}`;
if (compareAsc(warningStartDate, subDays(currentDate, 1)) < 0) {
dateRange += ` to ${DateUtils.getLabel(currentDate)}`;
}
currentDate = warningStartDate;
if (firstIteration) { // special handling for currently open window.
firstIteration = false;
if (!foundLogEventInReminderWindow) {
deadline = DateUtils.getLabel(deadlineDate);
// eslint-disable-next-line no-continue
continue;
}
}
if (foundLogEventInReminderWindow) {
if (value >= 0) {
value += 1;
dateRanges.push(dateRange);
} else {
break;
}
} else {
// eslint-disable-next-line no-lonely-if
if (value <= 0) {
value -= 1;
dateRanges.push(dateRange);
} else {
break;
}
}
}
return { value, deadline, dateRanges };
};
ActionsRegistry['reminder-sidebar'] = async function (input) {
const { todayLabel } = input;
const logStructureGroups = await this.invoke.call(this, 'log-structure-group-list', {
ordering: true,
where: input.where,
});
const periodicLogStructures = await this.invoke.call(this, 'log-structure-list', {
where: { isPeriodic: true },
ordering: true,
});
const reminderGroups = await Promise.all(
logStructureGroups.map(async (logStructureGroup) => {
const logStructures = await asyncFilter(
periodicLogStructures.filter(
(logStructure) => logStructure.logStructureGroup.__id__
=== logStructureGroup.__id__,
),
async (logStructure) => this.invoke.call(this, 'reminder-check', { logStructure, todayLabel }),
);
if (!logStructures.length) {
return null;
}
await Promise.all(logStructures.map(async (logStructure) => {
logStructure.reminderScore = await this.invoke.call(this, 'reminder-score', { logStructure, todayLabel });
}));
return { ...logStructureGroup, logStructures };
}),
);
return reminderGroups.filter((reminderGroup) => reminderGroup);
};
function getSuppressUntilDate(logStructure, todayLabel) {
assert(logStructure.isPeriodic);
const todayDate = DateUtils.getDate(todayLabel);
const option = LogStructure.Frequency[logStructure.frequency];
const reminderDate = option.getNextMatch(todayDate, logStructure.frequencyArgs);
const warningStartDate = subDays(reminderDate, 1 + logStructure.warningDays);
return DateUtils.getLabel(warningStartDate);
}
ActionsRegistry['reminder-complete'] = async function (input) {
const { logEvent: inputLogEvent, logStructure: inputLogStructure, todayLabel } = input;
const result = {};
result.logEvent = await this.invoke.call(this, 'log-event-upsert', inputLogEvent);
if (inputLogStructure) {
inputLogStructure.suppressUntilDate = getSuppressUntilDate(
inputLogStructure,
todayLabel,
);
result.logStructure = await this.invoke.call(
this,
'log-structure-upsert',
inputLogStructure,
);
}
this.broadcast('reminder-sidebar');
return result;
};
ActionsRegistry['reminder-dismiss'] = async function (input) {
const { logStructure: inputLogStructure, todayLabel } = input;
inputLogStructure.suppressUntilDate = getSuppressUntilDate(
inputLogStructure,
todayLabel,
);
const outputLogStructure = await this.invoke.call(
this,
'log-structure-upsert',
inputLogStructure,
);
this.broadcast('reminder-sidebar');
return { logStructure: outputLogStructure };
};
export default ActionsRegistry;
================================================
FILE: src/server/actions/settings.js
================================================
/* eslint-disable func-names */
import assert from 'assert';
const ActionsRegistry = {};
const INTERNAL_SETTINGS_PREFIX = '_';
ActionsRegistry['settings-get'] = async function () {
const result = {};
const items = await this.database.findAll('Settings');
items.forEach((item) => {
if (!item.key.startsWith(INTERNAL_SETTINGS_PREFIX)) {
result[item.key] = JSON.parse(item.value);
}
});
return result;
};
ActionsRegistry['settings-set'] = async function (input) {
const items = await this.database.findAll('Settings', { key: Object.keys(input) });
const keyToItem = {};
items.forEach((item) => {
keyToItem[item.key] = item;
});
await Promise.all(Object.entries(input).map(async ([key, value]) => {
assert(!key.startsWith(INTERNAL_SETTINGS_PREFIX));
let item = keyToItem[key];
if (value) {
const fields = { key, value: JSON.stringify(value) };
item = await this.database.createOrUpdateItem('Settings', item, fields);
} else if (item) {
await this.database.deleteByPk('Settings', item.id);
}
}));
this.broadcast('settings-get');
};
export default ActionsRegistry;
================================================
FILE: src/server/actions/suggestions.js
================================================
/* eslint-disable func-names */
import assert from 'assert';
import { LogKey } from '../../common/data_types';
const ActionsRegistry = {};
const getOrDefault = (item, key, defaultValue) => {
if (!(key in item)) item[key] = defaultValue;
return item[key];
};
const getValues = (logKey) => {
if (!logKey.value) {
return [];
}
if (logKey.type === LogKey.Type.STRING_LIST) {
assert(Array.isArray(logKey.value));
return logKey.value;
}
if (logKey.type === LogKey.Type.LOG_TOPIC) {
return [logKey.value.__id__];
}
return [logKey.value];
};
const buildIndex = (items, getLogKeys) => {
if (!items.length) {
return null;
}
const indexData = {};
items.forEach((item) => {
getLogKeys(item).forEach((logKey, index) => {
const keyIndexData = getOrDefault(indexData, index, { logKey, counts: {} });
getValues(logKey).forEach((value) => {
getOrDefault(keyIndexData.counts, value, 0);
keyIndexData.counts[value] += 1;
});
});
});
Object.values(indexData).forEach((keyIndexData) => {
keyIndexData.values = Array.from(Object.entries(keyIndexData.counts))
.sort((left, right) => left[1] - right[1])
.map((pair) => pair[0]);
delete keyIndexData.counts;
});
return indexData;
};
const lookupIndex = (indexData, index, query) => {
if (!indexData) return [];
const keyIndexData = indexData[index];
if (!keyIndexData) return [];
return keyIndexData.values.filter((value) => value.startsWith(query));
};
ActionsRegistry['structure-value-typeahead-index-$cached'] = async function (input) {
const where = { logStructure: { __id__: input.structure_id } };
const logEvents = await this.invoke.call(this, 'log-event-list', { where });
return buildIndex(logEvents, (logEvent) => logEvent.logStructure.eventKeys);
};
ActionsRegistry['topic-value-typeahead-index-$cached'] = async function (input) {
const where = { parentLogTopic: { __id__: input.parent_topic_id } };
const childLogTopics = await this.invoke.call(this, 'log-topic-list', { where });
return buildIndex(childLogTopics, (childLogTopic) => childLogTopic.parentLogTopic.childKeys);
};
ActionsRegistry['value-typeahead'] = async function (input) {
if (!input.source) {
return [];
} if (input.source.__type__ === 'log-structure') {
const structureValueTypeaheadIndex = await this.invoke.call(
this,
'structure-value-typeahead-index',
{ structure_id: input.source.__id__ },
);
return lookupIndex(structureValueTypeaheadIndex, input.index, input.query);
} if (input.source.__type__ === 'log-topic') {
const topicValueTypeaheadIndex = await this.invoke.call(
this,
'topic-value-typeahead-index',
{ parent_topic_id: input.source.__id__ },
);
return lookupIndex(topicValueTypeaheadIndex, input.index, input.query);
}
throw new Error(`unsupported source: ${input.source.__type__}`);
};
export default ActionsRegistry;
================================================
FILE: src/server/actions.js
================================================
/* eslint-disable func-names */
/* eslint-disable max-classes-per-file */
import assert from 'assert';
// This method only exists within webpack, so we need to provide it for Jest.
// Note: babel-plugin-transform-require-context does not work for Jest.
// Source = https://stackoverflow.com/a/42191018/903585
if (typeof require.context === 'undefined') {
// eslint-disable-next-line global-require
const fs = require('fs');
// eslint-disable-next-line global-require
const path = require('path');
require.context = (base = '.', scanSubDirectories = false, regularExpression = /\.js$/) => {
const files = {};
function readDirectory(directory) {
fs.readdirSync(directory).forEach((file) => {
const fullPath = path.resolve(directory, file);
if (fs.statSync(fullPath).isDirectory()) {
if (scanSubDirectories) readDirectory(fullPath);
return;
}
if (!regularExpression.test(fullPath)) return;
files[fullPath] = true;
});
}
readDirectory(path.resolve(__dirname, base));
function Module(file) {
// eslint-disable-next-line import/no-dynamic-require, global-require
return require(file);
}
Module.keys = () => Object.keys(files);
return Module;
};
}
class ActionsRegistry {
static get(configPlugins) {
if (ActionsRegistry.result) {
return ActionsRegistry.result;
}
const result = {};
const actionsContext = require.context('./actions', false, /\.js$/);
actionsContext.keys()
.forEach((filePath) => {
const exports = actionsContext(filePath);
ActionsRegistry.build(result, exports.default);
});
if (Array.isArray(configPlugins)) {
const pluginsContext = require.context('../plugins', true, /actions\.js$/);
const pluginPatterns = configPlugins.map((pattern) => new RegExp(pattern));
pluginsContext.keys()
.filter((filePath) => pluginPatterns.some((regex) => filePath.match(regex)))
.forEach((filePath) => {
const exports = pluginsContext(filePath);
ActionsRegistry.build(result, exports.default);
});
}
ActionsRegistry.result = result;
return result;
}
static build(result, nameToMethods) {
Object.entries(nameToMethods).forEach(([name, method]) => {
const cacheSuffix = '-$cached';
if (name.endsWith(cacheSuffix)) {
ActionsRegistry.useCache(result, name.slice(0, -cacheSuffix.length), method);
} else {
result[name] = method;
}
});
}
static useCache(result, name, method) {
const actualName = `${name}-actual`;
result[actualName] = method;
result[name] = async function (input = null) {
const serializedInput = JSON.stringify(input);
if (!(name in this.memory)) {
this.memory[name] = {};
}
if (!(serializedInput in this.memory[name])) {
this.memory[name][serializedInput] = new Promise((resolve, reject) => {
this.invoke.call(this, actualName, input).then(resolve).catch(reject);
});
}
const promise = this.memory[name][serializedInput];
assert(promise);
return promise;
};
result[`${name}-$refresh`] = async function (input = null) {
const serializedInput = JSON.stringify(input);
if (name in this.memory) {
if (serializedInput in this.memory[name]) {
delete this.memory[name][serializedInput];
}
}
};
}
}
export default class {
constructor(config, database) {
this.config = config;
this.database = database;
this.registry = ActionsRegistry.get(config ? config.plugins : null);
this.memory = {};
this.socket = null;
this.broadcasts = null;
}
registerBroadcast(socket) {
this.socket = socket;
}
getBroadcasts() { // used for tests
const result = this.broadcasts;
this.broadcasts = null;
return result;
}
// eslint-disable-next-line class-methods-use-this
has(name) {
return name in this.registry;
}
async invoke(name, input, moreContext = {}) {
const context = {
...moreContext,
invoke(innerName, innerInput) {
if (!(innerName in this.registry)) {
throw new Error(`unknown action: ${innerName}`);
}
try {
return this.registry[innerName].call(context, innerInput);
} catch (error) {
const serializedInput = JSON.stringify(input, null, 4);
throw new Error(`${innerName}: ${serializedInput}\n\n${error.message}`);
}
},
config: this.config,
// The Object.create method creates a new object with the given prototype.
// This allows us to concurrently set the transaction field below.
database: Object.create(this.database),
// The `registry` is used from the `invoke` method above.
registry: this.registry,
// The `memory` object is shared across all actions, used for caching.
memory: this.memory,
// Arguments for deferred `invoke` operations on separate transactions.
deferredInvoke: [],
// Transmit logs to client.
console: {},
};
['info', 'log', 'warning', 'error'].forEach((logLevel) => {
context.console[logLevel] = (...args) => {
if (this.socket) {
this.socket.log(logLevel, ...args);
}
};
});
context.database.transaction = await this.database.sequelize.transaction();
try {
const broadcasts = [];
context.broadcast = (...args) => broadcasts.push(args);
const response = await context.invoke.call(context, name, input); // action
await context.database.transaction.commit();
if (this.socket) {
broadcasts.forEach((args) => this.socket.broadcast(...args));
} else {
this.broadcasts = broadcasts;
}
context.deferredInvoke.forEach((deferredArgs) => this.invoke(...deferredArgs));
return response;
} catch (error) {
// console.error(error.toString());
try {
await context.database.transaction.rollback();
} catch (anotherError) {
throw error;
}
throw error;
}
}
}
================================================
FILE: src/server/database.js
================================================
import assert from 'assert';
import fs from 'fs';
import { isRealItem } from '../common/data_types';
import { getDataModels } from './models';
const Sequelize = require('sequelize');
export default class {
constructor(config) {
this.config = config;
this.sequelize = new Sequelize(this.config);
const nameAndModels = getDataModels(this.sequelize);
this._modelSequence = nameAndModels.map(([_name, model]) => model);
this._models = nameAndModels.reduce((result, [name, model]) => {
result[name] = model;
return result;
}, {});
this.Op = Sequelize.Op;
this.transaction = null;
}
getTransaction() {
// The this.transaction field is set by the Actions class.
// By creating a new object with the database instance as a prototype,
// we have the transaction available in context, and API remains simple.
assert(!!this.transaction);
return this.transaction;
}
async reset() {
// You cant invoke sync during an active transaction!
await this.transaction.commit();
if (fs.existsSync(this.sequelize.options.storage)) {
fs.unlinkSync(this.sequelize.options.storage);
}
if (this.config.dialect === 'sqlite') {
// https://github.com/sequelize/sequelize/issues/11583
await this.sequelize.query('PRAGMA foreign_keys = false;');
await this.sequelize.sync({ force: true });
await this.sequelize.query('PRAGMA foreign_keys = true;');
} else {
await this.sequelize.sync({ force: true });
}
this.transaction = await this.sequelize.transaction();
}
async close() {
await this.sequelize.close();
}
getModelSequence() {
return this._modelSequence;
}
async build(name) {
const Model = this._models[name];
return Model.build({});
}
async create(name, fields) {
const transaction = this.getTransaction();
const { __id__: _id, ...remainingFields } = fields;
const Model = this._models[name];
return Model.create(
remainingFields,
// Why specify fields? https://github.com/sequelize/sequelize/issues/11417
{ fields: Object.keys(remainingFields), transaction },
);
}
async update(name, fields) {
const transaction = this.getTransaction();
const { id, ...remainingFields } = fields;
const Model = this._models[name];
const instance = await Model.findByPk(id, { transaction });
return instance.update(remainingFields, { transaction });
}
async createOrUpdateItem(name, item, fields) {
const transaction = this.getTransaction();
if (item) {
return item.update(fields, { transaction });
}
return this.create(name, fields);
}
async findAll(name, where, order, limit) {
const transaction = this.getTransaction();
const Model = this._models[name];
return Model.findAll({
where, order, limit, transaction,
});
}
async findOne(name, where, order) {
const transaction = this.getTransaction();
const Model = this._models[name];
return Model.findOne({ where, order, transaction });
}
async findByPk(name, id) {
const transaction = this.getTransaction();
const Model = this._models[name];
return Model.findByPk(id, { transaction });
}
async findItem(name, item) {
if (isRealItem(item)) {
return this.findByPk(name, item.__id__);
}
return null;
}
async count(name, where, group) {
const transaction = this.getTransaction();
const Model = this._models[name];
return Model.count({ where, group, transaction });
}
async createOrFind(name, where, updateFields) {
const transaction = this.getTransaction();
const Model = this._models[name];
const instance = await Model.findOne({ where, transaction });
if (!instance) {
return this.create(name, { ...where, ...updateFields });
}
return instance;
}
async deleteAll(name, where) {
const transaction = this.getTransaction();
const Model = this._models[name];
return Model.destroy({ where, transaction });
}
async deleteByPk(name, id) {
const transaction = this.getTransaction();
const Model = this._models[name];
const instance = await Model.findByPk(id, { transaction });
return instance.destroy({ transaction });
}
async getEdges(edgeName, leftName, leftId) {
const transaction = this.getTransaction();
const EdgeModel = this._models[edgeName];
const edges = await EdgeModel.findAll({
where: { [leftName]: leftId },
transaction,
});
if (edges.length > 1 && typeof edges[0].ordering_index !== 'undefined') {
edges.sort((left, right) => left.ordering_index - right.ordering_index);
}
return edges;
}
async getNodesByEdge(edgeName, leftName, leftId, rightName, rightType) {
const transaction = this.getTransaction();
const edges = await this.getEdges(edgeName, leftName, leftId);
const NodeModel = this._models[rightType];
const nodes = await Promise.all(
edges.map((edge) => NodeModel.findByPk(edge[rightName], { transaction })),
);
return nodes;
}
async setEdges(edgeName, leftName, leftId, rightName, right) {
const transaction = this.getTransaction();
const Model = this._models[edgeName];
const existingEdges = await Model.findAll({ where: { [leftName]: leftId }, transaction });
const existingIDs = existingEdges.map((edge) => edge[rightName].toString());
// Why specify fields? https://github.com/sequelize/sequelize/issues/11417
const fields = [
leftName,
rightName,
...Object.keys(Object.values(right)[0] || {}),
];
// eslint-disable-next-line no-unused-vars
const [updatedEdges, deletedEdges] = await Promise.all([
Promise.all(
existingEdges
.filter((edge) => edge[rightName] in right)
.map((edge) => edge.update(right[edge[rightName]], { transaction })),
),
Promise.all(
existingEdges
.filter((edge) => !(edge[rightName] in right))
.map((edge) => edge.destroy({ transaction })),
),
]);
// eslint-disable-next-line no-unused-vars
const createdEdges = await Promise.all(
Object.keys(right)
.filter((rightId) => !existingIDs.includes(rightId))
.map((rightId) => Model.create({
[leftName]: leftId,
[rightName]: rightId,
...right[rightId],
}, { fields, transaction })),
);
return deletedEdges;
}
}
================================================
FILE: src/server/index.js
================================================
/* eslint-disable no-console */
import '../common/polyfill';
import express from 'express';
import fs from 'fs';
import http from 'http';
import process from 'process';
import SingleInstance from 'single-instance';
import SocketIO from 'socket.io';
import yargs from 'yargs';
import SocketRPC from '../common/SocketRPC';
import Actions from './actions';
import Database from './database';
async function init() {
this.database = new Database(this.config.database);
this.actions = new Actions(this.config, this.database);
}
async function startServer() {
await this.actions.invoke('database-validate', { verbose: true });
const app = express();
const server = http.Server(app);
const io = SocketIO(server);
io.on('connection', (socket) => SocketRPC.server(socket, this.actions));
const { host, port } = this.config.server;
app.get('/', (req, res) => {
res.cookie('host', host);
res.cookie('port', port);
res.cookie('plugins', JSON.stringify(this.config.plugins || []));
res.sendFile('index.html', { root: 'dist' });
});
app.use(express.static('dist'));
this.server = server.listen(port, host);
console.info(`Server running at http://${host}:${port}`);
}
async function cleanup() {
if (this.server) {
this.server.close();
}
if (this.database) {
this.database.close();
}
}
async function main(argv) {
if (this === global) {
main.call({}, argv);
return;
}
this.config = JSON.parse(fs.readFileSync(argv.configPath));
const locker = new SingleInstance(this.config.lock_name || 'glados');
console.info('Acquiring lock ...');
await locker.lock();
console.info('Acquired lock!');
await init.call(this);
if (argv.action) {
await this.actions.invoke(argv.action, { verbose: true });
} else {
await startServer.call(this);
// Let the server run until we get a signal.
await new Promise((resolve) => {
process.on('SIGTERM', resolve);
process.on('SIGINT', resolve);
});
}
await cleanup.call(this);
console.info('Releasing lock ...');
await locker.unlock();
console.info('Released lock!');
}
// Put everything together!
const { argv } = yargs
.option('configPath', { alias: 'c', default: 'config.json' })
.demandOption('configPath')
.option('action', { alias: 'a' })
.choices('action', ['database-reset', 'backup-load', 'backup-save']);
main(argv).catch((error) => console.error(error));
================================================
FILE: src/server/models.js
================================================
const Sequelize = require('sequelize');
export function getDataFormatVersion() {
// This value is used to ensure that the backup file being loaded
// is still compatible with this version of code.
// In case of database schema changes, this value should be bumped,
// and a script can be written to generate a new backup file from an older version.
return '100';
}
export function getDataModels(sequelize) {
const options = {
timestamps: false,
underscored: true,
};
const Settings = sequelize.define(
'settings',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
key: {
type: Sequelize.STRING,
allowNull: false,
},
value: {
type: Sequelize.STRING,
allowNull: false,
},
},
{
...options,
indexes: [
{ unique: true, fields: ['key'] },
],
},
);
const LogTopic = sequelize.define(
'log_topics',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
parent_topic_id: {
type: Sequelize.INTEGER,
allowNull: true,
},
ordering_index: {
type: Sequelize.INTEGER,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
details: {
type: Sequelize.TEXT,
allowNull: false,
},
child_count: {
type: Sequelize.INTEGER,
allowNull: false,
},
is_favorite: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
is_deprecated: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
// Keys & Values
child_keys: {
type: Sequelize.TEXT,
allowNull: true,
},
child_name_template: {
type: Sequelize.TEXT,
allowNull: true,
},
parent_values: {
type: Sequelize.TEXT,
allowNull: true,
},
},
{
...options,
indexes: [
{ unique: true, fields: ['name'] },
],
},
);
LogTopic.belongsTo(LogTopic, {
foreignKey: 'parent_topic_id',
allowNull: true,
onDelete: 'restrict',
onUpdate: 'restrict',
});
const LogTopicToLogTopic = sequelize.define(
'log_topics_to_log_topics',
{
source_topic_id: {
type: Sequelize.INTEGER,
references: {
model: LogTopic,
key: 'id',
},
},
target_topic_id: {
type: Sequelize.INTEGER,
references: {
model: LogTopic,
key: 'id',
},
},
},
options,
);
LogTopic.belongsToMany(LogTopic, {
as: 'Source',
through: LogTopicToLogTopic,
foreignKey: 'source_topic_id',
// Deleteing a source topic is allowed!
// The links will be broken, and the Tags could be cleaned up.
onDelete: 'cascade',
onUpdate: 'cascade',
});
LogTopic.belongsToMany(LogTopic, {
as: 'Target',
through: LogTopicToLogTopic,
foreignKey: 'target_topic_id',
onDelete: 'restrict',
onUpdate: 'restrict',
});
const LogStructureGroup = sequelize.define(
'log_structure_groups',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
ordering_index: {
type: Sequelize.INTEGER,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
},
options,
);
const LogStructure = sequelize.define(
'log_structures',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
group_id: {
type: Sequelize.INTEGER,
allowNull: false,
},
ordering_index: {
type: Sequelize.INTEGER,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
details: {
type: Sequelize.TEXT,
allowNull: false,
},
// Should this structure have key-value-pairs?
event_keys: {
type: Sequelize.TEXT,
allowNull: false,
},
event_title_template: {
type: Sequelize.TEXT,
allowNull: false,
},
event_needs_edit: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
event_allow_details: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
// Should this structure have reminders?
is_periodic: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
reminder_text: {
type: Sequelize.STRING,
allowNull: true,
},
frequency: {
type: Sequelize.STRING,
allowNull: true,
},
frequency_args: {
type: Sequelize.STRING,
allowNull: true,
},
warning_days: {
type: Sequelize.INTEGER,
allowNull: true,
},
suppress_until_date: {
type: Sequelize.STRING,
allowNull: true,
},
// Additional fields to be copied to events.
log_level: {
type: Sequelize.INTEGER,
allowNull: false,
},
is_favorite: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
is_deprecated: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
},
options,
);
LogStructure.belongsTo(LogStructureGroup, {
foreignKey: 'group_id',
allowNull: false,
onDelete: 'restrict',
onUpdate: 'restrict',
});
const LogStructureToLogTopic = sequelize.define(
'log_structures_to_log_topics',
{
source_structure_id: {
type: Sequelize.INTEGER,
references: {
model: LogStructure,
key: 'id',
},
},
target_topic_id: {
type: Sequelize.INTEGER,
references: {
model: LogTopic,
key: 'id',
},
},
},
options,
);
LogStructure.belongsToMany(LogTopic, {
through: LogStructureToLogTopic,
foreignKey: 'source_structure_id',
// Deleteing an structure is allowed!
// The links will be broken, and the Tags could be cleaned up.
onDelete: 'cascade',
onUpdate: 'cascade',
});
LogTopic.belongsToMany(LogStructure, {
through: LogStructureToLogTopic,
foreignKey: 'target_topic_id',
onDelete: 'restrict',
onUpdate: 'restrict',
});
// Estimated scale? 50 events per day * 365 days * 10 years = 182,500 events
// Size of 1 event? 1 kb, so total size over 10 years ~= 200mb
const LogEvent = sequelize.define(
'log_events',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
date: {
type: Sequelize.STRING,
allowNull: true,
},
ordering_index: {
type: Sequelize.INTEGER,
allowNull: false,
},
title: {
type: Sequelize.TEXT,
allowNull: false,
},
structure_id: {
type: Sequelize.INTEGER,
allowNull: true,
},
structure_values: {
type: Sequelize.TEXT,
allowNull: true,
},
details: {
type: Sequelize.TEXT,
allowNull: false,
},
log_level: {
type: Sequelize.INTEGER,
allowNull: false,
},
is_favorite: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
is_complete: {
type: Sequelize.BOOLEAN,
allowNull: false,
},
},
options,
);
LogEvent.belongsTo(LogStructure, {
foreignKey: 'structure_id',
allowNull: true,
onDelete: 'restrict',
onUpdate: 'restrict',
});
const LogEventToLogTopic = sequelize.define(
'log_events_to_log_topics',
{
source_event_id: {
type: Sequelize.INTEGER,
references: {
model: LogEvent,
key: 'id',
},
},
target_topic_id: {
type: Sequelize.INTEGER,
references: {
model: LogTopic,
key: 'id',
},
},
},
options,
);
LogEvent.belongsToMany(LogTopic, {
through: LogEventToLogTopic,
foreignKey: 'source_event_id',
// Deleteing an event is allowed!
// The links will be broken, and the Tags could be cleaned up.
onDelete: 'cascade',
onUpdate: 'cascade',
});
LogTopic.belongsToMany(LogEvent, {
through: LogEventToLogTopic,
foreignKey: 'target_topic_id',
onDelete: 'restrict',
onUpdate: 'restrict',
});
// The following sequence of models is used to load data from backups
// while respecting foreign key constraints.
return [
['Settings', Settings],
['LogTopic', LogTopic],
['LogTopicToLogTopic', LogTopicToLogTopic],
['LogStructureGroup', LogStructureGroup],
['LogStructure', LogStructure],
['LogStructureToLogTopic', LogStructureToLogTopic],
['LogEvent', LogEvent],
['LogEventToLogTopic', LogEventToLogTopic],
];
}