Implement personal knowedge base version (#254)
* Allow to add and edit notes * Implement search * Implement settings * Implement checkout page * Implement paywall * Fix inconsistent margin * Render mobile menu * Allow to logout * emails * Implement user migration * Always build standalone * Embed digest in email * Move browser extension * Fix test * Use system font * Add favicon and app icons * Make tabbar smaller * Initialize focus on editor * Fix various UI audit issues * Simplify asset serving * Register sw * Upgrade deps
|
|
@ -27,3 +27,4 @@ script:
|
|||
- make test-cli
|
||||
- make test-api
|
||||
- make test-web
|
||||
- make test-jslib
|
||||
|
|
|
|||
76
Gopkg.lock
generated
|
|
@ -90,14 +90,6 @@
|
|||
revision = "9eb7a3d310e89e471c2cdf1ea3ec8d7fc1ab969c"
|
||||
version = "v2.5.2"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:318f1c959a8a740366fce4b1e1eb2fd914036b4af58fbd0a003349b305f118ad"
|
||||
name = "github.com/golang/protobuf"
|
||||
packages = ["proto"]
|
||||
pruneopts = "UT"
|
||||
revision = "b5d812f8a3706043e23a9cd5babf2e5423744d30"
|
||||
version = "v1.3.1"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:bfb6d8aee23cd9b2db8fa3760ca11d7a934d9a05993d5233406f1e6042e4c110"
|
||||
name = "github.com/google/go-github"
|
||||
|
|
@ -114,14 +106,6 @@
|
|||
revision = "44c6ddd0a2342c386950e880b658017258da92fc"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
|
||||
name = "github.com/gorilla/context"
|
||||
packages = ["."]
|
||||
pruneopts = "UT"
|
||||
revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42"
|
||||
version = "v1.1.1"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:fc51ecee8f31d03436c1a0167eb1e383ad0a241d02272541853f3995374a08f1"
|
||||
name = "github.com/gorilla/css"
|
||||
|
|
@ -138,22 +122,6 @@
|
|||
revision = "ed099d42384823742bba0bf9a72b53b55c9e2e38"
|
||||
version = "v1.7.2"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:e72d1ebb8d395cf9f346fd9cbc652e5ae222dd85e0ac842dc57f175abed6d195"
|
||||
name = "github.com/gorilla/securecookie"
|
||||
packages = ["."]
|
||||
pruneopts = "UT"
|
||||
revision = "e59506cc896acb7f7bf732d4fdf5e25f7ccd8983"
|
||||
version = "v1.1.1"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:e5bf52fd66a2e984b57b4c0f2c4ee024ed749a19886246240629998dc0cf31ce"
|
||||
name = "github.com/gorilla/sessions"
|
||||
packages = ["."]
|
||||
pruneopts = "UT"
|
||||
revision = "f57b7e2d29c6211d16ffa52a0998272f75799030"
|
||||
version = "v1.1.3"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be"
|
||||
name = "github.com/inconshreveable/mousetrap"
|
||||
|
|
@ -214,17 +182,6 @@
|
|||
revision = "bc6a3c0594130b1e34005880bc600b6d3f49fa7f"
|
||||
version = "v1.1.1"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:1241b137a50b99f7f395e6d3d917cafa4330bd17c55dacef18bfa8d87707533a"
|
||||
name = "github.com/markbates/goth"
|
||||
packages = [
|
||||
".",
|
||||
"gothic",
|
||||
]
|
||||
pruneopts = "UT"
|
||||
revision = "3b8012093d951beedd026d120be1792db01a08f6"
|
||||
version = "v1.54.1"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:c658e84ad3916da105a761660dcaeb01e63416c8ec7bc62256a9b411a05fcd67"
|
||||
name = "github.com/mattn/go-colorable"
|
||||
|
|
@ -367,28 +324,15 @@
|
|||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
digest = "1:1294ed02c7f91baa918f37b4cd7041b3f6ed39aa2ee9644fd39eb9cf6adb0066"
|
||||
digest = "1:d7b978a787736537d0ad2f84c0e8b75b05c2febef0537198e28edf1cf2768afd"
|
||||
name = "golang.org/x/net"
|
||||
packages = [
|
||||
"context",
|
||||
"context/ctxhttp",
|
||||
"html",
|
||||
"html/atom",
|
||||
]
|
||||
pruneopts = "UT"
|
||||
revision = "3b0461eec859c4b73bb64fdc8285971fd33e3938"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
digest = "1:8d1c112fb1679fa097e9a9255a786ee47383fa2549a3da71bcb1334a693ebcfe"
|
||||
name = "golang.org/x/oauth2"
|
||||
packages = [
|
||||
".",
|
||||
"internal",
|
||||
]
|
||||
pruneopts = "UT"
|
||||
revision = "0f29369cfe4552d0e4bcddc57cc75f4d7e672a33"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
digest = "1:fe40fbf915905f8a2397b321b3f10190edbdf5d293f087d01d7eb3a6d1a4adca"
|
||||
|
|
@ -408,22 +352,6 @@
|
|||
pruneopts = "UT"
|
||||
revision = "9d24e82272b4f38b78bc8cff74fa936d31ccd8ef"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:6eb6e3b6d9fffb62958cf7f7d88dbbe1dd6839436b0802e194c590667a40412a"
|
||||
name = "google.golang.org/appengine"
|
||||
packages = [
|
||||
"internal",
|
||||
"internal/base",
|
||||
"internal/datastore",
|
||||
"internal/log",
|
||||
"internal/remote_api",
|
||||
"internal/urlfetch",
|
||||
"urlfetch",
|
||||
]
|
||||
pruneopts = "UT"
|
||||
revision = "b2f4a3cf3c67576a2ee09e1fe62656a5086ce880"
|
||||
version = "v1.6.1"
|
||||
|
||||
[[projects]]
|
||||
branch = "v3"
|
||||
digest = "1:7388652e2215a3f45d341d58766ed58317971030eb1cbd75f005f96ace8e9196"
|
||||
|
|
@ -469,8 +397,6 @@
|
|||
"github.com/jinzhu/gorm",
|
||||
"github.com/joho/godotenv",
|
||||
"github.com/lib/pq",
|
||||
"github.com/markbates/goth",
|
||||
"github.com/markbates/goth/gothic",
|
||||
"github.com/mattn/go-sqlite3",
|
||||
"github.com/pkg/errors",
|
||||
"github.com/robfig/cron",
|
||||
|
|
|
|||
21
Makefile
|
|
@ -41,13 +41,17 @@ endif
|
|||
|
||||
ifeq ($(CI), true)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/web && npm install --unsafe-perm=true)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/browser && npm install --unsafe-perm=true)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/jslib && npm install --unsafe-perm=true)
|
||||
else
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/web && npm install)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/browser && npm install)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/jslib && npm install)
|
||||
endif
|
||||
.PHONY: install-js
|
||||
|
||||
## test
|
||||
test: test-cli test-api test-web
|
||||
test: test-cli test-api test-web test-jslib
|
||||
.PHONY: test
|
||||
|
||||
test-cli:
|
||||
|
|
@ -62,9 +66,24 @@ test-api:
|
|||
|
||||
test-web:
|
||||
@echo "==> running web test"
|
||||
|
||||
ifeq ($(WATCH), true)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/web && npm run test:watch)
|
||||
else
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/web && npm run test)
|
||||
endif
|
||||
.PHONY: test-web
|
||||
|
||||
test-jslib:
|
||||
@echo "==> running jslib test"
|
||||
|
||||
ifeq ($(WATCH), true)
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/jslib && npm run test:watch)
|
||||
else
|
||||
@(cd ${GOPATH}/src/github.com/dnote/dnote/jslib && npm run test)
|
||||
endif
|
||||
.PHONY: test-jslib
|
||||
|
||||
# development
|
||||
dev-server:
|
||||
@echo "==> running dev environment"
|
||||
|
|
|
|||
16
README.md
|
|
@ -1,17 +1,17 @@
|
|||

