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
This commit is contained in:
Sung Won Cho 2019-09-30 11:02:09 +08:00 committed by GitHub
commit 2758923c34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
567 changed files with 35445 additions and 13381 deletions

View file

@ -27,3 +27,4 @@ script:
- make test-cli
- make test-api
- make test-web
- make test-jslib

76
Gopkg.lock generated
View file

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

View file

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

View file

@ -1,17 +1,17 @@
![Dnote](assets/logo.png)
=========================
Dnote is a simple notebook for developers.
Dnote is a simple personal knowledge base.
[![Build Status](https://travis-ci.org/dnote/dnote.svg?branch=master)](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).
![A demo of Dnote CLI](assets/cli.gif)
@ -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)

View file

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

@ -0,0 +1,5 @@
/dist
/package
/node_modules
.DS_Store
extension.tar.gz

18
browser/CONTRIBUTING.md Normal file
View 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`

View 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
View 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.
![Dnote browser demo](assets/demo.gif)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

79
browser/gulpfile.js Normal file
View 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'));

View 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"
}
}
}
}

View 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

File diff suppressed because it is too large Load diff

52
browser/package.json Normal file
View 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
View 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
View 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
View 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
View 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;

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

15
browser/src/popup.html Normal file
View 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>

View 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;

View 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;

View 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;

View 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>
);

View 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;

View 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;

View 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;

View 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="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
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;

View 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;

View 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>
);

View 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>
);

View 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;

View 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 = '';
}
);
});

View 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));
});
};
}

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

View 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;

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

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

View 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;

View 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);
}

View 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))
);
}

View file

@ -0,0 +1,8 @@
import { NAVIGATE, NavigateAction } from './types';
export function navigate(path: string, state?): NavigateAction {
return {
type: NAVIGATE,
data: { path, state }
};
}

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

View 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;

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

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

View 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;

View file

@ -0,0 +1,5 @@
export default {
webUrl: __WEB_URL__,
apiEndpoint: __API_ENDPOINT__,
version: __VERSION__
};

View 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;

View 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,
});
}

View file

@ -0,0 +1,9 @@
import initServices from 'jslib/services';
import config from './config';
const services = initServices({
baseUrl: config.apiEndpoint,
pathPrefix: ''
});
export default services;

View 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);
});
}

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

View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
/dist
/node_modules
/coverage

3
jslib/README.md Normal file
View file

@ -0,0 +1,3 @@
# jslib
Code shared between Dnote JavaScript projects.

49
jslib/karma.conf.js Normal file
View 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

File diff suppressed because it is too large Load diff

33
jslib/package.json Normal file
View 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"
}
}

View file

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

View 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);
});
}
});
});

View 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
View 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);
}
};
}

View 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,
}

View file

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

View file

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

View file

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

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

View 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);
});
});
});

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

View file

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

@ -0,0 +1,7 @@
import * as Helpers from './helpers';
import * as Operations from './helpers';
export {
Helpers,
Operations
};

View 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);
}
};
}

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

View 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);
}
};
}

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

View 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);
}
};
}

View file

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

View 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
View 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' });
}
};
}

View 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);
}
};
}

View 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
View 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
View 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"
}
}

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more