|
||||
=========================
|
||||
|
||||
Dnote is a simple notebook for developers.
|
||||
Dnote is a simple personal knowledge base.
|
||||
|
||||
[](https://travis-ci.org/dnote/dnote)
|
||||
|
||||
## What is Dnote?
|
||||
|
||||
Dnote is a lightweight notebook for writing technical notes and neatly organizing them into books. The main design goal is to **keep you focused** by providing a way of swiftly capturing new information **without having to switch environment**. To that end, you can use Dnote as a command line interface, browser extension, web client, or an IDE plugin.
|
||||
Dnote is a lightweight personal knowledge base. The main design goal is to **keep you focused** by providing a way of swiftly capturing new information **without having to switch environment**. To that end, you can use Dnote as a command line interface, browser extension, web client, or an IDE plugin.
|
||||
|
||||
It also offers **end-to-end encrypted** backup with AES-256, a seamless **multi device sync**, and **automated spaced repetition** to retain your memory in case you are building a personal knowledge base.
|
||||
It also offers a seamless **multi device sync**, and **automated spaced repetition** to retain your memory in case you are building a personal knowledge base.
|
||||
|
||||
For more details, see the [download page](https://dnote.io/download) and [features](https://dnote.io/pricing).
|
||||
For more details, see the [download page](https://www.getdnote.com/download) and [features](https://www.getdnote.com/pricing).
|
||||
|
||||

|
||||
|
||||
|
|
@ -56,10 +56,10 @@ Dnote is great for building a personal knowledge base because:
|
|||
|
||||
You can read more in the following user stories:
|
||||
|
||||
- [How I Built a Personal Knowledge Base for Myself](https://dnote.io/blog/how-i-built-personal-knowledge-base-for-myself/)
|
||||
- [I Wrote Down Everything I Learned While Programming for a Month](https://dnote.io/blog/writing-everything-i-learn-coding-for-a-month/)
|
||||
- [How I Built a Personal Knowledge Base for Myself](https://www.getdnote.com/blog/how-i-built-personal-knowledge-base-for-myself/)
|
||||
- [I Wrote Down Everything I Learned While Programming for a Month](https://www.getdnote.com/blog/writing-everything-i-learn-coding-for-a-month/)
|
||||
|
||||
## See Also
|
||||
|
||||
- [Homepage](https://dnote.io)
|
||||
- [Forum](https://forum.dnote.io)
|
||||
- [Homepage](https://www.getdnote.com)
|
||||
- [Forum](https://forum.getdnote.com)
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ The following is an example configuration:
|
|||
|
||||
```yaml
|
||||
editor: nvim
|
||||
apiEndpoint: https://api.dnote.io
|
||||
apiEndpoint: https://api.getdnote.com
|
||||
```
|
||||
|
||||
Simply change the value for `apiEndpoint` to a full URL to the self-hosted instance, followed by '/api', and save the configuration file.
|
||||
|
|
|
|||
16
browser/.eslintrc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{ "extends": ["eslint-config-airbnb"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": 1,
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
"plugins": [
|
||||
"react-hooks", "@typescript-eslint"
|
||||
],
|
||||
}
|
||||
5
browser/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/dist
|
||||
/package
|
||||
/node_modules
|
||||
.DS_Store
|
||||
extension.tar.gz
|
||||
18
browser/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Contributing
|
||||
|
||||
Use the following commands to set up, build, and release.
|
||||
|
||||
## Set up
|
||||
|
||||
* `npm install` to install dependencies.
|
||||
|
||||
## Developing locally
|
||||
|
||||
* `npm run watch:firefox`
|
||||
* `npm run watch:chrome`
|
||||
|
||||
## Releasing
|
||||
|
||||
* Set a new version in `package.json`
|
||||
* Run `./scripts/build_prod.sh`
|
||||
* A gulp task `manifest` will copy the version from `package.json` to `manifest.json`
|
||||
18
browser/NOTE_TO_REVIEWER.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Note to reviewer
|
||||
|
||||
This document contains instructions about how to reproduce the final build of this extension.
|
||||
|
||||
All releases are tagged and pushed to [the GitHub repository](https://github.com/dnote/dnote).
|
||||
|
||||
## Steps
|
||||
|
||||
To reproduce the obfuscated code for Firefox, please follow the steps below.
|
||||
|
||||
1. Run `npm install` to install dependencies
|
||||
2. Run `./scripts/build_prod.sh` to build for Firefox and Chrome.
|
||||
|
||||
The obfuscated code will be under `/dist/firefox` and `/dist/chrome`.
|
||||
|
||||
## Further questions
|
||||
|
||||
Please contact sung@dnote.io
|
||||
28
browser/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Dnote Browser Extension
|
||||
|
||||
Dnote browser extension for Chrome and Firefox. Capture new information without opening a new tab or leaving your browser.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the extension
|
||||
|
||||
* Firefox - https://addons.mozilla.org/addon/dnote
|
||||
* Chrome - https://chrome.google.com/webstore/detail/dnote/mcfbfmihbijfaambfbbfcdcfibcjcahi
|
||||
|
||||
2. Login with your API key from https://dnote.io
|
||||
|
||||
## Overview
|
||||
|
||||
We learn many things while reading technical articles, or browsing StackOverflow. Unless we write them down we forget most of them exponentially.
|
||||
|
||||
This extension integrates seamlessly with [Dnote CLI](https://github.com/dnote/dnote/cli) and requires [Dnote Cloud](https://www.getdnote.com/pricing) account.
|
||||
|
||||
## Hotkeys
|
||||
|
||||
Write new notes without even moving your hands to the mouse.
|
||||
|
||||
* **Ctrl + d** - Open the extension (**Ctrl + Shift + v** on Firefox on Linux).
|
||||
* **Shift + Enter** - Save the current note
|
||||
* **b** - Open the saved note in the browser
|
||||
BIN
browser/assets/demo.gif
Normal file
|
After Width: | Height: | Size: 519 KiB |
79
browser/gulpfile.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const gulp = require('gulp');
|
||||
const del = require('del');
|
||||
const replace = require('gulp-replace');
|
||||
const gulpif = require('gulp-if');
|
||||
const imagemin = require('gulp-imagemin');
|
||||
const livereload = require('gulp-livereload');
|
||||
const zip = require('gulp-zip');
|
||||
|
||||
const target = process.env.TARGET;
|
||||
|
||||
gulp.task('manifest', () => {
|
||||
const pkg = require('./package.json');
|
||||
|
||||
return gulp
|
||||
.src(`manifests/${target}/manifest.json`)
|
||||
.pipe(replace('__VERSION__', pkg.version))
|
||||
.pipe(gulp.dest(`dist/${target}`));
|
||||
});
|
||||
|
||||
gulp.task('styles', () => {
|
||||
return gulp.src('src/styles/*.css').pipe(gulp.dest(`dist/${target}/styles`));
|
||||
});
|
||||
|
||||
gulp.task(
|
||||
'html',
|
||||
gulp.series('styles', () => {
|
||||
return gulp.src('src/*.html').pipe(gulp.dest(`dist/${target}`));
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task('images', () => {
|
||||
return gulp
|
||||
.src('src/images/**/*')
|
||||
.pipe(
|
||||
gulpif(
|
||||
gulpif.isFile,
|
||||
imagemin({
|
||||
progressive: true,
|
||||
interlaced: true,
|
||||
svgoPlugins: [{ cleanupIDs: false }]
|
||||
})
|
||||
)
|
||||
)
|
||||
.pipe(gulp.dest(`dist/${target}/images`));
|
||||
});
|
||||
|
||||
gulp.task('clean', del.bind(null, ['.tmp', `dist/${target}`]));
|
||||
|
||||
gulp.task(
|
||||
'watch',
|
||||
gulp.series('manifest', 'html', 'styles', 'images', () => {
|
||||
livereload.listen();
|
||||
|
||||
gulp
|
||||
.watch([
|
||||
'src/*.html',
|
||||
'src/scripts/**/*',
|
||||
'src/images/**/*',
|
||||
'src/styles/**/*'
|
||||
])
|
||||
.on('change', livereload.reload);
|
||||
|
||||
gulp.watch('src/*.html', gulp.parallel('html'));
|
||||
gulp.watch('manifests/**/*.json', gulp.parallel('manifest'));
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task('package', function() {
|
||||
const manifest = require(`./dist/${target}/manifest.json`);
|
||||
|
||||
return gulp
|
||||
.src(`dist/${target}/**`)
|
||||
.pipe(zip('dnote-' + manifest.version + '.zip'))
|
||||
.pipe(gulp.dest(`package/${target}`));
|
||||
});
|
||||
|
||||
gulp.task('build', gulp.series('manifest', gulp.parallel('html', 'images')));
|
||||
|
||||
gulp.task('default', gulp.series('clean', 'build'));
|
||||
31
browser/manifests/chrome/manifest.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "Dnote",
|
||||
"version": "__VERSION__",
|
||||
"description": "Capture your microlessons without leaving the browser.",
|
||||
"icons": {
|
||||
"16": "images/iconx16.png",
|
||||
"48": "images/iconx48.png",
|
||||
"128": "images/iconx128.png"
|
||||
},
|
||||
"manifest_version": 2,
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"16": "images/iconx16.png",
|
||||
"32": "images/iconx32.png"
|
||||
},
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"background": {
|
||||
"scripts": []
|
||||
},
|
||||
"content_scripts": [],
|
||||
"permissions": ["storage"],
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+D",
|
||||
"mac": "MacCtrl+D"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
browser/manifests/firefox/manifest.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "Dnote",
|
||||
"version": "__VERSION__",
|
||||
"description": "Capture your microlessons without leaving the browser.",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"id": "sung@dnote.io",
|
||||
"strict_min_version": "42.0"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "images/iconx16.png",
|
||||
"48": "images/iconx48.png",
|
||||
"128": "images/iconx128.png"
|
||||
},
|
||||
"manifest_version": 2,
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"16": "images/iconx16.png",
|
||||
"32": "images/iconx32.png"
|
||||
},
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"background": {
|
||||
"scripts": []
|
||||
},
|
||||
"content_scripts": [],
|
||||
"permissions": ["storage"],
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+D",
|
||||
"linux": "Ctrl+Shift+V",
|
||||
"mac": "MacCtrl+D"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9819
browser/package-lock.json
generated
Normal file
52
browser/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "dnote-extension",
|
||||
"description": "Dnote browser extension for Chrome and Firefox",
|
||||
"scripts": {
|
||||
"clean": "TARGET=firefox gulp clean && TARGET=chrome gulp clean",
|
||||
"build:chrome": "TARGET=chrome NODE_ENV=production concurrently webpack \"gulp build\"",
|
||||
"build:firefox": "TARGET=firefox NODE_ENV=production concurrently webpack \"gulp build\"",
|
||||
"package:chrome": "TARGET=chrome NODE_ENV=production gulp package",
|
||||
"package:firefox": "TARGET=firefox NODE_ENV=production gulp package",
|
||||
"watch:chrome": "TARGET=chrome NODE_ENV=development concurrently \"webpack --watch\" \"gulp watch\" ",
|
||||
"watch:firefox": "TARGET=firefox NODE_ENV=development concurrently \"webpack --watch\" \"gulp watch\" "
|
||||
},
|
||||
"author": "Monomax Software Pty Ltd",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"qs": "^6.9.0",
|
||||
"react": "^16.9.0",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-redux": "^7.0.0",
|
||||
"react-select": "^3.0.0",
|
||||
"redux": "^4.0.4",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^16.9.3",
|
||||
"@types/react-dom": "^16.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.3.1",
|
||||
"@typescript-eslint/parser": "^2.3.1",
|
||||
"concurrently": "^4.1.2",
|
||||
"del": "^5.0.0",
|
||||
"eslint-config-airbnb": "^18.0.1",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.14.3",
|
||||
"eslint-plugin-react-hooks": "^2.0.1",
|
||||
"gulp": "^4.0.0",
|
||||
"gulp-if": "^3.0.0",
|
||||
"gulp-imagemin": "^6.0.0",
|
||||
"gulp-livereload": "^4.0.2",
|
||||
"gulp-replace": "^1.0.0",
|
||||
"gulp-zip": "^5.0.0",
|
||||
"prettier": "^1.18.2",
|
||||
"ts-loader": "^6.1.2",
|
||||
"typescript": "^3.6.3",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack-cli": "^3.3.9"
|
||||
}
|
||||
}
|
||||
14
browser/scripts/build_prod.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
# build_prod.sh builds distributable archive for the addon
|
||||
# remember to bump version in package.json
|
||||
set -eux
|
||||
|
||||
# clean
|
||||
npm run clean
|
||||
|
||||
# chrome
|
||||
npm run build:chrome
|
||||
npm run package:chrome
|
||||
# firefox
|
||||
npm run build:firefox
|
||||
npm run package:firefox
|
||||
3
browser/scripts/zip.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
tar --exclude='./node_modules' --exclude='./package' --exclude='./dist' -zcvf extension.tar.gz * .babelrc .eslintrc
|
||||
3
browser/src/browser.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// browser.d.ts
|
||||
declare var browser: any;
|
||||
declare var chrome: any;
|
||||
6
browser/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// global.d.ts
|
||||
|
||||
// defined by webpack-define-plugin
|
||||
declare var __API_ENDPOINT__: string;
|
||||
declare var __WEB_URL__: string;
|
||||
declare var __VERSION__: string;
|
||||
1
browser/src/images/close.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 100 100" id="Layer_1" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon fill="#010101" points="77.6,21.1 49.6,49.2 21.5,21.1 19.6,23 47.6,51.1 19.6,79.2 21.5,81.1 49.6,53 77.6,81.1 79.6,79.2 51.5,51.1 79.6,23 "/></svg>
|
||||
|
After Width: | Height: | Size: 468 B |
1
browser/src/images/hamberger-menu.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.0//EN' 'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd'><svg enable-background="new 0 0 24 24" id="Layer_1" version="1.0" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><line fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2" x1="2" x2="22" y1="12" y2="12"/><line fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2" x1="2" x2="22" y1="6" y2="6"/><line fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2" x1="2" x2="22" y1="18" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 641 B |
BIN
browser/src/images/iconx128.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
browser/src/images/iconx16.png
Normal file
|
After Width: | Height: | Size: 362 B |
BIN
browser/src/images/iconx32.png
Normal file
|
After Width: | Height: | Size: 517 B |
BIN
browser/src/images/iconx48.png
Normal file
|
After Width: | Height: | Size: 631 B |
BIN
browser/src/images/iconx96.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
browser/src/images/logo-circle.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
15
browser/src/popup.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Dnote browser extension</title>
|
||||
<meta charset="utf-8" />
|
||||
<link href="styles/popup.css" rel="stylesheet" />
|
||||
<link href="styles/select.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Lato:400,700" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="pending">
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="scripts/popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
88
browser/src/scripts/components/App.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import services from '../utils/services';
|
||||
import { resetSettings } from '../store/settings/actions';
|
||||
import { useSelector, useDispatch } from '../store/hooks';
|
||||
import Header from './Header';
|
||||
import Home from './Home';
|
||||
import Menu from './Menu';
|
||||
import Success from './Success';
|
||||
import Composer from './Composer';
|
||||
|
||||
interface Props {}
|
||||
|
||||
function renderRoutes(path: string, isLoggedIn: boolean) {
|
||||
switch (path) {
|
||||
case '/success':
|
||||
return <Success />;
|
||||
case '/':
|
||||
if (isLoggedIn) {
|
||||
return <Composer />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
default:
|
||||
return <div>Not found</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const App: React.FunctionComponent<Props> = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { path, settings } = useSelector(state => {
|
||||
return {
|
||||
path: state.location.path,
|
||||
settings: state.settings
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// if session is expired, clear it
|
||||
const now = Math.round(new Date().getTime() / 1000);
|
||||
if (settings.sessionKey && settings.sessionKeyExpiry < now) {
|
||||
dispatch(resetSettings());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const isLoggedIn = Boolean(settings.sessionKey);
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
const handleLogout = async (done?: Function) => {
|
||||
try {
|
||||
await services.users.signout();
|
||||
dispatch(resetSettings());
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
} catch (e) {
|
||||
setErrMsg(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Header toggleMenu={toggleMenu} isShowingMenu={isMenuOpen} />
|
||||
|
||||
{isMenuOpen && (
|
||||
<Menu
|
||||
toggleMenu={toggleMenu}
|
||||
loggedIn={isLoggedIn}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main>
|
||||
{errMsg && <div className="alert error">{errMsg}</div>}
|
||||
|
||||
{renderRoutes(path, isLoggedIn)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
28
browser/src/scripts/components/BookIcon.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
const Icon = ({ fill, width, height, className }) => {
|
||||
const h = `${height}px`;
|
||||
const w = `${width}px`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
height={h}
|
||||
width={w}
|
||||
fill={fill}
|
||||
className={className}
|
||||
>
|
||||
<g transform="translate(240 0)">
|
||||
<path d="M-211,4v26h-24c-1.104,0-2-0.895-2-2s0.896-2,2-2h22V0h-22c-2.209,0-4,1.791-4,4v24c0,2.209,1.791,4,4,4h26V4H-211z M-235,8V2h20v22h-20V8z M-219,6h-12V4h12V6z M-223,10h-8V8h8V10z M-227,14h-4v-2h4V14z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
Icon.defaultProps = {
|
||||
fill: '#000',
|
||||
width: 32,
|
||||
height: 32
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
110
browser/src/scripts/components/BookSelector.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { useSelector, useDispatch } from '../store/hooks';
|
||||
import { updateBook, resetBook } from '../store/composer/actions';
|
||||
|
||||
import BookIcon from './BookIcon';
|
||||
|
||||
interface Props {
|
||||
selectorRef: React.Dispatch<any>;
|
||||
onAfterChange: () => void;
|
||||
}
|
||||
|
||||
function useCurrentOptions(options) {
|
||||
const currentValue = useSelector(state => {
|
||||
return state.composer.bookUUID;
|
||||
});
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
|
||||
if (option.value === currentValue) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useOptions() {
|
||||
const { books, composer } = useSelector(state => {
|
||||
return {
|
||||
books: state.books,
|
||||
composer: state.composer
|
||||
};
|
||||
});
|
||||
|
||||
const opts = books.items.map(book => {
|
||||
return {
|
||||
label: book.label,
|
||||
value: book.uuid
|
||||
};
|
||||
});
|
||||
|
||||
if (composer.bookLabel !== '' && composer.bookUUID === '') {
|
||||
opts.push({
|
||||
label: composer.bookLabel,
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
|
||||
// clone the array so as not to mutate Redux state manually
|
||||
// e.g. react-select mutates options prop internally upon adding a new option
|
||||
return cloneDeep(opts);
|
||||
}
|
||||
|
||||
const BookSelector: React.FunctionComponent<Props> = ({
|
||||
selectorRef,
|
||||
onAfterChange
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { books, composer } = useSelector(state => {
|
||||
return {
|
||||
books: state.books,
|
||||
composer: state.composer
|
||||
};
|
||||
});
|
||||
const options = useOptions();
|
||||
const currentOption = useCurrentOptions(options);
|
||||
|
||||
let placeholder: string;
|
||||
if (books.isFetched) {
|
||||
placeholder = 'Choose a book';
|
||||
} else {
|
||||
placeholder = 'Loading books...';
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect
|
||||
ref={el => {
|
||||
selectorRef(el);
|
||||
}}
|
||||
multi={false}
|
||||
isClearable
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
value={currentOption}
|
||||
onChange={(option, meta) => {
|
||||
if (meta.action === 'clear') {
|
||||
dispatch(resetBook());
|
||||
} else {
|
||||
let uuid: string;
|
||||
if (meta.action === 'create-option') {
|
||||
uuid = '';
|
||||
} else {
|
||||
uuid = option.value;
|
||||
}
|
||||
|
||||
dispatch(updateBook({ uuid, label: option.label }));
|
||||
}
|
||||
|
||||
onAfterChange();
|
||||
}}
|
||||
formatCreateLabel={label => `Add a new book ${label}`}
|
||||
isDisabled={!books.isFetched}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookSelector;
|
||||
11
browser/src/scripts/components/CloseIcon.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
export default () => (
|
||||
<svg height="20" viewBox="0 0 48 48" width="20">
|
||||
<path
|
||||
fill="#ffffff"
|
||||
d="M38 12.83l-2.83-2.83-11.17 11.17-11.17-11.17-2.83 2.83 11.17 11.17-11.17 11.17 2.83 2.83 11.17-11.17 11.17 11.17 2.83-2.83-11.17-11.17z"
|
||||
/>
|
||||
<path fill="none" d="M0 0h48v48h-48z" />
|
||||
</svg>
|
||||
);
|
||||
212
browser/src/scripts/components/Composer.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { KEYCODE_ENTER } from 'jslib/helpers/keyboard';
|
||||
import services from '../utils/services';
|
||||
import BookSelector from './BookSelector';
|
||||
import Flash from './Flash';
|
||||
import { useSelector, useDispatch } from '../store/hooks';
|
||||
import { updateContent, resetComposer } from '../store/composer/actions';
|
||||
import { fetchBooks } from '../store/books/actions';
|
||||
import { navigate } from '../store/location/actions';
|
||||
|
||||
interface Props {}
|
||||
|
||||
// focusBookSelectorInput focuses on the input element of the book selector.
|
||||
// It needs to traverse the tree returned by the ref API of the 'react-select' library,
|
||||
// and to guard against possible breaking changes, if the path does not exist, it noops.
|
||||
function focusBookSelectorInput(bookSelectorRef) {
|
||||
bookSelectorRef.select &&
|
||||
bookSelectorRef.select.select &&
|
||||
bookSelectorRef.select.select.inputRef &&
|
||||
bookSelectorRef.select.select.inputRef.focus();
|
||||
}
|
||||
|
||||
function useFetchData() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { books } = useSelector(state => {
|
||||
return {
|
||||
books: state.books
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!books.isFetched) {
|
||||
dispatch(fetchBooks());
|
||||
}
|
||||
}, [dispatch, books.isFetched]);
|
||||
}
|
||||
|
||||
function useInitFocus(contentRef, bookSelectorRef) {
|
||||
const { composer, books } = useSelector(state => {
|
||||
return {
|
||||
composer: state.composer,
|
||||
books: state.books
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!books.isFetched) {
|
||||
return () => null;
|
||||
}
|
||||
|
||||
if (bookSelectorRef && contentRef) {
|
||||
if (composer.bookLabel === '') {
|
||||
focusBookSelectorInput(bookSelectorRef);
|
||||
} else {
|
||||
contentRef.focus();
|
||||
}
|
||||
}
|
||||
}, [contentRef, bookSelectorRef, books.isFetched]);
|
||||
}
|
||||
|
||||
const Composer: React.FunctionComponent<Props> = () => {
|
||||
useFetchData();
|
||||
const [contentFocused, setContentFocused] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
const [contentRef, setContentEl] = useState(null);
|
||||
const [bookSelectorRef, setBookSelectorEl] = useState(null);
|
||||
|
||||
const { composer, settings } = useSelector(state => {
|
||||
return {
|
||||
composer: state.composer,
|
||||
settings: state.settings
|
||||
};
|
||||
});
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
let bookUUID;
|
||||
if (composer.bookUUID === '') {
|
||||
const resp = await services.books.create(
|
||||
{
|
||||
name: composer.bookLabel
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.sessionKey}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
bookUUID = resp.book.uuid;
|
||||
} else {
|
||||
bookUUID = composer.bookUUID;
|
||||
}
|
||||
|
||||
const resp = await services.notes.create(
|
||||
{
|
||||
book_uuid: bookUUID,
|
||||
content: composer.content
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.sessionKey}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// clear the composer state
|
||||
setErrMsg('');
|
||||
setSubmitting(false);
|
||||
|
||||
dispatch(resetComposer());
|
||||
|
||||
// navigate
|
||||
dispatch(
|
||||
navigate('/success', {
|
||||
bookName: composer.bookLabel,
|
||||
noteUUID: resp.result.uuid
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
setErrMsg(e.message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitShortcut = e => {
|
||||
// Shift + Enter
|
||||
if (e.shiftKey && e.keyCode === KEYCODE_ENTER) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleSubmitShortcut);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleSubmitShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, []);
|
||||
|
||||
let submitBtnText: string;
|
||||
if (submitting) {
|
||||
submitBtnText = 'Saving...';
|
||||
} else {
|
||||
submitBtnText = 'Save';
|
||||
}
|
||||
|
||||
useInitFocus(contentRef, bookSelectorRef);
|
||||
|
||||
return (
|
||||
<div className="composer">
|
||||
<Flash when={errMsg !== ''} message={errMsg} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<BookSelector
|
||||
selectorRef={setBookSelectorEl}
|
||||
onAfterChange={() => {
|
||||
contentRef.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="content-container">
|
||||
<textarea
|
||||
className="content"
|
||||
placeholder="What did you learn?"
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
dispatch(updateContent(val));
|
||||
}}
|
||||
value={composer.content}
|
||||
ref={el => {
|
||||
setContentEl(el);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setContentFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setContentFocused(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classnames('shortcut-hint', { shown: contentFocused })}
|
||||
>
|
||||
Shift + Enter to save
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="submit"
|
||||
value={submitBtnText}
|
||||
className="submit-button"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Composer;
|
||||
16
browser/src/scripts/components/Flash.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
when: boolean;
|
||||
}
|
||||
|
||||
const Flash: React.FunctionComponent<Props> = ({ message, when }) => {
|
||||
if (when) {
|
||||
return <div className="alert error">Error: {message}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Flash;
|
||||
36
browser/src/scripts/components/Header.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
|
||||
import Link from './Link';
|
||||
import MenuToggleIcon from './MenuToggleIcon';
|
||||
import CloseIcon from './CloseIcon';
|
||||
|
||||
interface Props {
|
||||
toggleMenu: () => void;
|
||||
isShowingMenu: boolean;
|
||||
}
|
||||
|
||||
const Header: React.FunctionComponent<Props> = ({
|
||||
toggleMenu,
|
||||
isShowingMenu
|
||||
}) => (
|
||||
<header className="header">
|
||||
<Link to="/" className="logo-link" tabIndex={-1}>
|
||||
<img src="images/logo-circle.png" alt="dnote" className="logo" />
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="#toggle"
|
||||
className="menu-toggle"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
|
||||
toggleMenu();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isShowingMenu ? <CloseIcon /> : <MenuToggleIcon />}
|
||||
</a>
|
||||
</header>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
100
browser/src/scripts/components/Home.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
|
||||
import Link from './Link';
|
||||
import config from '../utils/config';
|
||||
import { updateSettings } from '../store/settings/actions';
|
||||
import { useDispatch } from '../store/hooks';
|
||||
import services from '../utils/services';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Home: React.FunctionComponent<Props> = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleLogin = async e => {
|
||||
e.preventDefault();
|
||||
|
||||
setErrMsg('');
|
||||
setLoggingIn(true);
|
||||
|
||||
try {
|
||||
const signinResp = await services.users.signin({ email, password });
|
||||
|
||||
dispatch(
|
||||
updateSettings({
|
||||
sessionKey: signinResp.key,
|
||||
sessionKeyExpiry: signinResp.expiresAt
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.log('error while logging in', e);
|
||||
|
||||
setErrMsg(e.message);
|
||||
setLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="greet">Welcome to Dnote</h1>
|
||||
|
||||
<p className="lead">A simple personal knowledge base</p>
|
||||
|
||||
{errMsg && <div className="alert error">{errMsg}</div>}
|
||||
|
||||
<form id="login-form" onSubmit={handleLogin}>
|
||||
<label htmlFor="email-input">Email</label>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
className="input login-input"
|
||||
id="email-input"
|
||||
value={email}
|
||||
onChange={e => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<label htmlFor="password-input">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="●●●●●●●●"
|
||||
className="input login-input"
|
||||
id="password-input"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="button button-first button-small login-btn"
|
||||
disabled={loggingIn}
|
||||
>
|
||||
{loggingIn ? 'Signing in...' : 'Signin'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="actions">
|
||||
Don't have an account?{' '}
|
||||
<a
|
||||
href="https://app.getdnote.com/join"
|
||||
target="_blank"
|
||||
className="signup"
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
42
browser/src/scripts/components/Link.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { useDispatch } from '../store/hooks';
|
||||
import { navigate } from '../store/location/actions';
|
||||
|
||||
interface Props {
|
||||
to: string;
|
||||
className: string;
|
||||
tabIndex?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Link: React.FunctionComponent<Props> = ({
|
||||
to,
|
||||
children,
|
||||
onClick,
|
||||
...restProps
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`${to}`}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(navigate(to));
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
||||
35
browser/src/scripts/components/Menu.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { Fragment } from 'react';
|
||||
|
||||
import Link from './Link';
|
||||
|
||||
export default ({ toggleMenu, loggedIn, onLogout }) => (
|
||||
<Fragment>
|
||||
<ul className="menu">
|
||||
<li>
|
||||
<Link to="/" onClick={toggleMenu} className="menu-link">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{loggedIn && (
|
||||
<li>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
onLogout(toggleMenu);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Logout"
|
||||
className="menu-link logout-button"
|
||||
/>
|
||||
</form>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="menu-overlay" onClick={toggleMenu} />
|
||||
</Fragment>
|
||||
);
|
||||
43
browser/src/scripts/components/MenuToggleIcon.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
|
||||
export default () => (
|
||||
<svg
|
||||
enableBackground="new 0 0 24 24"
|
||||
id="Layer_1"
|
||||
version="1.0"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<line
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
x1="2"
|
||||
x2="22"
|
||||
y1="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
x1="2"
|
||||
x2="22"
|
||||
y1="6"
|
||||
y2="6"
|
||||
/>
|
||||
<line
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
x1="2"
|
||||
x2="22"
|
||||
y1="18"
|
||||
y2="18"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
85
browser/src/scripts/components/Success.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
KEYCODE_ENTER,
|
||||
KEYCODE_ESC,
|
||||
KEYCODE_LOWERCASE_B
|
||||
} from 'jslib/helpers/keyboard';
|
||||
import Flash from './Flash';
|
||||
import ext from '../utils/ext';
|
||||
import config from '../utils/config';
|
||||
import BookIcon from './BookIcon';
|
||||
import { navigate } from '../store/location/actions';
|
||||
import { useSelector, useDispatch } from '../store/hooks';
|
||||
|
||||
const Success: React.FunctionComponent = () => {
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { location } = useSelector(state => {
|
||||
return {
|
||||
location: state.location
|
||||
};
|
||||
});
|
||||
|
||||
const { bookName, noteUUID } = location.state;
|
||||
|
||||
const handleKeydown = e => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.keyCode === KEYCODE_ENTER) {
|
||||
dispatch(navigate('/'));
|
||||
} else if (e.keyCode === KEYCODE_ESC) {
|
||||
window.close();
|
||||
} else if (e.keyCode === KEYCODE_LOWERCASE_B) {
|
||||
const url = `${config.webUrl}/notes/${noteUUID}`;
|
||||
|
||||
ext.tabs
|
||||
.create({ url })
|
||||
.then(() => {
|
||||
window.close();
|
||||
})
|
||||
.catch(err => {
|
||||
setErrorMsg(err.message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Flash when={errorMsg !== ''} message={errorMsg} />
|
||||
|
||||
<div className="success-page">
|
||||
<div>
|
||||
<BookIcon width={20} height={20} className="book-icon" />
|
||||
|
||||
<h1 className="heading">Saved to {bookName}</h1>
|
||||
</div>
|
||||
|
||||
<ul className="key-list">
|
||||
<li className="key-item">
|
||||
<kbd className="key">Enter</kbd>{' '}
|
||||
<div className="key-desc">Go back</div>
|
||||
</li>
|
||||
<li className="key-item">
|
||||
<kbd className="key">b</kbd>{' '}
|
||||
<div className="key-desc">Open in browser</div>
|
||||
</li>
|
||||
<li className="key-item">
|
||||
<kbd className="key">ESC</kbd> <div className="key-desc">Close</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Success;
|
||||
49
browser/src/scripts/popup.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
|
||||
import { debounce } from 'jslib/helpers/perf';
|
||||
import configureStore from './store';
|
||||
import { loadState, saveState } from './utils/storage';
|
||||
import App from './components/App';
|
||||
import ext from './utils/ext';
|
||||
|
||||
const appContainer = document.getElementById('app');
|
||||
|
||||
loadState(items => {
|
||||
if (ext.runtime.lastError) {
|
||||
appContainer.innerText = `Failed to retrieve previous app state ${ext.runtime.lastError.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
let initialState;
|
||||
const prevState = items.state;
|
||||
if (prevState) {
|
||||
// rehydrate
|
||||
initialState = prevState;
|
||||
}
|
||||
|
||||
const store = configureStore(initialState);
|
||||
|
||||
store.subscribe(
|
||||
debounce(() => {
|
||||
const state = store.getState();
|
||||
|
||||
saveState(state);
|
||||
}, 100)
|
||||
);
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
appContainer,
|
||||
() => {
|
||||
// On Chrome, popup window size is kept at minimum if app render is delayed
|
||||
// Therefore add minimum dimension to body until app is rendered
|
||||
document.getElementsByTagName('body')[0].className = '';
|
||||
}
|
||||
);
|
||||
});
|
||||
59
browser/src/scripts/store/books/actions.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import services from '../../utils/services';
|
||||
|
||||
import {
|
||||
START_FETCHING,
|
||||
RECEIVE,
|
||||
RECEIVE_ERROR,
|
||||
StartFetchingAction,
|
||||
ReceiveAction,
|
||||
ReceiveErrorAction
|
||||
} from './types';
|
||||
|
||||
function startFetchingBooks(): StartFetchingAction {
|
||||
return {
|
||||
type: START_FETCHING
|
||||
};
|
||||
}
|
||||
|
||||
function receiveBooks(books): ReceiveAction {
|
||||
return {
|
||||
type: RECEIVE,
|
||||
data: {
|
||||
books
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function receiveBooksError(error: string): ReceiveErrorAction {
|
||||
return {
|
||||
type: RECEIVE_ERROR,
|
||||
data: {
|
||||
error
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBooks() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(startFetchingBooks());
|
||||
|
||||
const { settings } = getState();
|
||||
|
||||
services.books
|
||||
.fetch(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.sessionKey}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(books => {
|
||||
dispatch(receiveBooks(books));
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('error fetching books', err);
|
||||
dispatch(receiveBooksError(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
48
browser/src/scripts/store/books/reducers.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
START_FETCHING,
|
||||
RECEIVE,
|
||||
RECEIVE_ERROR,
|
||||
BooksState,
|
||||
BooksActionType
|
||||
} from './types';
|
||||
|
||||
const initialState = {
|
||||
items: [],
|
||||
isFetching: false,
|
||||
isFetched: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
export default function(
|
||||
state = initialState,
|
||||
action: BooksActionType
|
||||
): BooksState {
|
||||
switch (action.type) {
|
||||
case START_FETCHING:
|
||||
return {
|
||||
...state,
|
||||
isFetching: true,
|
||||
isFetched: false
|
||||
};
|
||||
case RECEIVE: {
|
||||
const { books } = action.data;
|
||||
|
||||
// get uuids of deleted books and that of a currently selected book
|
||||
return {
|
||||
...state,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
items: [...state.items, ...books]
|
||||
};
|
||||
}
|
||||
case RECEIVE_ERROR:
|
||||
return {
|
||||
...state,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
error: action.data.error
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
35
browser/src/scripts/store/books/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export type BookData = any;
|
||||
|
||||
export interface BooksState {
|
||||
items: BookData[];
|
||||
isFetching: boolean;
|
||||
isFetched: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const START_FETCHING = 'books/START_FETCHING';
|
||||
export const RECEIVE = 'books/RECEIVE';
|
||||
export const RECEIVE_ERROR = 'books/RECEIVE_ERROR';
|
||||
|
||||
export interface StartFetchingAction {
|
||||
type: typeof START_FETCHING;
|
||||
}
|
||||
|
||||
export interface ReceiveAction {
|
||||
type: typeof RECEIVE;
|
||||
data: {
|
||||
books: BookData[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReceiveErrorAction {
|
||||
type: typeof RECEIVE_ERROR;
|
||||
data: {
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type BooksActionType =
|
||||
| StartFetchingAction
|
||||
| ReceiveAction
|
||||
| ReceiveErrorAction;
|
||||
47
browser/src/scripts/store/composer/actions.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
UPDATE_CONTENT,
|
||||
UPDATE_BOOK,
|
||||
RESET,
|
||||
RESET_BOOK,
|
||||
UpdateContentAction,
|
||||
UpdateBookAction,
|
||||
ResetBookAction,
|
||||
ResetAction
|
||||
} from './types';
|
||||
|
||||
export function updateContent(content: string): UpdateContentAction {
|
||||
return {
|
||||
type: UPDATE_CONTENT,
|
||||
data: { content }
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateBookActionParam {
|
||||
uuid: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function updateBook({
|
||||
uuid,
|
||||
label
|
||||
}: UpdateBookActionParam): UpdateBookAction {
|
||||
return {
|
||||
type: UPDATE_BOOK,
|
||||
data: {
|
||||
uuid,
|
||||
label
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetBook(): ResetBookAction {
|
||||
return {
|
||||
type: RESET_BOOK
|
||||
};
|
||||
}
|
||||
|
||||
export function resetComposer(): ResetAction {
|
||||
return {
|
||||
type: RESET
|
||||
};
|
||||
}
|
||||
47
browser/src/scripts/store/composer/reducers.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
UPDATE_CONTENT,
|
||||
UPDATE_BOOK,
|
||||
RESET,
|
||||
RESET_BOOK,
|
||||
ComposerActionType,
|
||||
ComposerState
|
||||
} from './types';
|
||||
|
||||
const initialState: ComposerState = {
|
||||
content: '',
|
||||
bookUUID: '',
|
||||
bookLabel: ''
|
||||
};
|
||||
|
||||
export default function(
|
||||
state = initialState,
|
||||
action: ComposerActionType
|
||||
): ComposerState {
|
||||
switch (action.type) {
|
||||
case UPDATE_CONTENT: {
|
||||
return {
|
||||
...state,
|
||||
content: action.data.content
|
||||
};
|
||||
}
|
||||
case UPDATE_BOOK: {
|
||||
return {
|
||||
...state,
|
||||
bookUUID: action.data.uuid,
|
||||
bookLabel: action.data.label
|
||||
};
|
||||
}
|
||||
case RESET_BOOK: {
|
||||
return {
|
||||
...state,
|
||||
bookUUID: '',
|
||||
bookLabel: ''
|
||||
};
|
||||
}
|
||||
case RESET: {
|
||||
return initialState;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
39
browser/src/scripts/store/composer/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export interface ComposerState {
|
||||
content: string;
|
||||
bookUUID: string;
|
||||
bookLabel: string;
|
||||
}
|
||||
|
||||
export const UPDATE_CONTENT = 'composer/UPDATE_CONTENT';
|
||||
export const UPDATE_BOOK = 'composer/UPDATE_BOOK';
|
||||
export const RESET = 'composer/RESET';
|
||||
export const RESET_BOOK = 'composer/RESET_BOOK';
|
||||
|
||||
export interface UpdateContentAction {
|
||||
type: typeof UPDATE_CONTENT;
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateBookAction {
|
||||
type: typeof UPDATE_BOOK;
|
||||
data: {
|
||||
uuid: string;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResetAction {
|
||||
type: typeof RESET;
|
||||
}
|
||||
|
||||
export interface ResetBookAction {
|
||||
type: typeof RESET_BOOK;
|
||||
}
|
||||
|
||||
export type ComposerActionType =
|
||||
| UpdateContentAction
|
||||
| UpdateBookAction
|
||||
| ResetAction
|
||||
| ResetBookAction;
|
||||
36
browser/src/scripts/store/hooks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Store, Action } from 'redux';
|
||||
import {
|
||||
useDispatch as useReduxDispatch,
|
||||
useStore as useReduxStore,
|
||||
useSelector as useReduxSelector
|
||||
} from 'react-redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
|
||||
import { ComposerState } from './composer/types';
|
||||
import { LocationState } from './location/types';
|
||||
import { SettingsState } from './settings/types';
|
||||
import { BooksState } from './books/types';
|
||||
|
||||
// AppState represents the application state
|
||||
interface AppState {
|
||||
composer: ComposerState;
|
||||
location: LocationState;
|
||||
settings: SettingsState;
|
||||
books: BooksState;
|
||||
}
|
||||
|
||||
type ReduxDispatch = ThunkDispatch<AppState, any, Action>;
|
||||
|
||||
export function useDispatch(): ReduxDispatch {
|
||||
return useReduxDispatch<ReduxDispatch>();
|
||||
}
|
||||
|
||||
export function useStore(): Store<AppState> {
|
||||
return useReduxStore<AppState>();
|
||||
}
|
||||
|
||||
export function useSelector<TSelected>(
|
||||
selector: (state: AppState) => TSelected
|
||||
) {
|
||||
return useReduxSelector<AppState, TSelected>(selector);
|
||||
}
|
||||
29
browser/src/scripts/store/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import createLogger from 'redux-logger';
|
||||
|
||||
import location from './location/reducers';
|
||||
import settings from './settings/reducers';
|
||||
import books from './books/reducers';
|
||||
import composer from './composer/reducers';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
location,
|
||||
settings,
|
||||
books,
|
||||
composer
|
||||
});
|
||||
|
||||
// configuruStore returns a new store that contains the appliation state
|
||||
export default function configureStore(initialState) {
|
||||
const typedWindow = window as any;
|
||||
|
||||
const composeEnhancers =
|
||||
typedWindow.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
return createStore(
|
||||
rootReducer,
|
||||
initialState,
|
||||
composeEnhancers(applyMiddleware(createLogger, thunkMiddleware))
|
||||
);
|
||||
}
|
||||
8
browser/src/scripts/store/location/actions.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { NAVIGATE, NavigateAction } from './types';
|
||||
|
||||
export function navigate(path: string, state?): NavigateAction {
|
||||
return {
|
||||
type: NAVIGATE,
|
||||
data: { path, state }
|
||||
};
|
||||
}
|
||||
22
browser/src/scripts/store/location/reducers.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NAVIGATE, LocationState, LocationActionType } from './types';
|
||||
|
||||
const initialState: LocationState = {
|
||||
path: '/',
|
||||
state: {}
|
||||
};
|
||||
|
||||
export default function(
|
||||
state = initialState,
|
||||
action: LocationActionType
|
||||
): LocationState {
|
||||
switch (action.type) {
|
||||
case NAVIGATE:
|
||||
return {
|
||||
...state,
|
||||
path: action.data.path,
|
||||
state: action.data.state || {}
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
16
browser/src/scripts/store/location/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export interface LocationState {
|
||||
path: string;
|
||||
state: any;
|
||||
}
|
||||
|
||||
export const NAVIGATE = 'location/NAVIGATE';
|
||||
|
||||
export interface NavigateAction {
|
||||
type: typeof NAVIGATE;
|
||||
data: {
|
||||
path: string;
|
||||
state: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocationActionType = NavigateAction;
|
||||
14
browser/src/scripts/store/settings/actions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { UPDATE, RESET, UpdateAction, ResetAction } from './types';
|
||||
|
||||
export function updateSettings(settings): UpdateAction {
|
||||
return {
|
||||
type: UPDATE,
|
||||
data: { settings }
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSettings(): ResetAction {
|
||||
return {
|
||||
type: RESET
|
||||
};
|
||||
}
|
||||
23
browser/src/scripts/store/settings/reducers.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { UPDATE, RESET, SettingsState, SettingsActionType } from './types';
|
||||
|
||||
const initialState: SettingsState = {
|
||||
sessionKey: '',
|
||||
sessionKeyExpiry: 0
|
||||
};
|
||||
|
||||
export default function(
|
||||
state = initialState,
|
||||
action: SettingsActionType
|
||||
): SettingsState {
|
||||
switch (action.type) {
|
||||
case UPDATE:
|
||||
return {
|
||||
...state,
|
||||
...action.data.settings
|
||||
};
|
||||
case RESET:
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
20
browser/src/scripts/store/settings/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface SettingsState {
|
||||
sessionKey: string;
|
||||
sessionKeyExpiry: number;
|
||||
}
|
||||
|
||||
export const UPDATE = 'settings/UPDATE';
|
||||
export const RESET = 'settings/RESET';
|
||||
|
||||
export interface UpdateAction {
|
||||
type: typeof UPDATE;
|
||||
data: {
|
||||
settings: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResetAction {
|
||||
type: typeof RESET;
|
||||
}
|
||||
|
||||
export type SettingsActionType = UpdateAction | ResetAction;
|
||||
5
browser/src/scripts/utils/config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
webUrl: __WEB_URL__,
|
||||
apiEndpoint: __API_ENDPOINT__,
|
||||
version: __VERSION__
|
||||
};
|
||||
37
browser/src/scripts/utils/ext.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// module ext provides a cross-browser interface to access extension APIs
|
||||
// by using WebExtensions API if available, and using Chrome as a fallback.
|
||||
let ext: any = {};
|
||||
|
||||
const apis = ['tabs', 'storage', 'runtime'];
|
||||
|
||||
for (let i = 0; i < apis.length; i++) {
|
||||
const api = apis[i];
|
||||
|
||||
try {
|
||||
if (browser[api]) {
|
||||
ext[api] = browser[api];
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
if (chrome[api] && !ext[api]) {
|
||||
ext[api] = chrome[api];
|
||||
|
||||
// Standardize the signature to conform to WebExtensions API
|
||||
if (api === 'tabs') {
|
||||
const fn = ext[api].create;
|
||||
|
||||
// Promisify chrome.tabs.create
|
||||
ext[api].create = function(obj) {
|
||||
return new Promise(resolve => {
|
||||
fn(obj, function(tab) {
|
||||
resolve(tab);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export default ext;
|
||||
48
browser/src/scripts/utils/fetch.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import qs from 'qs';
|
||||
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
}
|
||||
return response.text().then((body) => {
|
||||
const error = new Error(body);
|
||||
error.response = response;
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
if (response.headers.get('Content-Type') === 'application/json') {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function request(url, options) {
|
||||
return fetch(url, options)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON);
|
||||
}
|
||||
|
||||
export function post(url, data, options = {}) {
|
||||
return request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, options = {}) {
|
||||
let endpoint = url;
|
||||
|
||||
if (options.params) {
|
||||
endpoint = `${endpoint}?${qs.stringify(options.params)}`;
|
||||
}
|
||||
|
||||
return request(endpoint, {
|
||||
method: 'GET',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
9
browser/src/scripts/utils/services.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import initServices from 'jslib/services';
|
||||
import config from './config';
|
||||
|
||||
const services = initServices({
|
||||
baseUrl: config.apiEndpoint,
|
||||
pathPrefix: ''
|
||||
});
|
||||
|
||||
export default services;
|
||||
51
browser/src/scripts/utils/storage.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import ext from "./ext";
|
||||
|
||||
const stateKey = "state";
|
||||
|
||||
// filterState filters the given state to be suitable for reuse upon next app
|
||||
// load
|
||||
function filterState(state) {
|
||||
return {
|
||||
...state,
|
||||
location: {
|
||||
...state.location,
|
||||
path: "/"
|
||||
},
|
||||
books: {
|
||||
...state.books,
|
||||
items: state.books.items.filter(item => {
|
||||
return !item.isNew || item.selected;
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseStorageItem(item) {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(item);
|
||||
}
|
||||
|
||||
// saveState writes the given state to storage
|
||||
export function saveState(state) {
|
||||
const filtered = filterState(state);
|
||||
const serialized = JSON.stringify(filtered);
|
||||
|
||||
ext.storage.local.set({ [stateKey]: serialized }, () => {
|
||||
console.log("synced state");
|
||||
});
|
||||
}
|
||||
|
||||
// loadState loads and parses serialized state stored in ext.storage
|
||||
export function loadState(done) {
|
||||
ext.storage.local.get("state", items => {
|
||||
const parsed = {
|
||||
...items,
|
||||
state: parseStorageItem(items.state)
|
||||
};
|
||||
|
||||
return done(parsed);
|
||||
});
|
||||
}
|
||||
375
browser/src/styles/popup.css
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
/* container width: 345px */
|
||||
|
||||
html {
|
||||
font-size: 62.5%;
|
||||
/* 1.0 rem = 10px */
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
body.pending {
|
||||
height: 257px;
|
||||
width: 345px;
|
||||
}
|
||||
main.blur {
|
||||
opacity: 0.15;
|
||||
}
|
||||
.container {
|
||||
width: 345px;
|
||||
position: relative;
|
||||
}
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: .375rem .75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
}
|
||||
.login-input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.message {
|
||||
color: #2cae2c;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.alert.error {
|
||||
padding: 10px 9px;
|
||||
font-size: 1.4rem;
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 3px 5px;
|
||||
font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
line-height: 10px;
|
||||
color: #444d56;
|
||||
vertical-align: middle;
|
||||
background-color: #fafbfc;
|
||||
border: solid 1px #d1d5da;
|
||||
border-bottom-color: #c6cbd1;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 #c6cbd1;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
z-index: 1;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: 0px 3px 2px 0px #cacaca;
|
||||
}
|
||||
.menu .logout-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-link {
|
||||
display: block;
|
||||
padding: 10px 13px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
.menu-link:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
.menu-link:not(:hover) {
|
||||
background: inherit;
|
||||
}
|
||||
.menu-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 44px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0.8;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
.header {
|
||||
background-color: #272a35;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header .logo {
|
||||
width: 32px;
|
||||
}
|
||||
.header .logo-link {
|
||||
height: 32px;
|
||||
}
|
||||
.header .menu-toggle {
|
||||
height: 20px;
|
||||
}
|
||||
.home {
|
||||
text-align: center;
|
||||
padding: 16px 28px;
|
||||
}
|
||||
.home .greet {
|
||||
font-size: 2.2rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.home .lead {
|
||||
color: #575757;
|
||||
}
|
||||
.home #login-form {
|
||||
text-align: left;
|
||||
}
|
||||
.home #login-form label {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.home .login-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.home .actions {
|
||||
margin-top: 18px;
|
||||
font-size: 1.3rem;
|
||||
color: gray;
|
||||
}
|
||||
.home .actions a {
|
||||
color: gray;
|
||||
font-weight: 600;
|
||||
}
|
||||
.home .actions .signup:visited {
|
||||
color: inherit;
|
||||
}
|
||||
.settings {
|
||||
padding: 15px 8px;
|
||||
}
|
||||
.settings .label {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.settings .actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.settings .hint {
|
||||
font-size: 1.4rem;
|
||||
color: #7e7e7e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.composer .form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.composer .content-container {
|
||||
position: relative;
|
||||
height: 148px;
|
||||
}
|
||||
.composer .content {
|
||||
border: none;
|
||||
resize: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 11px 11px 18px 11px;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-top: 0;
|
||||
}
|
||||
.composer .content::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
.composer .content:focus {
|
||||
box-shadow: inset 0px 0px 3px #c0c0c0;
|
||||
outline: none;
|
||||
}
|
||||
.composer .shortcut-hint {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 7px;
|
||||
color: gray;
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.composer .shortcut-hint:not(.shown) {
|
||||
visibility: hidden;
|
||||
}
|
||||
.composer .submit-button {
|
||||
color: #fff;
|
||||
background-color: #272a35;
|
||||
border-radius: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-size: 1.4rem;
|
||||
text-decoration: none;
|
||||
padding: 11px 18px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
.composer .submit-button:hover {
|
||||
color: #fff;
|
||||
background-color: #323642;
|
||||
box-shadow: 0px 0px 4px 2px #cacaca;
|
||||
}
|
||||
.composer .book-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.composer .book-value .book-icon {
|
||||
margin-right: 9px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.composer .book-option {
|
||||
font-size: 1.4rem;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.success-page {
|
||||
text-align: center;
|
||||
padding: 21px 0;
|
||||
}
|
||||
.success-page .key-list {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.success-page .key-item {
|
||||
display: flex;
|
||||
}
|
||||
.success-page .key-item:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.success-page .key-desc {
|
||||
font-size: 1.4rem;
|
||||
margin-left: 9px;
|
||||
}
|
||||
.success-page .book-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.success-page .heading {
|
||||
display: inline-block;
|
||||
font-size: 2.2rem;
|
||||
margin-left: 13px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-size: 1.4rem;
|
||||
text-decoration: none;
|
||||
padding: 11px 18px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.button:icon {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.button-first {
|
||||
color: #ffffff;
|
||||
background-color: #333745;
|
||||
}
|
||||
.button-first:hover {
|
||||
color: #ffffff;
|
||||
background-color: #252833;
|
||||
box-shadow: 0px 0px 4px 2px #cacaca;
|
||||
}
|
||||
|
||||
.button-first-outline {
|
||||
background: transparent;
|
||||
border-color: #333745;
|
||||
color: #333744;
|
||||
}
|
||||
.button-first-outline:hover {
|
||||
color: #333744;
|
||||
}
|
||||
|
||||
.button-second {
|
||||
background: white;
|
||||
color: #343a40;
|
||||
}
|
||||
.button-second:hover {
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.button-third {
|
||||
color: #ffffff;
|
||||
background-color: #4577cc;
|
||||
}
|
||||
.button-third:hover {
|
||||
color: #ffffff;
|
||||
background-color: #245fc5;
|
||||
box-shadow: 0px 0px 4px 2px #cacaca;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
color: #0366d6;
|
||||
background-color: #fff;
|
||||
border: 2px solid #0366d6;
|
||||
}
|
||||
|
||||
.button ~ .button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.button-small {
|
||||
padding: 5px 14px;
|
||||
}
|
||||
435
browser/src/styles/select.css
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
/**
|
||||
* React Select
|
||||
* ============
|
||||
* Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/
|
||||
* https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs
|
||||
* MIT License: https://github.com/JedWatson/react-select
|
||||
*/
|
||||
.Select {
|
||||
position: relative;
|
||||
}
|
||||
.Select input::-webkit-contacts-auto-fill-button,
|
||||
.Select input::-webkit-credentials-auto-fill-button {
|
||||
display: none !important;
|
||||
}
|
||||
.Select input::-ms-clear {
|
||||
display: none !important;
|
||||
}
|
||||
.Select input::-ms-reveal {
|
||||
display: none !important;
|
||||
}
|
||||
.Select,
|
||||
.Select div,
|
||||
.Select input,
|
||||
.Select span {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.Select.is-disabled .Select-arrow-zone {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.Select.is-disabled > .Select-control {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.Select.is-disabled > .Select-control:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
.Select.is-open > .Select-control {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background: #fff;
|
||||
border-color: #b3b3b3 #ccc #d9d9d9;
|
||||
}
|
||||
.Select.is-open > .Select-control .Select-arrow {
|
||||
top: -2px;
|
||||
border-color: transparent transparent #999;
|
||||
border-width: 0 5px 5px;
|
||||
}
|
||||
.Select.is-searchable.is-open > .Select-control {
|
||||
cursor: text;
|
||||
}
|
||||
.Select.is-searchable.is-focused:not(.is-open) > .Select-control {
|
||||
cursor: text;
|
||||
}
|
||||
.Select.is-focused > .Select-control {
|
||||
background: #fff;
|
||||
}
|
||||
.Select.is-focused:not(.is-open) > .Select-control {
|
||||
border-color: #007eff;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
|
||||
0 0 0 3px rgba(0, 126, 255, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
.Select.has-value.is-clearable.Select--single > .Select-control .Select-value {
|
||||
padding-right: 42px;
|
||||
}
|
||||
.Select.has-value.Select--single
|
||||
> .Select-control .Select-value .Select-value-label,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control .Select-value .Select-value-label {
|
||||
color: #333;
|
||||
}
|
||||
.Select.has-value.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.Select.has-value.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:hover,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:hover,
|
||||
.Select.has-value.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:focus,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:focus {
|
||||
color: #007eff;
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Select.has-value.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:focus,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control .Select-value a.Select-value-label:focus {
|
||||
background: #fff;
|
||||
}
|
||||
.Select.has-value.is-pseudo-focused .Select-input {
|
||||
opacity: 0;
|
||||
}
|
||||
.Select.is-open .Select-arrow,
|
||||
.Select .Select-arrow-zone:hover > .Select-arrow {
|
||||
border-top-color: #666;
|
||||
}
|
||||
.Select.Select--rtl {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
.Select-control {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
cursor: default;
|
||||
display: table;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-top: 0;
|
||||
height: 36px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.Select-control:hover {
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.Select-control .Select-input:focus {
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.Select-placeholder,
|
||||
.Select--single > .Select-control .Select-value {
|
||||
bottom: 0;
|
||||
color: #aaa;
|
||||
left: 0;
|
||||
line-height: 34px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.Select-input {
|
||||
height: 34px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select-input > input {
|
||||
width: 100%;
|
||||
background: none transparent;
|
||||
border: 0 none;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
line-height: 17px;
|
||||
/* For IE 8 compatibility */
|
||||
padding: 8px 0 12px;
|
||||
/* For IE 8 compatibility */
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.is-focused .Select-input > input {
|
||||
cursor: text;
|
||||
}
|
||||
.has-value.is-pseudo-focused .Select-input {
|
||||
opacity: 0;
|
||||
}
|
||||
.Select-control:not(.is-searchable) > .Select-input {
|
||||
outline: none;
|
||||
}
|
||||
.Select-loading-zone {
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 16px;
|
||||
}
|
||||
.Select-loading {
|
||||
-webkit-animation: Select-animation-spin 400ms infinite linear;
|
||||
-o-animation: Select-animation-spin 400ms infinite linear;
|
||||
animation: Select-animation-spin 400ms infinite linear;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ccc;
|
||||
border-right-color: #333;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select-clear-zone {
|
||||
-webkit-animation: Select-animation-fadeIn 200ms;
|
||||
-o-animation: Select-animation-fadeIn 200ms;
|
||||
animation: Select-animation-fadeIn 200ms;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 17px;
|
||||
}
|
||||
.Select-clear-zone:hover {
|
||||
color: #d0021b;
|
||||
}
|
||||
.Select-clear {
|
||||
display: inline-block;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.Select--multi .Select-clear-zone {
|
||||
width: 17px;
|
||||
}
|
||||
.Select-arrow-zone {
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 25px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.Select--rtl .Select-arrow-zone {
|
||||
padding-right: 0;
|
||||
padding-left: 5px;
|
||||
}
|
||||
.Select-arrow {
|
||||
border-color: #999 transparent transparent;
|
||||
border-style: solid;
|
||||
border-width: 5px 5px 2.5px;
|
||||
display: inline-block;
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.Select-control > *:last-child {
|
||||
padding-right: 5px;
|
||||
}
|
||||
.Select--multi .Select-multi-value-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
.Select .Select-aria-only {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
overflow: hidden;
|
||||
float: left;
|
||||
}
|
||||
@-webkit-keyframes Select-animation-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes Select-animation-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.Select-menu-outer {
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-top-color: #e6e6e6;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
margin-top: -1px;
|
||||
max-height: 200px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.Select-menu {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.Select-option {
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.Select-option:last-child {
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.Select-option.is-selected {
|
||||
background-color: #f5faff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.04);
|
||||
color: #333;
|
||||
}
|
||||
.Select-option.is-focused {
|
||||
background-color: #ebf5ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.08);
|
||||
color: #333;
|
||||
}
|
||||
.Select-option.is-disabled {
|
||||
color: #cccccc;
|
||||
cursor: default;
|
||||
}
|
||||
.Select-noresults {
|
||||
box-sizing: border-box;
|
||||
color: #999999;
|
||||
cursor: default;
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.Select--multi .Select-input {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-input {
|
||||
margin-left: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.Select--multi.has-value .Select-input {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.Select--multi .Select-value {
|
||||
background-color: #ebf5ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
border: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border: 1px solid rgba(0, 126, 255, 0.24);
|
||||
color: #007eff;
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.Select--multi .Select-value-icon,
|
||||
.Select--multi .Select-value-label {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select--multi .Select-value-label {
|
||||
border-bottom-right-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
cursor: default;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.Select--multi a.Select-value-label {
|
||||
color: #007eff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.Select--multi a.Select-value-label:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Select--multi .Select-value-icon {
|
||||
cursor: pointer;
|
||||
border-bottom-left-radius: 2px;
|
||||
border-top-left-radius: 2px;
|
||||
border-right: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border-right: 1px solid rgba(0, 126, 255, 0.24);
|
||||
padding: 1px 5px 3px;
|
||||
}
|
||||
.Select--multi .Select-value-icon:hover,
|
||||
.Select--multi .Select-value-icon:focus {
|
||||
background-color: #d8eafd;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 113, 230, 0.08);
|
||||
color: #0071e6;
|
||||
}
|
||||
.Select--multi .Select-value-icon:active {
|
||||
background-color: #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.24);
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-value {
|
||||
margin-left: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-value-icon {
|
||||
border-right: none;
|
||||
border-left: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border-left: 1px solid rgba(0, 126, 255, 0.24);
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value {
|
||||
background-color: #fcfcfc;
|
||||
border: 1px solid #e3e3e3;
|
||||
color: #333;
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value-icon {
|
||||
cursor: not-allowed;
|
||||
border-right: 1px solid #e3e3e3;
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value-icon:hover,
|
||||
.Select--multi.is-disabled .Select-value-icon:focus,
|
||||
.Select--multi.is-disabled .Select-value-icon:active {
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
@keyframes Select-animation-spin {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes Select-animation-spin {
|
||||
to {
|
||||
-webkit-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
18
browser/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": false,
|
||||
"module": "es6",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"target": "es5",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"jslib/*": [
|
||||
"../jslib/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
58
browser/webpack.config.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
const ENV = process.env.NODE_ENV;
|
||||
const TARGET = process.env.TARGET;
|
||||
const isProduction = ENV === 'production';
|
||||
|
||||
console.log(`Running webpack in ${ENV} mode`);
|
||||
|
||||
const webUrl = isProduction
|
||||
? 'https://app.getdnote.com'
|
||||
: 'http://127.0.0.1:3000';
|
||||
const apiUrl = isProduction
|
||||
? 'https://api.getdnote.com'
|
||||
: 'http://127.0.0.1:5000';
|
||||
|
||||
const plugins = [
|
||||
new webpack.DefinePlugin({
|
||||
__API_ENDPOINT__: JSON.stringify(apiUrl),
|
||||
__WEB_URL__: JSON.stringify(webUrl),
|
||||
__VERSION__: JSON.stringify(packageJson.version)
|
||||
})
|
||||
];
|
||||
|
||||
const moduleRules = [
|
||||
{
|
||||
test: /\.ts(x?)$/,
|
||||
exclude: /node_modules|_test\.ts(x)$/,
|
||||
loaders: ['ts-loader'],
|
||||
exclude: path.resolve(__dirname, 'node_modules')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = env => {
|
||||
return {
|
||||
// run in production mode because of Content Security Policy error encountered
|
||||
// when running a JavaScript bundle produced in a development mode
|
||||
mode: 'production',
|
||||
entry: { popup: ['./src/scripts/popup.tsx'] },
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist', TARGET, 'scripts')
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
alias: {
|
||||
jslib: path.join(__dirname, '../jslib/src')
|
||||
},
|
||||
modules: [path.resolve('node_modules')]
|
||||
},
|
||||
module: { rules: moduleRules },
|
||||
plugins: plugins,
|
||||
optimization: {
|
||||
minimize: isProduction
|
||||
}
|
||||
};
|
||||
};
|
||||
3
jslib/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/dist
|
||||
/node_modules
|
||||
/coverage
|
||||
3
jslib/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# jslib
|
||||
|
||||
Code shared between Dnote JavaScript projects.
|
||||
49
jslib/karma.conf.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
module.exports = config => {
|
||||
config.set({
|
||||
frameworks: ['mocha', 'karma-typescript'],
|
||||
reporters: ['mocha', 'karma-typescript'],
|
||||
browsers: ['ChromeHeadlessNoSandbox'],
|
||||
customLaunchers: {
|
||||
ChromeHeadlessNoSandbox: {
|
||||
base: 'ChromeHeadless',
|
||||
// specified because the default user of gitlab ci docker image is root, and chrome does not
|
||||
// support running as root without no-sandbox
|
||||
flags: ['--no-sandbox']
|
||||
}
|
||||
},
|
||||
files: [
|
||||
'./src/**/*.ts'
|
||||
],
|
||||
preprocessors: {
|
||||
'**/*.ts': 'karma-typescript'
|
||||
},
|
||||
karmaTypescriptConfig: {
|
||||
tsconfig: './tsconfig.json',
|
||||
bundlerOptions: {
|
||||
entrypoints: /\_test\.ts$/,
|
||||
sourceMap: true
|
||||
}
|
||||
},
|
||||
mochaReporter: {
|
||||
showDiff: true
|
||||
}
|
||||
});
|
||||
};
|
||||
3896
jslib/package-lock.json
generated
Normal file
33
jslib/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "jslib",
|
||||
"version": "0.1.0",
|
||||
"description": "An internal JavaScript SDK for Dnote",
|
||||
"types": "dist/types/index.d.ts",
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./dist",
|
||||
"build": "tsc",
|
||||
"build:watch": "tsc --watch",
|
||||
"test": "karma start ./karma.conf.js --single-run",
|
||||
"test:watch": "karma start ./karma.conf.js --watch"
|
||||
},
|
||||
"author": "Monomax Software Pty Ltd",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.3",
|
||||
"history": "^4.10.1",
|
||||
"lodash": "^4.17.15",
|
||||
"qs": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^5.2.7",
|
||||
"chai": "^4.2.0",
|
||||
"karma": "^4.3.0",
|
||||
"karma-chrome-launcher": "^3.1.0",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-mocha-reporter": "^2.2.5",
|
||||
"karma-typescript": "^4.1.1",
|
||||
"mocha": "^6.2.0",
|
||||
"prettier": "^1.18.2",
|
||||
"typescript": "^3.6.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { BookData } from '../operations/books';
|
||||
|
||||
// errBookNameNumeric is an error for book names that only contain numbers
|
||||
export const errBookNameNumeric = new Error(
|
||||
'The book name cannot contain only numbers'
|
||||
|
|
@ -26,6 +28,9 @@ export const errBookNameHasSpace = new Error(
|
|||
'The book name cannot contain spaces'
|
||||
);
|
||||
|
||||
// errBookNameHasComma is an error for book names that have any comma
|
||||
export const errBookNameHasComma = new Error('The book name has comma');
|
||||
|
||||
// errBookNameReserved is an error incidating that the specified book name is reserved
|
||||
export const errBookNameReserved = new Error('The book name is reserved');
|
||||
|
||||
|
|
@ -34,7 +39,7 @@ const numberRegex = /^\d+$/;
|
|||
const reservedBookNames = ['trash', 'conflicts'];
|
||||
|
||||
// validateBookName validates the given book name and throws error if not valid
|
||||
export function validateBookName(bookName) {
|
||||
export function validateBookName(bookName: string) {
|
||||
if (reservedBookNames.indexOf(bookName) > -1) {
|
||||
throw errBookNameReserved;
|
||||
}
|
||||
|
|
@ -46,11 +51,15 @@ export function validateBookName(bookName) {
|
|||
if (bookName.indexOf(' ') > -1) {
|
||||
throw errBookNameHasSpace;
|
||||
}
|
||||
|
||||
if (bookName.indexOf(',') > -1) {
|
||||
throw errBookNameHasComma;
|
||||
}
|
||||
}
|
||||
|
||||
// checkDuplicate checks if the given book name has a duplicate in the given array
|
||||
// of books
|
||||
export function checkDuplicate(books, bookName) {
|
||||
export function checkDuplicate(books: BookData[], bookName: string): boolean {
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
|
||||
184
jslib/src/helpers/books_test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
validateBookName,
|
||||
checkDuplicate,
|
||||
errBookNameNumeric,
|
||||
errBookNameHasSpace,
|
||||
errBookNameReserved,
|
||||
errBookNameHasComma
|
||||
} from './books';
|
||||
|
||||
describe('books lib', () => {
|
||||
describe('validateBookName', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'javascript',
|
||||
expectedErr: null
|
||||
},
|
||||
{
|
||||
input: 'node.js',
|
||||
expectedErr: null
|
||||
},
|
||||
{
|
||||
input: 'foo bar',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: '123',
|
||||
expectedErr: errBookNameNumeric
|
||||
},
|
||||
{
|
||||
input: '+123',
|
||||
expectedErr: null
|
||||
},
|
||||
{
|
||||
input: '-123',
|
||||
expectedErr: null
|
||||
},
|
||||
{
|
||||
input: '+javascript',
|
||||
expectedErr: null
|
||||
},
|
||||
{
|
||||
input: '0',
|
||||
expectedErr: errBookNameNumeric
|
||||
},
|
||||
{
|
||||
input: '0333',
|
||||
expectedErr: errBookNameNumeric
|
||||
},
|
||||
{
|
||||
input: ' javascript',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: 'java script',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: 'javascript (1)',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: 'javascript ',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: 'javascript (1) (2) (3)',
|
||||
expectedErr: errBookNameHasSpace
|
||||
},
|
||||
{
|
||||
input: ',',
|
||||
expectedErr: errBookNameHasComma
|
||||
},
|
||||
{
|
||||
input: 'foo,bar',
|
||||
expectedErr: errBookNameHasComma
|
||||
},
|
||||
{
|
||||
input: ',,,',
|
||||
expectedErr: errBookNameHasComma
|
||||
},
|
||||
|
||||
// reserved book names
|
||||
{
|
||||
input: 'trash',
|
||||
expectedErr: errBookNameReserved
|
||||
},
|
||||
{
|
||||
input: 'conflicts',
|
||||
expectedErr: errBookNameReserved
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; ++i) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`validates ${tc.input}`, () => {
|
||||
const base = expect(() => validateBookName(tc.input));
|
||||
|
||||
if (tc.expectedErr) {
|
||||
base.to.throw(tc.expectedErr);
|
||||
} else {
|
||||
base.to.not.throw();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('checkDuplicate', () => {
|
||||
const golangBook = {
|
||||
label: 'golang',
|
||||
uuid: '04a0ead6-a450-44c2-b952-4d8ddsfdc70j',
|
||||
usn: 10,
|
||||
created_at: '2019-08-20T05:13:54.690438Z',
|
||||
updated_at: '2019-08-20T05:13:54.690438Z'
|
||||
};
|
||||
const fooBook = {
|
||||
label: 'foo',
|
||||
uuid: '14a0ead6-a450-44c2-b952-4d8ddsfdc70j',
|
||||
usn: 10,
|
||||
created_at: '2019-08-20T05:13:54.690438Z',
|
||||
updated_at: '2019-08-20T05:13:54.690438Z'
|
||||
};
|
||||
const barBook = {
|
||||
label: 'bar',
|
||||
uuid: '24a0ead6-a450-44c2-b952-4d8ddsfdc70j',
|
||||
usn: 10,
|
||||
created_at: '2019-08-20T05:13:54.690438Z',
|
||||
updated_at: '2019-08-20T05:13:54.690438Z'
|
||||
};
|
||||
const fooBarBook = {
|
||||
label: 'foo_bar',
|
||||
uuid: '34a0ead6-a450-44c2-b952-4d8ddsfdc70j',
|
||||
usn: 10,
|
||||
created_at: '2019-08-20T05:13:54.690438Z',
|
||||
updated_at: '2019-08-20T05:13:54.690438Z'
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
books: [],
|
||||
bookName: 'javascript',
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
books: [golangBook, fooBarBook, fooBook],
|
||||
bookName: 'bar1',
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
books: [golangBook, fooBook, barBook],
|
||||
bookName: 'bar',
|
||||
expected: true
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; ++i) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`checks duplicate for the test case ${i}`, () => {
|
||||
const result = checkDuplicate(tc.books, tc.bookName);
|
||||
expect(result).to.equal(tc.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
95
jslib/src/helpers/filters.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { parseSearchString } from './url';
|
||||
import { Queries } from './queries';
|
||||
|
||||
export interface Filters {
|
||||
queries: Queries;
|
||||
page: number;
|
||||
}
|
||||
|
||||
function compareBookArr(a1: string[], a2: string[]) {
|
||||
if (a1.length !== a2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const a1Sorted = a1.sort();
|
||||
const a2Sorted = a2.sort();
|
||||
|
||||
for (let i = 0; i < a1Sorted.length; ++i) {
|
||||
if (a1Sorted[i] !== a2Sorted[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// getFiltersFromSearchStr unmarshals the given search string from the URL
|
||||
// into an object
|
||||
export function getFiltersFromSearchStr(search: string): Filters {
|
||||
const searchObj = parseSearchString(search);
|
||||
|
||||
let bookVal;
|
||||
if (typeof searchObj.book === 'string') {
|
||||
bookVal = [searchObj.book];
|
||||
} else if (searchObj.book === undefined) {
|
||||
bookVal = [];
|
||||
} else {
|
||||
bookVal = searchObj.book;
|
||||
}
|
||||
|
||||
const ret: Filters = {
|
||||
queries: {
|
||||
q: searchObj.q || '',
|
||||
book: bookVal
|
||||
},
|
||||
page: parseInt(searchObj.page, 10) || 1
|
||||
};
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// checkFilterEqual checks that the two given filters are equal
|
||||
export function checkFilterEqual(a: Filters, b: Filters): boolean {
|
||||
return (
|
||||
a.page === b.page &&
|
||||
a.queries.q === b.queries.q &&
|
||||
compareBookArr(a.queries.book, b.queries.book)
|
||||
);
|
||||
}
|
||||
|
||||
// toSearchObj transforms the filters into a search obj to be marshaled to a URL search string
|
||||
export function toSearchObj(filters: Filters): any {
|
||||
const ret: any = {};
|
||||
|
||||
const { queries } = filters;
|
||||
|
||||
if (filters.page) {
|
||||
ret.page = filters.page;
|
||||
}
|
||||
if (queries.q !== '') {
|
||||
ret.q = queries.q;
|
||||
}
|
||||
if (queries.book.length > 0) {
|
||||
ret.book = queries.book;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
131
jslib/src/helpers/http.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// module https.ts provides an interface to make HTTP requests and receive responses
|
||||
|
||||
class ResponseError extends Error {
|
||||
response: Response;
|
||||
}
|
||||
|
||||
function checkStatus(response: Response): Response | Promise<Response> {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return response.text().then(body => {
|
||||
const error = new ResponseError(body);
|
||||
error.response = response;
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function parseJSON<T>(response: Response): Promise<T> {
|
||||
if (response.headers.get('Content-Type') === 'application/json') {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
function request<T>(path: string, options: RequestInit) {
|
||||
return fetch(path, {
|
||||
...options
|
||||
})
|
||||
.then(checkStatus)
|
||||
.then(res => {
|
||||
return parseJSON<T>(res);
|
||||
});
|
||||
}
|
||||
|
||||
function get<T>(path: string, options = {}): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'GET',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
function post<T>(path: string, data: any, options = {}): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
function patch<T>(path: string, data: any, options = {}) {
|
||||
return request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
function put(path: string, data: any, options = {}) {
|
||||
return request(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
function del(path: string, options = {}) {
|
||||
return request(path, {
|
||||
method: 'DELETE',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export interface HttpClientConfig {
|
||||
pathPrefix: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
// getHttpClient returns an http client
|
||||
export function getHttpClient(c: HttpClientConfig) {
|
||||
function transformPath(path: string): string {
|
||||
let ret = path;
|
||||
|
||||
if (c.pathPrefix !== '') {
|
||||
ret = `${c.pathPrefix}${ret}`;
|
||||
}
|
||||
if (c.baseUrl !== '') {
|
||||
ret = `${c.baseUrl}${ret}`;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T = any>(path: string, options = {}) => {
|
||||
return get<T>(transformPath(path), options);
|
||||
},
|
||||
post: <T>(path: string, data = {}, options = {}) => {
|
||||
return post<T>(transformPath(path), data, options);
|
||||
},
|
||||
patch: <T = any>(path: string, data, options = {}) => {
|
||||
return patch<T>(transformPath(path), data, options);
|
||||
},
|
||||
put: (path: string, data, options = {}) => {
|
||||
return put(transformPath(path), data, options);
|
||||
},
|
||||
del: (path: string, options = {}) => {
|
||||
return del(transformPath(path), options);
|
||||
}
|
||||
};
|
||||
}
|
||||
19
jslib/src/helpers/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as BooksHelpers from './books';
|
||||
import * as FiltersHelpers from './filters';
|
||||
import * as HttpHelpers from './http';
|
||||
import * as ObjHelpers from './obj';
|
||||
import * as QueriesHelpers from './queries';
|
||||
import * as SearchHelpers from './search';
|
||||
import * as SelectHelpers from './select';
|
||||
import * as UrlHelpers from './url';
|
||||
|
||||
export {
|
||||
BooksHelpers,
|
||||
FiltersHelpers,
|
||||
HttpHelpers,
|
||||
ObjHelpers,
|
||||
QueriesHelpers,
|
||||
SearchHelpers,
|
||||
SelectHelpers,
|
||||
UrlHelpers,
|
||||
}
|
||||
|
|
@ -21,3 +21,6 @@ export const KEYCODE_UP = 38;
|
|||
export const KEYCODE_ENTER = 13;
|
||||
export const KEYCODE_ESC = 27;
|
||||
export const KEYCODE_TAB = 9;
|
||||
|
||||
// alphabet
|
||||
export const KEYCODE_LOWERCASE_B = 66;
|
||||
|
|
@ -16,10 +16,13 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
function filterObjKeys(obj, keys, filterFn) {
|
||||
type FilterFn = (val: any, key: any) => boolean;
|
||||
|
||||
export function filterObjKeys(obj: object, filterFn: FilterFn) {
|
||||
return Object.keys(obj)
|
||||
.filter(key => {
|
||||
return filterFn(key);
|
||||
const val = obj[key];
|
||||
return filterFn(val, key);
|
||||
})
|
||||
.reduce((ret, key) => {
|
||||
return {
|
||||
|
|
@ -32,14 +35,14 @@ function filterObjKeys(obj, keys, filterFn) {
|
|||
// whitelist returns a new object whose keys are whitelisted by the given array
|
||||
// of keys
|
||||
export function whitelist(obj, keys) {
|
||||
return filterObjKeys(obj, keys, key => {
|
||||
return filterObjKeys(obj, (val, key) => {
|
||||
return keys.indexOf(key) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
// blacklist returns a new object where key-val pairs are filtered out by keys
|
||||
export function blacklist(obj, keys) {
|
||||
return filterObjKeys(obj, keys, key => {
|
||||
return filterObjKeys(obj, (val, key) => {
|
||||
return keys.indexOf(key) === -1;
|
||||
});
|
||||
}
|
||||
|
|
@ -48,3 +51,17 @@ export function blacklist(obj, keys) {
|
|||
export function isEmptyObj(obj) {
|
||||
return Object.getOwnPropertyNames(obj).length === 0;
|
||||
}
|
||||
|
||||
// removeKey returns a new object with the given key removed
|
||||
export function removeKey(obj: object, deleteKey: string) {
|
||||
const keys = Object.keys(obj).filter(key => key !== deleteKey);
|
||||
|
||||
const ret = {};
|
||||
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
const key = keys[i];
|
||||
ret[key] = obj[key];
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function debounce(func, wait, immediate) {
|
||||
export function debounce(func: Function, wait: number, immediate?: boolean) {
|
||||
let timeout;
|
||||
|
||||
return (...args) => {
|
||||
|
|
@ -31,7 +31,7 @@ export function debounce(func, wait, immediate) {
|
|||
timeout = setTimeout(later, wait);
|
||||
|
||||
if (callNow) {
|
||||
func.apply(context, args);
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
66
jslib/src/helpers/queries.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Location } from 'history';
|
||||
|
||||
import { parseSearchString } from './url';
|
||||
import { removeKey } from './obj';
|
||||
import * as searchLib from './search';
|
||||
|
||||
export interface Queries {
|
||||
q: string;
|
||||
book: string[];
|
||||
}
|
||||
|
||||
function encodeQuery(keyword: string, value: string): string {
|
||||
return `${keyword}:${value}`;
|
||||
}
|
||||
|
||||
export const keywordBook = 'book';
|
||||
|
||||
export const keywords = [keywordBook];
|
||||
|
||||
// parse unmarshals the given string represesntation of the queries into an object
|
||||
export function parse(s: string): Queries {
|
||||
const result = searchLib.parse(s, keywords);
|
||||
|
||||
let bookValue: string[];
|
||||
const { book } = result.filters;
|
||||
if (!book) {
|
||||
bookValue = [];
|
||||
} else if (typeof book === 'string') {
|
||||
bookValue = [book];
|
||||
} else {
|
||||
bookValue = book;
|
||||
}
|
||||
|
||||
let qValue: string;
|
||||
if (result.text) {
|
||||
qValue = result.text;
|
||||
} else {
|
||||
qValue = '';
|
||||
}
|
||||
|
||||
return {
|
||||
q: qValue,
|
||||
book: bookValue
|
||||
};
|
||||
}
|
||||
|
||||
// stringify marshals the givne queries into a string format
|
||||
export function stringify(queries: Queries): string {
|
||||
let ret = '';
|
||||
|
||||
if (queries.book.length > 0) {
|
||||
for (let i = 0; i < queries.book.length; i++) {
|
||||
const book = queries.book[i];
|
||||
|
||||
ret += encodeQuery(keywordBook, book);
|
||||
ret += ' ';
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.q !== '') {
|
||||
const result = searchLib.parse(queries.q, keywords);
|
||||
ret += result.text;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
262
jslib/src/helpers/search.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
export enum TokenKind {
|
||||
colon = 'COLON',
|
||||
id = 'ID',
|
||||
eof = 'EOF'
|
||||
}
|
||||
|
||||
interface Token {
|
||||
kind: TokenKind;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export enum NodeKind {
|
||||
text = 'text',
|
||||
filter = 'filter'
|
||||
}
|
||||
|
||||
interface TextNode {
|
||||
kind: NodeKind.text;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterNode {
|
||||
kind: NodeKind.filter;
|
||||
keyword: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type Node = TextNode | FilterNode;
|
||||
|
||||
function nodeToString(node: Node): string {
|
||||
if (node.kind === NodeKind.text) {
|
||||
return node.value;
|
||||
}
|
||||
if (node.kind === NodeKind.filter) {
|
||||
return `${node.keyword}:${node.value}`;
|
||||
}
|
||||
|
||||
throw new Error('unknown node kind');
|
||||
}
|
||||
|
||||
const whitespaceRegex = /^\s*$/;
|
||||
const charRegex = /^((?![\s:]).)*$/;
|
||||
|
||||
function isSpace(c: string): boolean {
|
||||
return whitespaceRegex.test(c);
|
||||
}
|
||||
|
||||
function isChar(c: string): boolean {
|
||||
return charRegex.test(c);
|
||||
}
|
||||
|
||||
export function tokenize(s: string): Token[] {
|
||||
const ret: Token[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
function colon() {
|
||||
ret.push({ kind: TokenKind.colon });
|
||||
cursor++;
|
||||
}
|
||||
function id() {
|
||||
let text = '';
|
||||
while (cursor <= s.length - 1 && isChar(s[cursor])) {
|
||||
text += s[cursor];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
ret.push({ kind: TokenKind.id, value: text });
|
||||
}
|
||||
|
||||
while (cursor <= s.length - 1) {
|
||||
const currentChar = s[cursor];
|
||||
|
||||
if (isSpace(currentChar)) {
|
||||
cursor++;
|
||||
} else if (currentChar === ':') {
|
||||
colon();
|
||||
} else if (isChar(currentChar)) {
|
||||
id();
|
||||
} else {
|
||||
throw new Error(`invalid character ${currentChar}`);
|
||||
}
|
||||
}
|
||||
|
||||
ret.push({ kind: TokenKind.eof });
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
toks: Token[];
|
||||
cursor: number;
|
||||
currentToken: Token;
|
||||
|
||||
constructor(s: string) {
|
||||
this.toks = tokenize(s);
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
do(): Node[] {
|
||||
/*
|
||||
* expr: term (term)*
|
||||
*
|
||||
* term: filter | text
|
||||
*
|
||||
* filter: text COLON text
|
||||
*
|
||||
* text: ID | EOF
|
||||
*/
|
||||
return this.expr();
|
||||
}
|
||||
|
||||
eat(kind: TokenKind) {
|
||||
const currentToken = this.getCurrentToken();
|
||||
|
||||
if (currentToken.kind !== kind) {
|
||||
throw new Error(
|
||||
`invalid syntax. Expected ${kind} got: ${currentToken.kind}`
|
||||
);
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
getCurrentToken() {
|
||||
return this.toks[this.cursor];
|
||||
}
|
||||
|
||||
expr(): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
while (this.getCurrentToken().kind !== TokenKind.eof) {
|
||||
const n = this.term();
|
||||
nodes.push(n);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
term(): Node | null {
|
||||
// try to parse filter and backtrack if not match
|
||||
const n = this.filter();
|
||||
if (n !== null) {
|
||||
return n;
|
||||
}
|
||||
|
||||
return this.str();
|
||||
}
|
||||
|
||||
filter(): Node | null {
|
||||
// save the current cursor for backtracking
|
||||
const cursor = this.cursor;
|
||||
|
||||
const keyword = this.text({ maybe: true });
|
||||
if (keyword === null) {
|
||||
this.cursor = cursor;
|
||||
return null;
|
||||
}
|
||||
if (this.getCurrentToken().kind === TokenKind.colon) {
|
||||
this.eat(TokenKind.colon);
|
||||
} else {
|
||||
this.cursor = cursor;
|
||||
return null;
|
||||
}
|
||||
const value = this.text({ maybe: true });
|
||||
if (value === null) {
|
||||
this.cursor = cursor;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NodeKind.filter,
|
||||
keyword,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
str(): Node | null {
|
||||
const value = this.text();
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NodeKind.text,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
// text parses and returns a text. If 'maybe' option is true, it returns null
|
||||
// if the current token cannot be parsed as a text (such scenario can happen when
|
||||
// backtracking)
|
||||
text(opts = { maybe: false }): string | null {
|
||||
const currentToken = this.getCurrentToken();
|
||||
|
||||
if (currentToken.kind === TokenKind.eof) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (opts.maybe) {
|
||||
if (currentToken.kind !== TokenKind.id) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
this.eat(TokenKind.id);
|
||||
|
||||
return currentToken.value;
|
||||
}
|
||||
}
|
||||
|
||||
interface Search {
|
||||
text: string;
|
||||
filters: {
|
||||
[key: string]: string | string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function parse(s: string, keywords: string[]): Search {
|
||||
const p = new Parser(s);
|
||||
|
||||
const ret: Search = {
|
||||
text: '',
|
||||
filters: {}
|
||||
};
|
||||
|
||||
function addText(t: string) {
|
||||
if (ret.text !== '') {
|
||||
ret.text += ' ';
|
||||
}
|
||||
ret.text += t;
|
||||
}
|
||||
|
||||
function addFilter(key: string, val: string) {
|
||||
const currentVal = ret.filters[key];
|
||||
|
||||
if (typeof currentVal === 'undefined') {
|
||||
ret.filters[key] = val;
|
||||
} else if (typeof currentVal === 'string') {
|
||||
ret.filters[key] = [currentVal, val];
|
||||
} else {
|
||||
ret.filters[key] = [...currentVal, val];
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = p.do();
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const n = nodes[i];
|
||||
|
||||
if (n.kind === NodeKind.text) {
|
||||
addText(n.value);
|
||||
} else if (n.kind === NodeKind.filter) {
|
||||
// if keyword was not specified, treat as a text
|
||||
if (keywords.indexOf(n.keyword) > -1) {
|
||||
addFilter(n.keyword, n.value);
|
||||
} else {
|
||||
addText(nodeToString(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
438
jslib/src/helpers/search_test.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { expect } from 'chai';
|
||||
import { TokenKind, tokenize, parse } from './search';
|
||||
|
||||
describe('search.ts', () => {
|
||||
describe('tokenize', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'foo'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: '123',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: '123'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: 'foo123',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'foo123'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: 'foo\tbar',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'foo'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: ' foo \tbar\t',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'foo'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: 'foo:bar',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'foo'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.colon
|
||||
},
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: '"foo" bar',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: '"foo"'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: '"foo:bar"',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: '"foo'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.colon
|
||||
},
|
||||
{
|
||||
kind: TokenKind.id,
|
||||
value: 'bar"'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eof
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`tokenizes ${tc.input}`, () => {
|
||||
const result = tokenize(tc.input);
|
||||
|
||||
expect(result).to.eql(tc.tokens);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
function run(testCases) {
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`keyword [${tc.keywords}] - parses ${tc.input} `, () => {
|
||||
const result = parse(tc.input, tc.keywords);
|
||||
|
||||
expect(result).to.eql(tc.result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('text only', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '123',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: '123',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo123',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo123',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '"',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: '"',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '""',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: '""',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: `'`,
|
||||
keywords: [],
|
||||
result: {
|
||||
text: `'`,
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: `''`,
|
||||
keywords: [],
|
||||
result: {
|
||||
text: `''`,
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: `'foo:bar'`,
|
||||
keywords: [],
|
||||
result: {
|
||||
text: `'foo:bar'`,
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo bar',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo bar',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: ' foo \t bar ',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo bar',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '"foo:bar"',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: '"foo:bar"',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '"foo:bar""',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: '"foo:bar""',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '"foo:bar""""',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: '"foo:bar""""',
|
||||
filters: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
run(testCases);
|
||||
});
|
||||
|
||||
describe('filter only', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo:bar',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '123:bar',
|
||||
keywords: ['123'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
'123': 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo123:bar',
|
||||
keywords: ['foo123'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
foo123: 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: '123:456',
|
||||
keywords: ['123'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
'123': '456'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz:quz 123:qux',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: 'baz:quz 123:qux',
|
||||
filters: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz:quz',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: 'baz:quz',
|
||||
filters: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz:quz',
|
||||
keywords: ['bar'],
|
||||
result: {
|
||||
text: 'foo:bar baz:quz',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz:quz',
|
||||
keywords: ['foo', 'baz'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
foo: 'bar',
|
||||
baz: 'quz'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo:bar',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz:quz',
|
||||
keywords: [],
|
||||
result: {
|
||||
text: 'foo:bar baz:quz',
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar foo:baz',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: '',
|
||||
filters: {
|
||||
foo: ['bar', 'baz']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
run(testCases);
|
||||
});
|
||||
|
||||
describe('text and filter', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo:bar baz',
|
||||
keywords: ['foo'],
|
||||
result: {
|
||||
text: 'baz',
|
||||
filters: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz quz:qux1 ',
|
||||
keywords: ['foo', 'quz'],
|
||||
result: {
|
||||
text: 'baz',
|
||||
filters: {
|
||||
foo: 'bar',
|
||||
quz: 'qux1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz quz:qux1 qux',
|
||||
keywords: ['foo', 'quz'],
|
||||
result: {
|
||||
text: 'baz qux',
|
||||
filters: {
|
||||
foo: 'bar',
|
||||
quz: 'qux1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz quz:qux1 qux "quux:fooz"',
|
||||
keywords: ['foo', 'quz'],
|
||||
result: {
|
||||
text: 'baz qux "quux:fooz"',
|
||||
filters: {
|
||||
foo: 'bar',
|
||||
quz: 'qux1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
input: 'foo:bar baz quz:qux1 qux "quux:fooz"',
|
||||
keywords: ['foo', 'quux'],
|
||||
result: {
|
||||
text: 'baz quz:qux1 qux "quux:fooz"',
|
||||
filters: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
run(testCases);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
jslib/src/helpers/select.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { BookData } from '../operations/books';
|
||||
|
||||
// Option represents an option in a selection list
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// optionValueCreate is the value of the option for creating a new option
|
||||
export const optionValueCreate = 'create-new-option';
|
||||
|
||||
// filterOptions returns a new array of options based on the given filter criteria
|
||||
export function filterOptions(
|
||||
options: Option[],
|
||||
term: string,
|
||||
creatable: boolean
|
||||
): Option[] {
|
||||
if (!term) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const ret = [];
|
||||
const searchReg = new RegExp(`${term}`, 'i');
|
||||
let hit = null;
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
|
||||
if (option.label === term) {
|
||||
hit = option;
|
||||
} else if (searchReg.test(option.label) && option.value !== '') {
|
||||
ret.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
// if there is an exact match, display the option at the top
|
||||
// otherwise, display a creatable option at the bottom
|
||||
if (hit) {
|
||||
ret.unshift(hit);
|
||||
} else if (creatable) {
|
||||
// creatable option has a value of an empty string
|
||||
ret.push({ label: term, value: '' });
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// booksToOptions returns an array of options for select ui, given an array of books
|
||||
export function booksToOptions(books: BookData[]): Option[] {
|
||||
const ret = [];
|
||||
|
||||
for (let i = 0; i < books.length; ++i) {
|
||||
const book = books[i];
|
||||
|
||||
ret.push({
|
||||
label: book.label,
|
||||
value: book.uuid
|
||||
});
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
|
@ -19,10 +19,11 @@
|
|||
import qs from 'qs';
|
||||
import isArray from 'lodash/isArray';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import { Location } from 'history';
|
||||
|
||||
// getPath returns a path optionally suffixed by query string
|
||||
export function getPath(path, queryObj) {
|
||||
const queryStr = qs.stringify(queryObj);
|
||||
export function getPath(path, queryObj): string {
|
||||
const queryStr = qs.stringify(queryObj, { arrayFormat: 'repeat' });
|
||||
|
||||
if (!queryStr) {
|
||||
return path;
|
||||
|
|
@ -33,42 +34,35 @@ export function getPath(path, queryObj) {
|
|||
|
||||
// getPathFromLocation returns a full path based on the location object used by
|
||||
// React Router
|
||||
export function getPathFromLocation(location) {
|
||||
export function getPathFromLocation(location): string {
|
||||
const { pathname, search } = location;
|
||||
|
||||
return `${pathname}${search}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseSearchString parses the 'search' string in `location` object provided
|
||||
* by React Router.
|
||||
*
|
||||
* @param searchStr {String} - in a form of "?foo=bar&baz=1"
|
||||
* @return {Object} - in a form of "{foo: "bar", baz: "1"}"
|
||||
*/
|
||||
export function parseSearchString(searchStr) {
|
||||
if (!searchStr || searchStr === '') {
|
||||
// parseSearchString parses the 'search' string in `location` object provided
|
||||
// by React Router.
|
||||
export function parseSearchString(search: string): any {
|
||||
if (!search || search === '') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// drop the leading '?'
|
||||
const queryStr = searchStr.substring(1);
|
||||
const queryStr = search.substring(1);
|
||||
return qs.parse(queryStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* addQueryToLocation returns a new location object for react-router given the
|
||||
* new `queryKey` and `val` to be set in loation.query.
|
||||
* If there exists the given key in the query object, addQueryToLocation sets its
|
||||
* value to be an array containing the old value and the new value.
|
||||
* Otherwise the value for the key is set to the `val`.
|
||||
*
|
||||
* @param location {Object} - location object from react-router
|
||||
* @param queryKey {String} - the new query key to be set in location.query
|
||||
* @param val {String} - the value corresponding to queryKey
|
||||
* @param override {Boolean} - whether to override any existing param
|
||||
*/
|
||||
export function addQueryToLocation(location, queryKey, val, override = true) {
|
||||
// addQueryToLocation returns a new location object for react-router given the
|
||||
// new `queryKey` and `val` to be set in loation.query.
|
||||
// If there exists the given key in the query object, addQueryToLocation sets its
|
||||
// value to be an array containing the old value and the new value.
|
||||
// Otherwise the value for the key is set to the `val`.
|
||||
export function addQueryToLocation(
|
||||
location: Location,
|
||||
queryKey: string,
|
||||
val: string,
|
||||
override = true
|
||||
): Location {
|
||||
const queryObj = parseSearchString(location.search);
|
||||
const existingParam = queryObj[queryKey];
|
||||
|
||||
|
|
@ -94,11 +88,13 @@ export function addQueryToLocation(location, queryKey, val, override = true) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* removeQueryFromLocation returns a new location object without the queryKey
|
||||
* and val
|
||||
*/
|
||||
export function removeQueryFromLocation(location, queryKey, val) {
|
||||
// removeQueryFromLocation returns a new location object without the queryKey
|
||||
// and val
|
||||
export function removeQueryFromLocation(
|
||||
location: Location,
|
||||
queryKey: string,
|
||||
val?: string
|
||||
): Location {
|
||||
const queryObj = parseSearchString(location.search);
|
||||
const existingParam = queryObj[queryKey];
|
||||
if (!existingParam) {
|
||||
|
|
@ -131,7 +127,7 @@ export function removeQueryFromLocation(location, queryKey, val) {
|
|||
};
|
||||
}
|
||||
|
||||
export function getReferrer(location) {
|
||||
export function getReferrer(location: Location): string {
|
||||
const queryObj = parseSearchString(location.search);
|
||||
const { referrer } = queryObj;
|
||||
|
||||
7
jslib/src/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import * as Helpers from './helpers';
|
||||
import * as Operations from './helpers';
|
||||
|
||||
export {
|
||||
Helpers,
|
||||
Operations
|
||||
};
|
||||
59
jslib/src/operations/books.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import initBooksService from '../services/books';
|
||||
import { HttpClientConfig } from '../helpers/http';
|
||||
|
||||
export type BookData = {
|
||||
uuid: string;
|
||||
usn: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export interface CreateParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function init(c: HttpClientConfig) {
|
||||
const booksService = initBooksService(c);
|
||||
|
||||
return {
|
||||
get: (bookUUID: string) => {
|
||||
return booksService.get(bookUUID);
|
||||
},
|
||||
|
||||
// create creates an encrypted book. It returns a promise that resolves with
|
||||
// a decrypted book.
|
||||
create: (payload: CreateParams): Promise<BookData> => {
|
||||
return booksService.create(payload).then(res => {
|
||||
return res.book;
|
||||
});
|
||||
},
|
||||
|
||||
fetch: (params = {}) => {
|
||||
return booksService.fetch(params);
|
||||
},
|
||||
|
||||
// remove deletes the book with the given uuid
|
||||
remove: (bookUUID: string) => {
|
||||
return booksService.remove(bookUUID);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
jslib/src/operations/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { HttpClientConfig } from '../helpers/http';
|
||||
import initBooksOperation from './books';
|
||||
import initNotesOperation from './notes';
|
||||
|
||||
// init initializes operations with the given http configuration
|
||||
// and returns an object of all services.
|
||||
export default function initOperations(c: HttpClientConfig) {
|
||||
const booksOperation = initBooksOperation(c);
|
||||
const notesOperation = initNotesOperation(c);
|
||||
|
||||
return {
|
||||
books: booksOperation,
|
||||
notes: notesOperation
|
||||
};
|
||||
}
|
||||
68
jslib/src/operations/notes.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// This module provides interfaces to perform operations. It abstarcts
|
||||
// the backend implementation and thus unifies the API for web and desktop clients.
|
||||
|
||||
import initNotesService from '../services/notes';
|
||||
import { HttpClientConfig } from '../helpers/http';
|
||||
import { NoteData } from './types';
|
||||
import { Filters } from '../helpers/filters';
|
||||
|
||||
export interface FetchOneParams {
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface CreateParams {
|
||||
bookUUID: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface UpdateParams {
|
||||
book_uuid?: string;
|
||||
content?: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export default function init(c: HttpClientConfig) {
|
||||
const notesService = initNotesService(c);
|
||||
|
||||
return {
|
||||
fetch: (params: Filters) => {
|
||||
return notesService.fetch(params);
|
||||
},
|
||||
|
||||
fetchOne: (noteUUID: string, params: FetchOneParams = {}) => {
|
||||
return notesService.fetchOne(noteUUID, params);
|
||||
},
|
||||
|
||||
create: ({ bookUUID, content }: CreateParams) => {
|
||||
return notesService.create({ book_uuid: bookUUID, content });
|
||||
},
|
||||
|
||||
update: (noteUUID: string, input: UpdateParams): Promise<NoteData> => {
|
||||
return notesService.update(noteUUID, input).then(res => {
|
||||
return res.result;
|
||||
});
|
||||
},
|
||||
|
||||
remove: noteUUID => {
|
||||
return notesService.remove(noteUUID);
|
||||
}
|
||||
};
|
||||
}
|
||||
31
jslib/src/operations/types.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// NoteData represents a data for a note as returned by services.
|
||||
// The response from services need to conform to this interface.
|
||||
export interface NoteData {
|
||||
uuid: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
content: string;
|
||||
added_on: number;
|
||||
public: boolean;
|
||||
usn: number;
|
||||
book: {
|
||||
uuid: string;
|
||||
label: string;
|
||||
};
|
||||
user: {
|
||||
name: string;
|
||||
uuid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailPrefData {
|
||||
digestWeekly: boolean;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
uuid: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
pro: boolean;
|
||||
classic: boolean;
|
||||
}
|
||||
81
jslib/src/services/books.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import qs from 'qs';
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
|
||||
export interface BookFetchParams {
|
||||
name?: string;
|
||||
encrypted?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreatePayload {
|
||||
book: {
|
||||
uuid: string;
|
||||
usn: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: type
|
||||
type updateParams = any;
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
fetch: (queryObj: BookFetchParams = {}, opts = {}) => {
|
||||
const baseURL = '/v3/books';
|
||||
|
||||
const queryStr = qs.stringify(queryObj);
|
||||
|
||||
let endpoint;
|
||||
if (queryStr) {
|
||||
endpoint = `${baseURL}?${queryStr}`;
|
||||
} else {
|
||||
endpoint = baseURL;
|
||||
}
|
||||
|
||||
return client.get(endpoint, opts);
|
||||
},
|
||||
|
||||
create: (payload: CreateParams, opts = {}) => {
|
||||
return client.post<CreatePayload>('/v3/books', payload, opts);
|
||||
},
|
||||
|
||||
remove: (uuid: string) => {
|
||||
return client.del(`/v3/books/${uuid}`);
|
||||
},
|
||||
|
||||
update: (uuid: string, payload: updateParams) => {
|
||||
return client.patch(`/v3/books/${uuid}`, payload);
|
||||
},
|
||||
|
||||
get: (bookUUID: string) => {
|
||||
const endpoint = `/v3/books/${bookUUID}`;
|
||||
|
||||
return client.get(endpoint);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -16,34 +16,35 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
import { getPath } from '../helpers/url';
|
||||
|
||||
import AccountMenu from './AccountMenu';
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
import styles from './Footer.module.scss';
|
||||
|
||||
const Footer = ({ user, demo }) => {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<ul className={classnames('list-unstyled', styles['action-list'])}>
|
||||
<li>
|
||||
<AccountMenu
|
||||
tirggerClassName={styles.action}
|
||||
user={user}
|
||||
demo={demo}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
user: state.auth.user.data
|
||||
fetch: (digestUUID, { demo }) => {
|
||||
let endpoint;
|
||||
if (demo) {
|
||||
endpoint = `/demo/digests/${digestUUID}`;
|
||||
} else {
|
||||
endpoint = `/digests/${digestUUID}`;
|
||||
}
|
||||
|
||||
return client.get(endpoint);
|
||||
},
|
||||
|
||||
fetchAll: ({ page, demo }) => {
|
||||
let path;
|
||||
if (demo) {
|
||||
path = `/demo/digests`;
|
||||
} else {
|
||||
path = '/digests';
|
||||
}
|
||||
|
||||
const endpoint = getPath(path, { page });
|
||||
|
||||
return client.get(endpoint);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Footer);
|
||||
24
jslib/src/services/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { HttpClientConfig } from '../helpers/http';
|
||||
import initUsersService from './users';
|
||||
import initBooksService from './books';
|
||||
import initNotesService from './notes';
|
||||
import initDigestsService from './digests';
|
||||
import initPaymentService from './payment';
|
||||
|
||||
// init initializes service helpers with the given http configuration
|
||||
// and returns an object of all services.
|
||||
export default function initServices(c: HttpClientConfig) {
|
||||
const usersService = initUsersService(c);
|
||||
const booksService = initBooksService(c);
|
||||
const notesService = initNotesService(c);
|
||||
const digestsService = initDigestsService(c);
|
||||
const paymentService = initPaymentService(c);
|
||||
|
||||
return {
|
||||
users: usersService,
|
||||
books: booksService,
|
||||
notes: notesService,
|
||||
digests: digestsService,
|
||||
payment: paymentService
|
||||
};
|
||||
}
|
||||
108
jslib/src/services/notes.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getPath } from '../helpers/url';
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
import { NoteData } from '../operations/types';
|
||||
import { Filters } from '../helpers/filters';
|
||||
|
||||
export interface CreateParams {
|
||||
book_uuid: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface CreateResponse {
|
||||
result: NoteData;
|
||||
}
|
||||
|
||||
export interface UpdateParams {
|
||||
book_uuid?: string;
|
||||
content?: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateNoteResp {
|
||||
status: number;
|
||||
result: NoteData;
|
||||
}
|
||||
|
||||
export interface FetchResponse {
|
||||
notes: NoteData[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FetchOneQuery {
|
||||
q?: string;
|
||||
}
|
||||
|
||||
type FetchOneResponse = NoteData;
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
create: (params: CreateParams, opts = {}): Promise<CreateResponse> => {
|
||||
return client.post<CreateResponse>('/v3/notes', params, opts);
|
||||
},
|
||||
|
||||
update: (noteUUID: string, params: UpdateParams) => {
|
||||
const endpoint = `/v3/notes/${noteUUID}`;
|
||||
|
||||
return client.patch<UpdateNoteResp>(endpoint, params);
|
||||
},
|
||||
|
||||
remove: (noteUUID: string) => {
|
||||
const endpoint = `/v3/notes/${noteUUID}`;
|
||||
|
||||
return client.del(endpoint, {});
|
||||
},
|
||||
|
||||
fetch: (filters: Filters) => {
|
||||
const params: any = {
|
||||
page: filters.page
|
||||
};
|
||||
|
||||
const { queries } = filters;
|
||||
if (queries.q) {
|
||||
params.q = queries.q;
|
||||
}
|
||||
if (queries.book) {
|
||||
params.book = queries.book;
|
||||
}
|
||||
|
||||
const endpoint = getPath('/notes', params);
|
||||
|
||||
return client.get<FetchResponse>(endpoint, {});
|
||||
},
|
||||
|
||||
fetchOne: (
|
||||
noteUUID: string,
|
||||
params: FetchOneQuery
|
||||
): Promise<FetchOneResponse> => {
|
||||
const endpoint = getPath(`/notes/${noteUUID}`, params);
|
||||
|
||||
return client.get<FetchOneResponse>(endpoint, {});
|
||||
},
|
||||
|
||||
classicFetch: () => {
|
||||
const endpoint = '/classic/notes';
|
||||
|
||||
return client.get(endpoint, { credentials: 'include' });
|
||||
}
|
||||
};
|
||||
}
|
||||
69
jslib/src/services/payment.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
createSubscription: ({ source, country }) => {
|
||||
const payload = {
|
||||
source,
|
||||
country
|
||||
};
|
||||
|
||||
return client.post('/subscriptions', payload);
|
||||
},
|
||||
|
||||
getSubscription: () => {
|
||||
return client.get('/subscriptions');
|
||||
},
|
||||
|
||||
cancelSubscription: ({ subscriptionId }) => {
|
||||
const data = {
|
||||
op: 'cancel',
|
||||
stripe_subscription_id: subscriptionId
|
||||
};
|
||||
|
||||
return client.patch('/subscriptions', data);
|
||||
},
|
||||
|
||||
reactivateSubscription: ({ subscriptionId }) => {
|
||||
const data = {
|
||||
op: 'reactivate',
|
||||
stripe_subscription_id: subscriptionId
|
||||
};
|
||||
|
||||
return client.patch('/subscriptions', data);
|
||||
},
|
||||
|
||||
getSource: () => {
|
||||
return client.get('/stripe_source');
|
||||
},
|
||||
|
||||
updateSource: ({ source, country }) => {
|
||||
const payload = {
|
||||
source,
|
||||
country
|
||||
};
|
||||
|
||||
return client.patch('/stripe_source', payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
4
jslib/src/services/types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Config is the configuration for the services
|
||||
export interface Config {
|
||||
baseUrl: string;
|
||||
}
|
||||
216
jslib/src/services/users.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
import { EmailPrefData, UserData } from '../operations/types';
|
||||
|
||||
export interface UpdateProfileParams {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UpdatePasswordParams {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface RegisterParams {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SigninParams {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SigninResponse {
|
||||
key: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface GetEmailPreferenceParams {
|
||||
// if not logged in, users can optionally make an authenticated request using a token
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface classicPresigninPayload {
|
||||
key: string;
|
||||
expiresAt: number;
|
||||
cipherKeyEnc: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordParams {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface GetMeResponse {
|
||||
user: {
|
||||
uuid: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
pro: boolean;
|
||||
classic: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface classicSetPasswordPayload {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
updateUser: ({ name }) => {
|
||||
const payload = { name };
|
||||
|
||||
return client.patch('/account/profile', payload);
|
||||
},
|
||||
|
||||
updateProfile: ({ email }: UpdateProfileParams) => {
|
||||
const payload = {
|
||||
email
|
||||
};
|
||||
|
||||
return client.patch('/account/profile', payload);
|
||||
},
|
||||
|
||||
updatePassword: ({ oldPassword, newPassword }: UpdatePasswordParams) => {
|
||||
const payload = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
};
|
||||
|
||||
return client.patch('/account/password', payload);
|
||||
},
|
||||
|
||||
register: (params: RegisterParams) => {
|
||||
const payload = {
|
||||
email: params.email,
|
||||
password: params.password
|
||||
};
|
||||
|
||||
return client.post('/v3/register', payload);
|
||||
},
|
||||
|
||||
signin: (params: SigninParams) => {
|
||||
const payload = {
|
||||
email: params.email,
|
||||
password: params.password
|
||||
};
|
||||
|
||||
return client.post<SigninResponse>('/v3/signin', payload).then(resp => {
|
||||
return {
|
||||
key: resp.key,
|
||||
expiresAt: resp.expires_at
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
signout: () => {
|
||||
return client.post('/v3/signout');
|
||||
},
|
||||
|
||||
sendResetPasswordEmail: ({ email }) => {
|
||||
const payload = { email };
|
||||
|
||||
return client.post('/reset-token', payload);
|
||||
},
|
||||
|
||||
sendEmailVerificationEmail: () => {
|
||||
return client.post('/verification-token');
|
||||
},
|
||||
|
||||
verifyEmail: ({ token }) => {
|
||||
const payload = { token };
|
||||
|
||||
return client.patch('/verify-email', payload);
|
||||
},
|
||||
|
||||
updateEmailPreference: ({ token, digestFrequency }) => {
|
||||
const payload = { digest_weekly: digestFrequency === 'weekly' };
|
||||
|
||||
let endpoint = '/account/email-preference';
|
||||
if (token) {
|
||||
endpoint = `${endpoint}?token=${token}`;
|
||||
}
|
||||
return client.patch(endpoint, payload);
|
||||
},
|
||||
|
||||
getEmailPreference: ({
|
||||
token
|
||||
}: GetEmailPreferenceParams): Promise<EmailPrefData> => {
|
||||
let endpoint = '/account/email-preference';
|
||||
if (token) {
|
||||
endpoint = `${endpoint}?token=${token}`;
|
||||
}
|
||||
|
||||
return client.get<EmailPrefData>(endpoint);
|
||||
},
|
||||
|
||||
getMe: (): Promise<UserData> => {
|
||||
return client.get<GetMeResponse>('/me').then(res => {
|
||||
const { user } = res;
|
||||
|
||||
return {
|
||||
uuid: user.uuid,
|
||||
email: user.email,
|
||||
emailVerified: user.email_verified,
|
||||
pro: user.pro,
|
||||
classic: user.classic
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetPassword: ({ token, password }: ResetPasswordParams) => {
|
||||
const payload = { token, password };
|
||||
|
||||
return client.patch('/reset-password', payload);
|
||||
},
|
||||
|
||||
// classic
|
||||
classicPresignin: ({ email }) => {
|
||||
return client.get(`/classic/presignin?email=${email}`);
|
||||
},
|
||||
|
||||
classicSignin: ({ email, authKey }): Promise<classicPresigninPayload> => {
|
||||
const payload = { email, auth_key: authKey };
|
||||
|
||||
return client.post<any>('/classic/signin', payload).then(resp => {
|
||||
return {
|
||||
key: resp.key,
|
||||
expiresAt: resp.expires_at,
|
||||
cipherKeyEnc: resp.cipher_key_enc
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
classicSetPassword: ({ password }: classicSetPasswordPayload) => {
|
||||
const payload = {
|
||||
password
|
||||
};
|
||||
|
||||
return client.patch<any>('/classic/set-password', payload);
|
||||
},
|
||||
|
||||
classicCompleteMigrate: () => {
|
||||
return client.patch('/classic/migrate', '');
|
||||
}
|
||||
};
|
||||
}
|
||||
14
jslib/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": false,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es6",
|
||||
"jsx": "react",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist/types"
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/cli/context"
|
||||
"github.com/dnote/dnote/pkg/cli/crypt"
|
||||
"github.com/dnote/dnote/pkg/cli/log"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
|
@ -126,7 +125,7 @@ type GetSyncStateResp struct {
|
|||
func GetSyncState(ctx context.DnoteCtx) (GetSyncStateResp, error) {
|
||||
var ret GetSyncStateResp
|
||||
|
||||
res, err := doAuthorizedReq(ctx, "GET", "/v1/sync/state", "", nil)
|
||||
res, err := doAuthorizedReq(ctx, "GET", "/v3/sync/state", "", nil)
|
||||
if err != nil {
|
||||
return ret, errors.Wrap(err, "constructing http request")
|
||||
}
|
||||
|
|
@ -192,7 +191,7 @@ func GetSyncFragment(ctx context.DnoteCtx, afterUSN int) (GetSyncFragmentResp, e
|
|||
v.Set("after_usn", strconv.Itoa(afterUSN))
|
||||
queryStr := v.Encode()
|
||||
|
||||
path := fmt.Sprintf("/v1/sync/fragment?%s", queryStr)
|
||||
path := fmt.Sprintf("/v3/sync/fragment?%s", queryStr)
|
||||
res, err := doAuthorizedReq(ctx, "GET", path, "", nil)
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
|
|
@ -230,20 +229,15 @@ type CreateBookResp struct {
|
|||
|
||||
// CreateBook creates a new book in the server
|
||||
func CreateBook(ctx context.DnoteCtx, label string) (CreateBookResp, error) {
|
||||
encLabel, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(label))
|
||||
if err != nil {
|
||||
return CreateBookResp{}, errors.Wrap(err, "encrypting the label")
|
||||
}
|
||||
|
||||
payload := CreateBookPayload{
|
||||
Name: encLabel,
|
||||
Name: label,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return CreateBookResp{}, errors.Wrap(err, "marshaling payload")
|
||||
}
|
||||
|
||||
res, err := doAuthorizedReq(ctx, "POST", "/v2/books", string(b), nil)
|
||||
res, err := doAuthorizedReq(ctx, "POST", "/v3/books", string(b), nil)
|
||||
if err != nil {
|
||||
return CreateBookResp{}, errors.Wrap(err, "posting a book to the server")
|
||||
}
|
||||
|
|
@ -267,20 +261,15 @@ type UpdateBookResp struct {
|
|||
|
||||
// UpdateBook updates a book in the server
|
||||
func UpdateBook(ctx context.DnoteCtx, label, uuid string) (UpdateBookResp, error) {
|
||||
encName, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(label))
|
||||
if err != nil {
|
||||
return UpdateBookResp{}, errors.Wrap(err, "encrypting the content")
|
||||
}
|
||||
|
||||
payload := updateBookPayload{
|
||||
Name: &encName,
|
||||
Name: &label,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return UpdateBookResp{}, errors.Wrap(err, "marshaling payload")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/v1/books/%s", uuid)
|
||||
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
|
||||
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
|
||||
if err != nil {
|
||||
return UpdateBookResp{}, errors.Wrap(err, "posting a book to the server")
|
||||
|
|
@ -302,7 +291,7 @@ type DeleteBookResp struct {
|
|||
|
||||
// DeleteBook deletes a book in the server
|
||||
func DeleteBook(ctx context.DnoteCtx, uuid string) (DeleteBookResp, error) {
|
||||
endpoint := fmt.Sprintf("/v1/books/%s", uuid)
|
||||
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
|
||||
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
|
||||
if err != nil {
|
||||
return DeleteBookResp{}, errors.Wrap(err, "deleting a book in the server")
|
||||
|
|
@ -351,21 +340,16 @@ type RespNote struct {
|
|||
|
||||
// CreateNote creates a note in the server
|
||||
func CreateNote(ctx context.DnoteCtx, bookUUID, content string) (CreateNoteResp, error) {
|
||||
encBody, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(content))
|
||||
if err != nil {
|
||||
return CreateNoteResp{}, errors.Wrap(err, "encrypting the content")
|
||||
}
|
||||
|
||||
payload := CreateNotePayload{
|
||||
BookUUID: bookUUID,
|
||||
Body: encBody,
|
||||
Body: content,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return CreateNoteResp{}, errors.Wrap(err, "marshaling payload")
|
||||
}
|
||||
|
||||
res, err := doAuthorizedReq(ctx, "POST", "/v2/notes", string(b), nil)
|
||||
res, err := doAuthorizedReq(ctx, "POST", "/v3/notes", string(b), nil)
|
||||
if err != nil {
|
||||
return CreateNoteResp{}, errors.Wrap(err, "posting a book to the server")
|
||||
}
|
||||
|
|
@ -392,14 +376,9 @@ type UpdateNoteResp struct {
|
|||
|
||||
// UpdateNote updates a note in the server
|
||||
func UpdateNote(ctx context.DnoteCtx, uuid, bookUUID, content string, public bool) (UpdateNoteResp, error) {
|
||||
encBody, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(content))
|
||||
if err != nil {
|
||||
return UpdateNoteResp{}, errors.Wrap(err, "encrypting the content")
|
||||
}
|
||||
|
||||
payload := updateNotePayload{
|
||||
BookUUID: &bookUUID,
|
||||
Body: &encBody,
|
||||
Body: &content,
|
||||
Public: &public,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
|
|
@ -407,7 +386,7 @@ func UpdateNote(ctx context.DnoteCtx, uuid, bookUUID, content string, public boo
|
|||
return UpdateNoteResp{}, errors.Wrap(err, "marshaling payload")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/v1/notes/%s", uuid)
|
||||
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
|
||||
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
|
||||
if err != nil {
|
||||
return UpdateNoteResp{}, errors.Wrap(err, "patching a note to the server")
|
||||
|
|
@ -429,7 +408,7 @@ type DeleteNoteResp struct {
|
|||
|
||||
// DeleteNote removes a note in the server
|
||||
func DeleteNote(ctx context.DnoteCtx, uuid string) (DeleteNoteResp, error) {
|
||||
endpoint := fmt.Sprintf("/v1/notes/%s", uuid)
|
||||
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
|
||||
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
|
||||
if err != nil {
|
||||
return DeleteNoteResp{}, errors.Wrap(err, "patching a note to the server")
|
||||
|
|
@ -451,7 +430,7 @@ type GetBooksResp []struct {
|
|||
|
||||
// GetBooks gets books from the server
|
||||
func GetBooks(ctx context.DnoteCtx, sessionKey string) (GetBooksResp, error) {
|
||||
res, err := doAuthorizedReq(ctx, "GET", "/v1/books", "", nil)
|
||||
res, err := doAuthorizedReq(ctx, "GET", "/v3/books", "", nil)
|
||||
if err != nil {
|
||||
return GetBooksResp{}, errors.Wrap(err, "making http request")
|
||||
}
|
||||
|
|
@ -464,14 +443,14 @@ func GetBooks(ctx context.DnoteCtx, sessionKey string) (GetBooksResp, error) {
|
|||
return resp, nil
|
||||
}
|
||||
|
||||
// PresigninResponse is a reponse from /v1/presignin endpoint
|
||||
// PresigninResponse is a reponse from /v3/presignin endpoint
|
||||
type PresigninResponse struct {
|
||||
Iteration int `json:"iteration"`
|
||||
}
|
||||
|
||||
// GetPresignin gets presignin credentials
|
||||
func GetPresignin(ctx context.DnoteCtx, email string) (PresigninResponse, error) {
|
||||
res, err := doReq(ctx, "GET", fmt.Sprintf("/v1/presignin?email=%s", email), "", nil)
|
||||
res, err := doReq(ctx, "GET", fmt.Sprintf("/v3/presignin?email=%s", email), "", nil)
|
||||
if err != nil {
|
||||
return PresigninResponse{}, errors.Wrap(err, "making http request")
|
||||
}
|
||||
|
|
@ -484,30 +463,29 @@ func GetPresignin(ctx context.DnoteCtx, email string) (PresigninResponse, error)
|
|||
return resp, nil
|
||||
}
|
||||
|
||||
// SigninPayload is a payload for /v1/signin
|
||||
// SigninPayload is a payload for /v3/signin
|
||||
type SigninPayload struct {
|
||||
Email string `json:"email"`
|
||||
AuthKey string `json:"auth_key"`
|
||||
Email string `json:"email"`
|
||||
Passowrd string `json:"password"`
|
||||
}
|
||||
|
||||
// SigninResponse is a response from /v1/signin endpoint
|
||||
// SigninResponse is a response from /v3/signin endpoint
|
||||
type SigninResponse struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
CipherKeyEnc string `json:"cipher_key_enc"`
|
||||
Key string `json:"key"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Signin requests a session token
|
||||
func Signin(ctx context.DnoteCtx, email, authKey string) (SigninResponse, error) {
|
||||
func Signin(ctx context.DnoteCtx, email, password string) (SigninResponse, error) {
|
||||
payload := SigninPayload{
|
||||
Email: email,
|
||||
AuthKey: authKey,
|
||||
Email: email,
|
||||
Passowrd: password,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return SigninResponse{}, errors.Wrap(err, "marshaling payload")
|
||||
}
|
||||
res, err := doReq(ctx, "POST", "/v1/signin", string(b), nil)
|
||||
res, err := doReq(ctx, "POST", "/v3/signin", string(b), nil)
|
||||
if err != nil {
|
||||
return SigninResponse{}, errors.Wrap(err, "making http request")
|
||||
}
|
||||
|
|
@ -536,7 +514,7 @@ func Signout(ctx context.DnoteCtx, sessionKey string) error {
|
|||
opts := requestOptions{
|
||||
HTTPClient: &hc,
|
||||
}
|
||||
_, err := doAuthorizedReq(ctx, "POST", "/v1/signout", "", &opts)
|
||||
_, err := doAuthorizedReq(ctx, "POST", "/v3/signout", "", &opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "making http request")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,11 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
|
||||
"github.com/dnote/dnote/pkg/cli/client"
|
||||
"github.com/dnote/dnote/pkg/cli/consts"
|
||||
"github.com/dnote/dnote/pkg/cli/context"
|
||||
"github.com/dnote/dnote/pkg/cli/crypt"
|
||||
"github.com/dnote/dnote/pkg/cli/database"
|
||||
"github.com/dnote/dnote/pkg/cli/infra"
|
||||
"github.com/dnote/dnote/pkg/cli/log"
|
||||
|
|
@ -51,38 +49,17 @@ func NewCmd(ctx context.DnoteCtx) *cobra.Command {
|
|||
|
||||
// Do dervies credentials on the client side and requests a session token from the server
|
||||
func Do(ctx context.DnoteCtx, email, password string) error {
|
||||
presigninResp, err := client.GetPresignin(ctx, email)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting presiginin")
|
||||
}
|
||||
|
||||
masterKey, authKey, err := crypt.MakeKeys([]byte(password), []byte(email), presigninResp.Iteration)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "making keys")
|
||||
}
|
||||
|
||||
authKeyB64 := base64.StdEncoding.EncodeToString(authKey)
|
||||
signinResp, err := client.Signin(ctx, email, authKeyB64)
|
||||
signinResp, err := client.Signin(ctx, email, password)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "requesting session")
|
||||
}
|
||||
|
||||
cipherKeyDec, err := crypt.AesGcmDecrypt(masterKey, signinResp.CipherKeyEnc)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "decrypting cipher key")
|
||||
}
|
||||
|
||||
cipherKeyDecB64 := base64.StdEncoding.EncodeToString(cipherKeyDec)
|
||||
|
||||
db := ctx.DB
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "beginning a transaction")
|
||||
}
|
||||
|
||||
if err := database.UpsertSystem(tx, consts.SystemCipherKey, cipherKeyDecB64); err != nil {
|
||||
return errors.Wrap(err, "saving enc key")
|
||||
}
|
||||
if err := database.UpsertSystem(tx, consts.SystemSessionKey, signinResp.Key); err != nil {
|
||||
return errors.Wrap(err, "saving session key")
|
||||
}
|
||||
|
|
@ -97,7 +74,7 @@ func Do(ctx context.DnoteCtx, email, password string) error {
|
|||
|
||||
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
log.Plain("Welcome to Dnote Pro (https://dnote.io).\n")
|
||||
log.Plain("Welcome to Dnote Pro (https://www.getdnote.com).\n")
|
||||
|
||||
var email, password string
|
||||
if err := ui.PromptInput("email", &email); err != nil {
|
||||
|
|
|
|||
|
|
@ -70,9 +70,6 @@ func Do(ctx context.DnoteCtx) error {
|
|||
return errors.Wrap(err, "requesting logout")
|
||||
}
|
||||
|
||||
if err := database.DeleteSystem(tx, consts.SystemCipherKey); err != nil {
|
||||
return errors.Wrap(err, "deleting enc key")
|
||||
}
|
||||
if err := database.DeleteSystem(tx, consts.SystemSessionKey); err != nil {
|
||||
return errors.Wrap(err, "deleting session key")
|
||||
}
|
||||
|
|
|
|||