Allow to specify repetition rule (#280)

* Implement data model and basic UI for repetition rules

* Implement tooltip

* Allow to update and write test

* Stop processing the first repetition until having waited at least the frequency amount

* Set up email dev server reload

* Test pro only

* Allow to toggle repetition using token

* Remove unused

* Add last active

* Simplify nextActive calculation

* Create weekly digest repetition rules for existing users

* Fix style

* Fix link

* Create default repetition rule upon signup

* Get notes with thresholds

* Fix test

* Fix test
This commit is contained in:
Sung Won Cho 2019-10-28 00:34:59 -07:00 committed by GitHub
commit 5902585216
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
176 changed files with 7746 additions and 1551 deletions

View file

@ -2,7 +2,7 @@ language: go
dist: xenial
go:
- 1.12
- 1.13
env:
- NODE_VERSION=10.15.0

View file

@ -10,7 +10,7 @@ This repository contains the server side and the client side code for Dnote.
1. Install the following prerequisites if necessary:
* [Go programming language](https://golang.org/dl/) 1.12+
* [Go programming language](https://golang.org/dl/) 1.13+
* [Node.js](https://nodejs.org/) 10.16+
* Postgres 10.9+

42
Gopkg.lock generated
View file

@ -90,6 +90,20 @@
revision = "9eb7a3d310e89e471c2cdf1ea3ec8d7fc1ab969c"
version = "v2.5.2"
[[projects]]
digest = "1:1d1cbf539d9ac35eb3148129f96be5537f1a1330cadcc7e3a83b4e72a59672a3"
name = "github.com/google/go-cmp"
packages = [
"cmp",
"cmp/internal/diff",
"cmp/internal/flags",
"cmp/internal/function",
"cmp/internal/value",
]
pruneopts = "UT"
revision = "2d0692c2e9617365a95b295612ac0d4415ba4627"
version = "v0.3.1"
[[projects]]
digest = "1:bfb6d8aee23cd9b2db8fa3760ca11d7a934d9a05993d5233406f1e6042e4c110"
name = "github.com/google/go-github"
@ -106,6 +120,14 @@
revision = "44c6ddd0a2342c386950e880b658017258da92fc"
version = "v1.0.0"
[[projects]]
digest = "1:582b704bebaa06b48c29b0cec224a6058a09c86883aaddabde889cd1a5f73e1b"
name = "github.com/google/uuid"
packages = ["."]
pruneopts = "UT"
revision = "0cd6bf5da1e1c83f8b45653022c74f71af0538a4"
version = "v1.1.1"
[[projects]]
digest = "1:fc51ecee8f31d03436c1a0167eb1e383ad0a241d02272541853f3995374a08f1"
name = "github.com/gorilla/css"
@ -154,6 +176,14 @@
revision = "23d116af351c84513e1946b527c88823e476be13"
version = "v1.3.0"
[[projects]]
branch = "master"
digest = "1:77fa3714d5009a706a77970c39b690c951473ab05560baab801f14055218c9ed"
name = "github.com/justincampbell/timeago"
packages = ["."]
pruneopts = "UT"
revision = "027f40306f1dbe89d24087611680ef95543bf876"
[[projects]]
digest = "1:77857b3205f936bdc6928ef347b682ab549cf99454d6c0ca04a49f8df9e418f3"
name = "github.com/karrick/godirwalk"
@ -244,14 +274,6 @@
pruneopts = "UT"
revision = "f4d34eae5a5cf210693e81c604e6bac5f6727927"
[[projects]]
digest = "1:274f67cb6fed9588ea2521ecdac05a6d62a8c51c074c1fccc6a49a40ba80e925"
name = "github.com/satori/go.uuid"
packages = ["."]
pruneopts = "UT"
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
version = "v1.2.0"
[[projects]]
digest = "1:d917313f309bda80d27274d53985bc65651f81a5b66b820749ac7f8ef061fd04"
name = "github.com/sergi/go-diff"
@ -392,16 +414,18 @@
"github.com/dnote/actions",
"github.com/dnote/color",
"github.com/gobuffalo/packr/v2",
"github.com/google/go-cmp/cmp",
"github.com/google/go-github/github",
"github.com/google/uuid",
"github.com/gorilla/mux",
"github.com/jinzhu/gorm",
"github.com/joho/godotenv",
"github.com/justincampbell/timeago",
"github.com/lib/pq",
"github.com/mattn/go-sqlite3",
"github.com/pkg/errors",
"github.com/robfig/cron",
"github.com/rubenv/sql-migrate",
"github.com/satori/go.uuid",
"github.com/sergi/go-diff/diffmatchpatch",
"github.com/spf13/cobra",
"github.com/stripe/stripe-go",

View file

@ -57,10 +57,6 @@
name = "github.com/lib/pq"
version = "1.1.1"
[[constraint]]
name = "github.com/markbates/goth"
version = "1.54.1"
[[constraint]]
name = "github.com/mattn/go-sqlite3"
version = "1.10.0"
@ -73,10 +69,6 @@
name = "github.com/robfig/cron"
version = "1.2.0"
[[constraint]]
name = "github.com/satori/go.uuid"
version = "1.2.0"
[[constraint]]
name = "github.com/sergi/go-diff"
version = "1.0.0"

View file

@ -61,7 +61,7 @@ test-cli:
test-api:
@echo "==> running API test"
@${GOPATH}/src/github.com/dnote/dnote/pkg/server/api/scripts/test-local.sh
@${GOPATH}/src/github.com/dnote/dnote/pkg/server/scripts/test-local.sh
.PHONY: test-api
test-web:

View file

@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import { BookData } from '../operations/books';
import { BookData } from '../operations/types';
// errBookNameNumeric is an error for book names that only contain numbers
export const errBookNameNumeric = new Error(

View file

@ -115,8 +115,11 @@ export function getHttpClient(c: HttpClientConfig) {
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);
post: <T = any>(path: string, data = {}, options = {}) => {
return post<T>(transformPath(path), data, options).then(resp => {
console.log('check', resp);
return resp;
});
},
patch: <T = any>(path: string, data, options = {}) => {
return patch<T>(transformPath(path), data, options);

View file

@ -20,7 +20,9 @@ export const KEYCODE_DOWN = 40;
export const KEYCODE_UP = 38;
export const KEYCODE_ENTER = 13;
export const KEYCODE_ESC = 27;
export const KEYCODE_SPACE = 32;
export const KEYCODE_TAB = 9;
export const KEYCODE_BACKSPACE = 8;
// alphabet
export const KEYCODE_LOWERCASE_B = 66;

View file

@ -16,7 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import { BookData } from '../operations/books';
import { BookData } from '../operations/types';
// Option represents an option in a selection list
export interface Option {

View file

@ -18,14 +18,7 @@
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;
};
import { BookData } from './types';
export interface CreateParams {
name: string;

View file

@ -47,3 +47,38 @@ export interface UserData {
pro: boolean;
classic: boolean;
}
export type BookData = {
uuid: string;
usn: number;
created_at: string;
updated_at: string;
label: string;
};
// BookDomain is the possible values for the field in the repetition_rule
// indicating how to derive the source books for the repetition_rule.
export enum BookDomain {
// All incidates that all books are eligible to be the source books
All = 'all',
// Including incidates that some specified books are eligible to be the source books
Including = 'including',
// Excluding incidates that all books except for some specified books are eligible to be the source books
Excluding = 'excluding'
}
export interface RepetitionRuleData {
uuid: string;
title: string;
enabled: boolean;
hour: number;
minute: number;
bookDomain: BookDomain;
frequency: number;
books: BookData[];
lastActive: number;
nextActive: number;
noteCount: number;
createdAt: string;
updatedAt: string;
}

View file

@ -23,24 +23,14 @@ export default function init(config: HttpClientConfig) {
const client = getHttpClient(config);
return {
fetch: (digestUUID, { demo }) => {
let endpoint;
if (demo) {
endpoint = `/demo/digests/${digestUUID}`;
} else {
endpoint = `/digests/${digestUUID}`;
}
fetch: digestUUID => {
const endpoint = `/digests/${digestUUID}`;
return client.get(endpoint);
},
fetchAll: ({ page, demo }) => {
let path;
if (demo) {
path = `/demo/digests`;
} else {
path = '/digests';
}
fetchAll: ({ page }) => {
const path = '/digests';
const endpoint = getPath(path, { page });

View file

@ -20,8 +20,9 @@ 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';
import initDigestsService from './digests';
import initRepetitionRulesService from './repetitionRules';
// init initializes service helpers with the given http configuration
// and returns an object of all services.
@ -29,14 +30,16 @@ 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);
const digestsService = initDigestsService(c);
const repetitionRulesService = initRepetitionRulesService(c);
return {
users: usersService,
books: booksService,
notes: notesService,
payment: paymentService,
digests: digestsService,
payment: paymentService
repetitionRules: repetitionRulesService
};
}

View file

@ -0,0 +1,116 @@
/* 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 { BookData, RepetitionRuleData, BookDomain } from '../operations/types';
import { getHttpClient, HttpClientConfig } from '../helpers/http';
import { getPath } from '../helpers/url';
export type FetchResponse = RepetitionRuleData[];
export interface CreateParams {
title: string;
hour: number;
minute: number;
book_domain: BookDomain;
frequency: number;
note_count: number;
book_uuids: string[];
enabled: boolean;
}
export type UpdateParams = Partial<CreateParams>;
export interface RepetitionRuleRespData {
uuid: string;
title: string;
enabled: boolean;
hour: number;
minute: number;
book_domain: BookDomain;
frequency: number;
books: BookData[];
note_count: number;
last_active: number;
next_active: number;
created_at: string;
updated_at: string;
}
function mapData(d: RepetitionRuleRespData): RepetitionRuleData {
return {
uuid: d.uuid,
title: d.title,
enabled: d.enabled,
hour: d.hour,
minute: d.minute,
bookDomain: d.book_domain,
frequency: d.frequency,
books: d.books,
noteCount: d.note_count,
lastActive: d.last_active,
nextActive: d.next_active,
createdAt: d.created_at,
updatedAt: d.updated_at
};
}
export default function init(config: HttpClientConfig) {
const client = getHttpClient(config);
return {
fetch: (uuid: string, queries = {}): Promise<RepetitionRuleData> => {
const path = `/repetition_rules/${uuid}`;
const endpoint = getPath(path, queries);
return client.get<RepetitionRuleRespData>(endpoint).then(resp => {
return mapData(resp);
});
},
fetchAll: (): Promise<RepetitionRuleData[]> => {
const endpoint = '/repetition_rules';
return client.get<RepetitionRuleRespData[]>(endpoint).then(resp => {
return resp.map(mapData);
});
},
create: (params: CreateParams) => {
const endpoint = '/repetition_rules';
return client
.post<RepetitionRuleRespData>(endpoint, params)
.then(resp => {
return mapData(resp);
});
},
update: (uuid: string, params: UpdateParams, queries = {}) => {
const path = `/repetition_rules/${uuid}`;
const endpoint = getPath(path, queries);
return client
.patch<RepetitionRuleRespData>(endpoint, params)
.then(resp => {
return mapData(resp);
});
},
remove: (uuid: string) => {
const endpoint = `/repetition_rules/${uuid}`;
return client.del(endpoint);
}
};
}

View file

@ -25,8 +25,10 @@ import (
"io/ioutil"
"net/http"
"reflect"
"runtime/debug"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/pkg/errors"
)
@ -40,7 +42,9 @@ Actual:
Expected:
========================
%+v
========================`, m, a, b)
========================
%s`, m, a, b, string(debug.Stack()))
}
func checkEqual(a, b interface{}, message string) (bool, string) {
@ -94,6 +98,7 @@ func DeepEqual(t *testing.T, a, b interface{}, message string) {
}
errorMessage := getErrorMessage(message, a, b)
errorMessage = fmt.Sprintf("%v\n%v", errorMessage, cmp.Diff(a, b))
t.Error(errorMessage)
}

View file

@ -135,7 +135,10 @@ func writeNote(ctx context.DnoteCtx, bookLabel string, content string, ts int64)
var bookUUID string
err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID)
if err == sql.ErrNoRows {
bookUUID = utils.GenerateUUID()
bookUUID, err = utils.GenerateUUID()
if err != nil {
return 0, errors.Wrap(err, "generating uuid")
}
b := database.NewBook(bookUUID, bookLabel, 0, false, true)
err = b.Insert(tx)
@ -147,7 +150,11 @@ func writeNote(ctx context.DnoteCtx, bookLabel string, content string, ts int64)
return 0, errors.Wrap(err, "finding the book")
}
noteUUID := utils.GenerateUUID()
noteUUID, err := utils.GenerateUUID()
if err != nil {
return 0, errors.Wrap(err, "generating uuid")
}
n := database.NewNote(noteUUID, bookUUID, content, ts, 0, 0, false, false, true)
err = n.Insert(tx)

View file

@ -192,7 +192,11 @@ func runBook(ctx context.DnoteCtx, bookLabel string) error {
}
// override the label with a random string
uniqLabel := utils.GenerateUUID()
uniqLabel, err := utils.GenerateUUID()
if err != nil {
return errors.Wrap(err, "generating uuid to override with")
}
if _, err = tx.Exec("UPDATE books SET deleted = ?, dirty = ?, label = ? WHERE uuid = ?", true, true, uniqLabel, bookUUID); err != nil {
tx.Rollback()
return errors.Wrap(err, "removing the book")

View file

@ -137,7 +137,11 @@ func getConflictsBookUUID(tx *database.DB) (string, error) {
err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", "conflicts").Scan(&ret)
if err == sql.ErrNoRows {
// Create a conflicts book
ret = utils.GenerateUUID()
ret, err = utils.GenerateUUID()
if err != nil {
return "", err
}
b := database.NewBook(ret, "conflicts", 0, false, true)
err = b.Insert(tx)
if err != nil {

View file

@ -33,7 +33,6 @@ import (
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/testutils"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
)
@ -235,7 +234,7 @@ func TestSyncDeleteNote(t *testing.T) {
})
t.Run("local copy is dirty", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
// set up
db := database.InitTestDB(t, dbPath, nil)
@ -305,7 +304,7 @@ func TestSyncDeleteNote(t *testing.T) {
})
t.Run("local copy is not dirty", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
// set up
db := database.InitTestDB(t, dbPath, nil)
@ -406,7 +405,7 @@ func TestSyncDeleteBook(t *testing.T) {
})
t.Run("local copy is dirty", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
// set up
db := database.InitTestDB(t, dbPath, nil)
@ -471,8 +470,8 @@ func TestSyncDeleteBook(t *testing.T) {
})
t.Run("local copy is not dirty", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b2UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
b2UUID := testutils.MustGenerateUUID(t)
// set up
db := database.InitTestDB(t, dbPath, nil)
@ -538,7 +537,7 @@ func TestSyncDeleteBook(t *testing.T) {
})
t.Run("local copy has at least one note that is dirty", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
// set up
db := database.InitTestDB(t, dbPath, nil)
@ -596,7 +595,7 @@ func TestFullSyncNote(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label")
// execute
@ -646,9 +645,9 @@ func TestFullSyncNote(t *testing.T) {
})
t.Run("exists on server and client", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b2UUID := utils.GenerateUUID()
conflictBookUUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
b2UUID := testutils.MustGenerateUUID(t)
conflictBookUUID := testutils.MustGenerateUUID(t)
testCases := []struct {
addedOn int64
@ -831,7 +830,7 @@ n1 body edited
database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label")
database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label")
database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty)
// execute
@ -890,7 +889,7 @@ func TestFullSyncBook(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false)
// execute
@ -899,7 +898,7 @@ func TestFullSyncBook(t *testing.T) {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
b := client.SyncFragBook{
UUID: b2UUID,
USN: 1,
@ -1029,7 +1028,7 @@ func TestFullSyncBook(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted)
// execute
@ -1082,7 +1081,7 @@ func TestStepSyncNote(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label")
// execute
@ -1132,9 +1131,9 @@ func TestStepSyncNote(t *testing.T) {
})
t.Run("exists on server and client", func(t *testing.T) {
b1UUID := utils.GenerateUUID()
b2UUID := utils.GenerateUUID()
conflictBookUUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
b2UUID := testutils.MustGenerateUUID(t)
conflictBookUUID := testutils.MustGenerateUUID(t)
testCases := []struct {
addedOn int64
@ -1243,7 +1242,7 @@ n1 body edited
database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label")
database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label")
database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty)
// execute
@ -1302,7 +1301,7 @@ func TestStepSyncBook(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false)
// execute
@ -1311,7 +1310,7 @@ func TestStepSyncBook(t *testing.T) {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
b := client.SyncFragBook{
UUID: b2UUID,
USN: 1,
@ -1425,9 +1424,9 @@ func TestStepSyncBook(t *testing.T) {
db := database.InitTestDB(t, dbPath, nil)
defer database.CloseTestDB(t, db)
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted)
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b2UUID, 2, tc.anotherBookLabel, false, false)
// execute
@ -1660,7 +1659,7 @@ func TestMergeBook(t *testing.T) {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 1, "b1-label", false, false)
b1 := client.SyncFragBook{
@ -2401,7 +2400,7 @@ func TestSendNotes_addedOn(t *testing.T) {
if r.URL.String() == "/v3/notes" && r.Method == "POST" {
resp := client.CreateNoteResp{
Result: client.RespNote{
UUID: utils.GenerateUUID(),
UUID: testutils.MustGenerateUUID(t),
},
}
@ -2649,7 +2648,7 @@ func TestSendNotes_isBehind(t *testing.T) {
func TestMergeNote(t *testing.T) {
b1UUID := "b1-uuid"
b2UUID := "b2-uuid"
conflictBookUUID := utils.GenerateUUID()
conflictBookUUID := testutils.MustGenerateUUID(t)
testCases := []struct {
addedOn int64
@ -2786,7 +2785,7 @@ n1 body edited
database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b1UUID, "b1-label", 5, false)
database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b2UUID, "b2-label", 6, false)
database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, b1UUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty)
// execute

View file

@ -30,8 +30,8 @@ import (
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/satori/go.uuid"
"gopkg.in/yaml.v2"
)
@ -81,6 +81,16 @@ func makeSchema(complete bool) schema {
return s
}
func genUUID() (string, error) {
res, err := uuid.NewRandom()
if err != nil {
return "", errors.Wrap(err, "generating a random uuid")
}
return res.String(), nil
}
// Legacy performs migration on JSON-based dnote if necessary
func Legacy(ctx context.DnoteCtx) error {
// If schema does not exist, no need run a legacy migration
@ -510,8 +520,13 @@ func migrateToV2(ctx context.DnoteCtx) error {
for bookName, book := range preDnote {
var notes = make([]migrateToV2PostNote, 0, len(book))
for _, note := range book {
noteUUID, err := genUUID()
if err != nil {
return errors.Wrap(err, "generating uuid")
}
newNote := migrateToV2PostNote{
UUID: uuid.NewV4().String(),
UUID: noteUUID,
Content: note.Content,
AddedOn: note.AddedOn,
EditedOn: 0,
@ -703,8 +718,13 @@ func migrateToV5(ctx context.DnoteCtx) error {
data = action.Data
}
actionUUID, err := genUUID()
if err != nil {
return errors.Wrap(err, "generating UUID")
}
migrated := migrateToV5PostAction{
UUID: uuid.NewV4().String(),
UUID: actionUUID,
Schema: 1,
Type: action.Type,
Data: data,
@ -866,7 +886,13 @@ func migrateToV8(ctx context.DnoteCtx) error {
}
for bookName, book := range dnote {
bookUUID := uuid.NewV4().String()
bookUUIDResult, err := uuid.NewRandom()
if err != nil {
return errors.Wrap(err, "generating uuid")
}
bookUUID := bookUUIDResult.String()
_, err = tx.Exec(`INSERT INTO books (uuid, label) VALUES (?, ?)`, bookUUID, bookName)
if err != nil {
tx.Rollback()

View file

@ -34,7 +34,6 @@ import (
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/testutils"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
)
@ -325,17 +324,17 @@ func TestLocalMigration1(t *testing.T) {
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
a1UUID := utils.GenerateUUID()
a1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: "", Content: "note 1"})
a2UUID := utils.GenerateUUID()
a2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "edit_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-2-uuid", FromBook: "js", ToBook: "", Content: "note 2"})
a3UUID := utils.GenerateUUID()
a3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "edit_note", string(data), 1537829463)
@ -405,21 +404,21 @@ func TestLocalMigration2(t *testing.T) {
c2 := "note 1 - v2"
css := "css"
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false})
a1UUID := utils.GenerateUUID()
a1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: nil, Content: &c1, Public: nil})
a2UUID := utils.GenerateUUID()
a2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 2, "edit_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: &css, Content: &c2, Public: nil})
a3UUID := utils.GenerateUUID()
a3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 2, "edit_note", string(data), 1537829463)
@ -488,17 +487,17 @@ func TestLocalMigration3(t *testing.T) {
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false})
a1UUID := utils.GenerateUUID()
a1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-1-uuid", BookName: "js"})
a2UUID := utils.GenerateUUID()
a2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "remove_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-2-uuid", BookName: "js"})
a3UUID := utils.GenerateUUID()
a3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "remove_note", string(data), 1537829463)
@ -562,9 +561,9 @@ func TestLocalMigration4(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano())
// Execute
@ -606,16 +605,16 @@ func TestLocalMigration5(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "js")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano())
n2UUID := utils.GenerateUUID()
n2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n2UUID, b1UUID, "n2 content", time.Now().UnixNano())
n3UUID := utils.GenerateUUID()
n3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n3UUID, b1UUID, "n3 content", time.Now().UnixNano())
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
@ -669,7 +668,7 @@ func TestLocalMigration6(t *testing.T) {
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
a1UUID := utils.GenerateUUID()
a1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463)
@ -701,7 +700,7 @@ func TestLocalMigration7_trash(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting trash book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "trash")
// Execute
@ -734,7 +733,7 @@ func TestLocalMigration7_conflicts(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts")
// Execute
@ -767,9 +766,9 @@ func TestLocalMigration7_conflicts_dup(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts")
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "conflicts (2)")
// Execute
@ -805,14 +804,14 @@ func TestLocalMigration8(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting n1", db, `INSERT INTO notes
(id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false)
n2UUID := utils.GenerateUUID()
n2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting n2", db, `INSERT INTO notes
(id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 2, n2UUID, b1UUID, "", 3, 4, false, true, 21, true)
@ -871,14 +870,14 @@ func TestLocalMigration9(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1")
n1UUID := utils.GenerateUUID()
n1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting n1", db, `INSERT INTO notes
(uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)`, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false)
n2UUID := utils.GenerateUUID()
n2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting n2", db, `INSERT INTO notes
(uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)`, n2UUID, b1UUID, "n2 Body", 3, 4, false, true, 21, false)
@ -917,21 +916,21 @@ func TestLocalMigration10(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "123")
b2UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book ", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "123")
b2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "123 javascript")
b3UUID := utils.GenerateUUID()
b3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 3", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b3UUID, "foo")
b4UUID := utils.GenerateUUID()
b4UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 4", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b4UUID, "+123")
b5UUID := utils.GenerateUUID()
b5UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 5", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b5UUID, "0123")
b6UUID := utils.GenerateUUID()
b6UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 6", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b6UUID, "javascript 123")
b7UUID := utils.GenerateUUID()
b7UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 7", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b7UUID, "123 (1)")
b8UUID := utils.GenerateUUID()
b8UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 8", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b8UUID, "5")
// Execute
@ -989,23 +988,23 @@ func TestLocalMigration11(t *testing.T) {
db := ctx.DB
b1UUID := utils.GenerateUUID()
b1UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "foo")
b2UUID := utils.GenerateUUID()
b2UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "bar baz")
b3UUID := utils.GenerateUUID()
b3UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 3", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b3UUID, "quz qux")
b4UUID := utils.GenerateUUID()
b4UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 4", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b4UUID, "quz_qux")
b5UUID := utils.GenerateUUID()
b5UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 5", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b5UUID, "foo bar baz quz 123")
b6UUID := utils.GenerateUUID()
b6UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 6", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b6UUID, "foo_bar baz")
b7UUID := utils.GenerateUUID()
b7UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 7", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b7UUID, "cool ideas")
b8UUID := utils.GenerateUUID()
b8UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 8", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b8UUID, "cool_ideas")
b9UUID := utils.GenerateUUID()
b9UUID := testutils.MustGenerateUUID(t)
database.MustExec(t, "inserting book 9", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b9UUID, "cool_ideas_2")
// Execute

View file

@ -224,3 +224,13 @@ func MustUnmarshalJSON(t *testing.T, data []byte, v interface{}) {
t.Fatalf("%s: unmarshalling data", t.Name())
}
}
// MustGenerateUUID generates the uuid. If error occurs, it fails the test.
func MustGenerateUUID(t *testing.T) string {
ret, err := utils.GenerateUUID()
if err != nil {
t.Fatal(errors.Wrap(err, "generating uuid").Error())
}
return ret
}

View file

@ -21,12 +21,18 @@ package utils
import (
"regexp"
"github.com/satori/go.uuid"
"github.com/google/uuid"
"github.com/pkg/errors"
)
// GenerateUUID returns a uid
func GenerateUUID() string {
return uuid.NewV4().String()
// GenerateUUID returns a uuid v4 in string
func GenerateUUID() (string, error) {
u, err := uuid.NewRandom()
if err != nil {
return "", errors.Wrap(err, "generating uuid")
}
return u.String(), nil
}
// regexNumber is a regex that matches a string that looks like an integer

View file

@ -26,8 +26,7 @@ import (
//TODO: use mutex to avoid race
// Clock is an interface to the standard library time.
// It is used to implement a real or a mock clock. The latter is
// used in tests.
// It is used to implement a real or a mock clock. The latter is used in tests.
type Clock interface {
Now() time.Time
}

View file

@ -84,7 +84,7 @@ func (a *App) getMe(w http.ResponseWriter, r *http.Request) {
}
tx.Commit()
respondJSON(w, response)
respondJSON(w, http.StatusOK, response)
}
type createResetTokenPayload struct {

View file

@ -215,7 +215,7 @@ func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) {
User: session,
}
respondJSON(w, response)
respondJSON(w, http.StatusOK, response)
}
type classicSetPasswordPayload struct {
@ -272,5 +272,5 @@ func (a *App) classicGetNotes(w http.ResponseWriter, r *http.Request) {
}
presented := presenters.PresentNotes(notes)
respondJSON(w, presented)
respondJSON(w, http.StatusOK, presented)
}

View file

@ -126,8 +126,10 @@ func handleError(w http.ResponseWriter, msg string, err error, statusCode int) {
}
// respondJSON encodes the given payload into a JSON format and writes it to the given response writer
func respondJSON(w http.ResponseWriter, payload interface{}) {
func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(payload); err != nil {
handleError(w, "encoding response", err, http.StatusInternalServerError)
}

View file

@ -76,7 +76,7 @@ ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?),
func respondWithNote(w http.ResponseWriter, note database.Note) {
presentedNote := presenters.PresentNote(note)
respondJSON(w, presentedNote)
respondJSON(w, http.StatusOK, presentedNote)
}
func parseSearchQuery(q url.Values) string {
@ -182,7 +182,7 @@ func respondGetNotes(userID int, query url.Values, w http.ResponseWriter) {
Notes: presenters.PresentNotes(notes),
Total: total,
}
respondJSON(w, response)
respondJSON(w, http.StatusOK, response)
}
type getNotesQuery struct {
@ -331,5 +331,5 @@ func (a *App) legacyGetNotes(w http.ResponseWriter, r *http.Request) {
}
presented := presenters.PresentNotes(notes)
respondJSON(w, presented)
respondJSON(w, http.StatusOK, presented)
}

View file

@ -0,0 +1,460 @@
/* 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/>.
*/
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/dnote/dnote/pkg/server/api/helpers"
"github.com/dnote/dnote/pkg/server/api/presenters"
"github.com/dnote/dnote/pkg/server/database"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
repetitionRuleUUID := vars["repetitionRuleUUID"]
if ok := helpers.ValidateUUID(repetitionRuleUUID); !ok {
http.Error(w, "invalid uuid", http.StatusBadRequest)
return
}
db := database.DBConn
var repetitionRule database.RepetitionRule
if err := db.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").Find(&repetitionRule).Error; err != nil {
handleError(w, "getting repetition rules", err, http.StatusInternalServerError)
return
}
resp := presenters.PresentRepetitionRule(repetitionRule)
respondJSON(w, http.StatusOK, resp)
}
func (a *App) getRepetitionRules(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
db := database.DBConn
var repetitionRules []database.RepetitionRule
if err := db.Where("user_id = ?", user.ID).Preload("Books").Order("last_active DESC").Find(&repetitionRules).Error; err != nil {
handleError(w, "getting repetition rules", err, http.StatusInternalServerError)
return
}
resp := presenters.PresentRepetitionRules(repetitionRules)
respondJSON(w, http.StatusOK, resp)
}
func validateBookDomain(val string) error {
if val == database.BookDomainAll || val == database.BookDomainIncluding || val == database.BookDomainExluding {
return nil
}
return errors.Errorf("invalid book_domain %s", val)
}
type repetitionRuleParams struct {
Title *string `json:"title"`
Enabled *bool `json:"enabled"`
Hour *int `json:"hour"`
Minute *int `json:"minute"`
Frequency *int64 `json:"frequency"`
BookDomain *string `json:"book_domain"`
BookUUIDs *[]string `json:"book_uuids"`
NoteCount *int `json:"note_count"`
}
func (r repetitionRuleParams) GetEnabled() bool {
if r.Enabled == nil {
return false
}
return *r.Enabled
}
func (r repetitionRuleParams) GetFrequency() int64 {
if r.Frequency == nil {
return 0
}
return *r.Frequency
}
func (r repetitionRuleParams) GetTitle() string {
if r.Title == nil {
return ""
}
return *r.Title
}
func (r repetitionRuleParams) GetNoteCount() int {
if r.NoteCount == nil {
return 0
}
return *r.NoteCount
}
func (r repetitionRuleParams) GetBookDomain() string {
if r.BookDomain == nil {
return ""
}
return *r.BookDomain
}
func (r repetitionRuleParams) GetBookUUIDs() []string {
if r.BookUUIDs == nil {
return []string{}
}
return *r.BookUUIDs
}
func (r repetitionRuleParams) GetHour() int {
if r.Hour == nil {
return 0
}
return *r.Hour
}
func (r repetitionRuleParams) GetMinute() int {
if r.Minute == nil {
return 0
}
return *r.Minute
}
func validateRepetitionRuleParams(p repetitionRuleParams) error {
if p.Frequency != nil && p.GetFrequency() == 0 {
return errors.New("frequency is required")
}
if p.Title != nil {
title := p.GetTitle()
if len(title) == 0 {
return errors.New("Title is required")
}
if len(title) > 50 {
return errors.New("Title is too long")
}
}
if p.NoteCount != nil && p.GetNoteCount() == 0 {
return errors.New("note count has to be greater than 0")
}
if p.BookDomain != nil {
bookDomain := p.GetBookDomain()
if err := validateBookDomain(bookDomain); err != nil {
return err
}
bookUUIDs := p.GetBookUUIDs()
if bookDomain == database.BookDomainAll {
if len(bookUUIDs) > 0 {
return errors.New("a global repetition should not specify book_uuids")
}
} else {
if len(bookUUIDs) == 0 {
return errors.New("book_uuids is required")
}
}
}
if p.Hour != nil {
hour := p.GetHour()
if hour < 0 && hour > 23 {
return errors.New("invalid hour")
}
}
if p.Minute != nil {
minute := p.GetMinute()
if minute < 0 && minute > 60 {
return errors.New("invalid minute")
}
}
return nil
}
func validateCreateRepetitionRuleParams(p repetitionRuleParams) error {
if p.Title == nil {
return errors.New("title is required")
}
if p.Frequency == nil {
return errors.New("frequency is required")
}
if p.NoteCount == nil {
return errors.New("note_count is required")
}
if p.BookDomain == nil {
return errors.New("book_domain is required")
}
if p.Hour == nil {
return errors.New("hour is required")
}
if p.Minute == nil {
return errors.New("minute is required")
}
if p.Enabled == nil {
return errors.New("enabled is required")
}
return nil
}
func parseCreateRepetitionRuleParams(r *http.Request) (repetitionRuleParams, error) {
var ret repetitionRuleParams
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&ret); err != nil {
return ret, errors.Wrap(err, "decoding json")
}
if err := validateCreateRepetitionRuleParams(ret); err != nil {
return ret, errors.Wrap(err, "validating params")
}
if err := validateRepetitionRuleParams(ret); err != nil {
return ret, errors.Wrap(err, "validating params")
}
return ret, nil
}
type calcNextActiveParams struct {
Hour int
Minute int
Frequency int64
}
// calcNextActive calculates the NextActive value for a repetition rule by adding the given
// frequency to the given present date time at the given hour and minute.
func calcNextActive(now time.Time, p calcNextActiveParams) int64 {
t0 := time.Date(now.Year(), now.Month(), now.Day(), p.Hour, p.Minute, 0, 0, now.Location()).UnixNano() / int64(time.Millisecond)
return t0 + p.Frequency
}
func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
params, err := parseCreateRepetitionRuleParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
db := database.DBConn
var books []database.Book
if err := db.Where("user_id = ? AND uuid IN (?)", user.ID, params.GetBookUUIDs()).Find(&books).Error; err != nil {
handleError(w, "finding books", nil, http.StatusInternalServerError)
return
}
nextActive := calcNextActive(a.Clock.Now(), calcNextActiveParams{
Hour: params.GetHour(),
Minute: params.GetMinute(),
Frequency: params.GetFrequency(),
})
record := database.RepetitionRule{
UserID: user.ID,
Title: params.GetTitle(),
Hour: params.GetHour(),
Minute: params.GetMinute(),
Frequency: params.GetFrequency(),
BookDomain: params.GetBookDomain(),
NextActive: nextActive,
Books: books,
NoteCount: params.GetNoteCount(),
Enabled: params.GetEnabled(),
}
if err := db.Create(&record).Error; err != nil {
handleError(w, "creating a repetition rule", err, http.StatusInternalServerError)
return
}
resp := presenters.PresentRepetitionRule(record)
respondJSON(w, http.StatusCreated, resp)
}
func parseUpdateDigestParams(r *http.Request) (repetitionRuleParams, error) {
var ret repetitionRuleParams
if err := json.NewDecoder(r.Body).Decode(&ret); err != nil {
return ret, errors.Wrap(err, "decoding json")
}
if err := validateRepetitionRuleParams(ret); err != nil {
return ret, errors.Wrap(err, "validating params")
}
return ret, nil
}
func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
repetitionRuleUUID := vars["repetitionRuleUUID"]
db := database.DBConn
var rule database.RepetitionRule
conn := db.Where("uuid = ? AND user_id = ?", repetitionRuleUUID, user.ID).First(&rule)
if conn.RecordNotFound() {
http.Error(w, "Not found", http.StatusNotFound)
return
} else if err := conn.Error; err != nil {
handleError(w, "finding the repetition rule", err, http.StatusInternalServerError)
return
}
if err := db.Exec("DELETE from repetition_rules WHERE uuid = ?", rule.UUID).Error; err != nil {
handleError(w, "deleting the repetition rule", err, http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
repetitionRuleUUID := vars["repetitionRuleUUID"]
params, err := parseUpdateDigestParams(r)
if err != nil {
http.Error(w, "parsing params", http.StatusBadRequest)
return
}
db := database.DBConn
tx := db.Begin()
var repetitionRule database.RepetitionRule
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").First(&repetitionRule).Error; err != nil {
handleError(w, "finding record", nil, http.StatusInternalServerError)
return
}
if params.Title != nil {
repetitionRule.Title = params.GetTitle()
}
if params.Enabled != nil {
enabled := params.GetEnabled()
repetitionRule.Enabled = enabled
if enabled && !repetitionRule.Enabled {
repetitionRule.NextActive = calcNextActive(a.Clock.Now(), calcNextActiveParams{
Hour: repetitionRule.Hour,
Minute: repetitionRule.Minute,
Frequency: repetitionRule.Frequency,
})
} else if !enabled && repetitionRule.Enabled {
repetitionRule.NextActive = 0
}
}
if params.Hour != nil {
repetitionRule.Hour = params.GetHour()
}
if params.Minute != nil {
repetitionRule.Minute = params.GetMinute()
}
if params.Frequency != nil {
frequency := params.GetFrequency()
repetitionRule.Frequency = frequency
repetitionRule.NextActive = calcNextActive(a.Clock.Now(), calcNextActiveParams{
Hour: repetitionRule.Hour,
Minute: repetitionRule.Minute,
Frequency: frequency,
})
}
if params.NoteCount != nil {
repetitionRule.NoteCount = params.GetNoteCount()
}
if params.BookDomain != nil {
repetitionRule.BookDomain = params.GetBookDomain()
}
if params.BookUUIDs != nil {
var books []database.Book
if err := tx.Where("user_id = ? AND uuid IN (?)", user.ID, *params.BookUUIDs).Find(&books).Error; err != nil {
handleError(w, "finding books", err, http.StatusInternalServerError)
return
}
if err := tx.Model(&repetitionRule).Association("Books").Replace(books).Error; err != nil {
tx.Rollback()
handleError(w, "updating books association for a repetitionRule", err, http.StatusInternalServerError)
return
}
}
if err := tx.Save(&repetitionRule).Error; err != nil {
tx.Rollback()
handleError(w, "creating a repetition rule", err, http.StatusInternalServerError)
return
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
handleError(w, "committing a transaction", err, http.StatusInternalServerError)
}
resp := presenters.PresentRepetitionRule(repetitionRule)
respondJSON(w, http.StatusOK, resp)
}

View file

@ -0,0 +1,649 @@
/* 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/>.
*/
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/api/presenters"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func init() {
testutils.InitTestDB()
}
func TestGetRepetitionRule(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
user := testutils.SetupUserData()
b1 := database.Book{
USN: 11,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing book1")
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 7).Milliseconds(),
Hour: 21,
Minute: 0,
LastActive: 0,
UserID: user.ID,
BookDomain: database.BookDomainExluding,
Books: []database.Book{b1},
NoteCount: 5,
}
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
// Execute
req := testutils.MakeReq(server, "GET", fmt.Sprintf("/repetition_rules/%s", r1.UUID), "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.RepetitionRule
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var r1Record database.RepetitionRule
testutils.MustExec(t, db.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
var b1Record database.Book
testutils.MustExec(t, db.Where("uuid = ?", b1.UUID).First(&b1Record), "finding b1Record")
expected := presenters.RepetitionRule{
UUID: r1Record.UUID,
Title: r1Record.Title,
Enabled: r1Record.Enabled,
Hour: r1Record.Hour,
Minute: r1Record.Minute,
Frequency: r1Record.Frequency,
BookDomain: r1Record.BookDomain,
NoteCount: r1Record.NoteCount,
LastActive: r1Record.LastActive,
Books: []presenters.Book{
{
UUID: b1Record.UUID,
USN: b1Record.USN,
Label: b1Record.Label,
CreatedAt: presenters.FormatTS(b1Record.CreatedAt),
UpdatedAt: presenters.FormatTS(b1Record.UpdatedAt),
},
},
CreatedAt: presenters.FormatTS(r1Record.CreatedAt),
UpdatedAt: presenters.FormatTS(r1Record.UpdatedAt),
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetRepetitionRules(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
user := testutils.SetupUserData()
b1 := database.Book{
USN: 11,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing book1")
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 7).Milliseconds(),
Hour: 21,
Minute: 0,
LastActive: 1257714000000,
UserID: user.ID,
BookDomain: database.BookDomainExluding,
Books: []database.Book{b1},
NoteCount: 5,
}
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
r2 := database.RepetitionRule{
Title: "Rule 2",
Frequency: (time.Hour * 24 * 7 * 2).Milliseconds(),
Hour: 2,
Minute: 0,
LastActive: 0,
UserID: user.ID,
BookDomain: database.BookDomainExluding,
Books: []database.Book{},
NoteCount: 5,
}
testutils.MustExec(t, db.Save(&r2), "preparing rule2")
// Execute
req := testutils.MakeReq(server, "GET", "/repetition_rules", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload []presenters.RepetitionRule
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var r1Record, r2Record database.RepetitionRule
testutils.MustExec(t, db.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
testutils.MustExec(t, db.Where("uuid = ?", r2.UUID).First(&r2Record), "finding r2Record")
var b1Record database.Book
testutils.MustExec(t, db.Where("uuid = ?", b1.UUID).First(&b1Record), "finding b1Record")
expected := []presenters.RepetitionRule{
{
UUID: r1Record.UUID,
Title: r1Record.Title,
Enabled: r1Record.Enabled,
Hour: r1Record.Hour,
Minute: r1Record.Minute,
Frequency: r1Record.Frequency,
BookDomain: r1Record.BookDomain,
NoteCount: r1Record.NoteCount,
LastActive: r1Record.LastActive,
Books: []presenters.Book{
{
UUID: b1Record.UUID,
USN: b1Record.USN,
Label: b1Record.Label,
CreatedAt: presenters.FormatTS(b1Record.CreatedAt),
UpdatedAt: presenters.FormatTS(b1Record.UpdatedAt),
},
},
CreatedAt: presenters.FormatTS(r1Record.CreatedAt),
UpdatedAt: presenters.FormatTS(r1Record.UpdatedAt),
},
{
UUID: r2Record.UUID,
Title: r2Record.Title,
Enabled: r2Record.Enabled,
Hour: r2Record.Hour,
Minute: r2Record.Minute,
Frequency: r2Record.Frequency,
BookDomain: r2Record.BookDomain,
NoteCount: r2Record.NoteCount,
LastActive: r2Record.LastActive,
Books: []presenters.Book{},
CreatedAt: presenters.FormatTS(r2Record.CreatedAt),
UpdatedAt: presenters.FormatTS(r2Record.UpdatedAt),
},
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestCreateRepetitionRules(t *testing.T) {
t.Run("all books", func(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
c := clock.NewMock()
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
c.SetNow(t0)
server := httptest.NewServer(NewRouter(&App{
Clock: c,
}))
defer server.Close()
user := testutils.SetupUserData()
// Execute
dat := `{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "all",
"book_uuids": [],
"note_count": 20
}`
req := testutils.MakeReq(server, "POST", "/repetition_rules", dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var ruleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
var rule database.RepetitionRule
testutils.MustExec(t, db.Preload("Books").First(&rule), "finding b1Record")
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
assert.Equal(t, rule.Title, "Rule 1", "rule Title mismatch")
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
assert.Equal(t, rule.Hour, 8, "rule HourTitle mismatch")
assert.Equal(t, rule.Minute, 30, "rule Minute mismatch")
assert.Equal(t, rule.Frequency, int64(604800000), "rule Frequency mismatch")
assert.Equal(t, rule.LastActive, int64(0), "rule LastActive mismatch")
assert.Equal(t, rule.NextActive, int64(1257064200000+604800000), "rule NextActive mismatch")
assert.Equal(t, rule.BookDomain, "all", "rule BookDomain mismatch")
assert.DeepEqual(t, rule.Books, []database.Book{}, "rule Books mismatch")
assert.Equal(t, rule.NoteCount, 20, "rule NoteCount mismatch")
})
bookDomainTestCases := []string{
"including",
"excluding",
}
for _, tc := range bookDomainTestCases {
t.Run(tc, func(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
c := clock.NewMock()
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
c.SetNow(t0)
server := httptest.NewServer(NewRouter(&App{
Clock: c,
}))
defer server.Close()
user := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, db.Save(&b1), "preparing b1")
// Execute
dat := fmt.Sprintf(`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "%s",
"book_uuids": ["%s"],
"note_count": 20
}`, tc, b1.UUID)
req := testutils.MakeReq(server, "POST", "/repetition_rules", dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var ruleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
var rule database.RepetitionRule
testutils.MustExec(t, db.Preload("Books").First(&rule), "finding b1Record")
var b1Record database.Book
testutils.MustExec(t, db.Where("id = ?", b1.ID).First(&b1Record), "finding b1Record")
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
assert.Equal(t, rule.Title, "Rule 1", "rule Title mismatch")
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
assert.Equal(t, rule.Hour, 8, "rule HourTitle mismatch")
assert.Equal(t, rule.Minute, 30, "rule Minute mismatch")
assert.Equal(t, rule.LastActive, int64(0), "rule LastActive mismatch")
assert.Equal(t, rule.NextActive, int64(1257064200000+604800000), "rule NextActive mismatch")
assert.Equal(t, rule.Frequency, int64(604800000), "rule Frequency mismatch")
assert.Equal(t, rule.BookDomain, tc, "rule BookDomain mismatch")
assert.DeepEqual(t, rule.Books, []database.Book{b1Record}, "rule Books mismatch")
assert.Equal(t, rule.NoteCount, 20, "rule NoteCount mismatch")
})
}
}
func TestUpdateRepetitionRules(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
c := clock.NewMock()
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
c.SetNow(t0)
server := httptest.NewServer(NewRouter(&App{
Clock: c,
}))
defer server.Close()
user := testutils.SetupUserData()
// Execute
r1 := database.RepetitionRule{
Title: "Rule 1",
UserID: user.ID,
Enabled: false,
Hour: 8,
Minute: 30,
Frequency: 604800000,
LastActive: 1257064200000,
NextActive: 1263088980000,
BookDomain: "all",
Books: []database.Book{},
NoteCount: 20,
}
testutils.MustExec(t, db.Save(&r1), "preparing r1")
b1 := database.Book{
UserID: user.ID,
USN: 11,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing book1")
dat := fmt.Sprintf(`{
"title": "Rule 1 - edited",
"enabled": true,
"hour": 18,
"minute": 40,
"frequency": 259200000,
"book_domain": "including",
"book_uuids": ["%s"],
"note_count": 30
}`, b1.UUID)
endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID)
req := testutils.MakeReq(server, "PATCH", endpoint, dat)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var totalRuleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
var rule database.RepetitionRule
testutils.MustExec(t, db.Preload("Books").First(&rule), "finding b1Record")
var b1Record database.Book
testutils.MustExec(t, db.Where("id = ?", b1.ID).First(&b1Record), "finding b1Record")
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
assert.Equal(t, rule.Title, "Rule 1 - edited", "rule Title mismatch")
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
assert.Equal(t, rule.Hour, 18, "rule HourTitle mismatch")
assert.Equal(t, rule.Minute, 40, "rule Minute mismatch")
assert.Equal(t, rule.Frequency, int64(259200000), "rule Frequency mismatch")
assert.Equal(t, rule.LastActive, int64(1257064200000), "rule LastActive mismatch")
assert.Equal(t, rule.NextActive, int64(1257100800000+259200000), "rule NextActive mismatch")
assert.Equal(t, rule.BookDomain, "including", "rule BookDomain mismatch")
assert.DeepEqual(t, rule.Books, []database.Book{b1Record}, "rule Books mismatch")
assert.Equal(t, rule.NoteCount, 30, "rule NoteCount mismatch")
}
func TestDeleteRepetitionRules(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
user := testutils.SetupUserData()
// Execute
r1 := database.RepetitionRule{
Title: "Rule 1",
UserID: user.ID,
Enabled: true,
Hour: 8,
Minute: 30,
Frequency: 604800000,
BookDomain: "all",
Books: []database.Book{},
NoteCount: 20,
}
testutils.MustExec(t, db.Save(&r1), "preparing r1")
r2 := database.RepetitionRule{
Title: "Rule 1",
UserID: user.ID,
Enabled: true,
Hour: 8,
Minute: 30,
Frequency: 604800000,
BookDomain: "all",
Books: []database.Book{},
NoteCount: 20,
}
testutils.MustExec(t, db.Save(&r2), "preparing r2")
endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID)
req := testutils.MakeReq(server, "DELETE", endpoint, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var totalRuleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
var r2Count int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Where("id = ?", r2.ID).Count(&r2Count), "counting r2")
assert.Equalf(t, r2Count, 1, "r2 count mismatch")
}
func TestCreateUpdateRepetitionRules_BadRequest(t *testing.T) {
testCases := []string{
// empty title
`{
"title": "",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "all",
"book_uuids": [],
"note_count": 20
}`,
// empty frequency
`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 0,
"book_domain": "some_invalid_book_domain",
"book_uuids": [],
"note_count": 20
}`,
// empty note count
`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "all",
"book_uuids": [],
"note_count": 0
}`,
// invalid book doamin
`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "some_invalid_book_domain",
"book_uuids": [],
"note_count": 20
}`,
// invalid combination of book domain and book_uuids
`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "excluding",
"book_uuids": [],
"note_count": 20
}`,
`{
"title": "Rule 1",
"enabled": true,
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "including",
"book_uuids": [],
"note_count": 20
}`,
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case - create %d", idx), func(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
user := testutils.SetupUserData()
// Execute
req := testutils.MakeReq(server, "POST", "/repetition_rules", tc)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
var ruleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch")
})
t.Run(fmt.Sprintf("test case %d - update", idx), func(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
user := testutils.SetupUserData()
r1 := database.RepetitionRule{
Title: "Rule 1",
UserID: user.ID,
Enabled: false,
Hour: 8,
Minute: 30,
Frequency: 604800000,
BookDomain: "all",
Books: []database.Book{},
NoteCount: 20,
}
testutils.MustExec(t, db.Save(&r1), "preparing r1")
b1 := database.Book{
UserID: user.ID,
USN: 11,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing book1")
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
// Execute
req := testutils.MakeReq(server, "PATCH", fmt.Sprintf("/repetition_rules/%s", r1.UUID), tc)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
var ruleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
})
}
}
func TestCreateRepetitionRules_BadRequest(t *testing.T) {
testCases := []string{
// no enabeld field
`{
"title": "Rule #1",
"hour": 8,
"minute": 30,
"frequency": 604800000,
"book_domain": "all",
"book_uuids": [],
"note_count": 20
}`,
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
// Setup
server := httptest.NewServer(NewRouter(&App{
Clock: clock.NewMock(),
}))
defer server.Close()
user := testutils.SetupUserData()
// Execute
req := testutils.MakeReq(server, "POST", "/repetition_rules", tc)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
var ruleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch")
})
}
}

View file

@ -38,6 +38,9 @@ import (
// ErrInvalidAuthHeader is an error for invalid format of Authorization HTTP header
var ErrInvalidAuthHeader = errors.New("Invalid authorization header")
// ErrForbidden is an error for forbidden requests
var ErrForbidden = errors.New("forbidden")
// Route represents a single route
type Route struct {
Method string
@ -66,6 +69,10 @@ func parseAuthHeader(h string) (authHeader, error) {
return parsed, nil
}
func respondForbidden(w http.ResponseWriter) {
http.Error(w, "forbidden", http.StatusForbidden)
}
func respondUnauthorized(w http.ResponseWriter) {
unsetSessionCookie(w)
w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`)
@ -146,7 +153,7 @@ func getCredential(r *http.Request) (string, error) {
return ret, nil
}
func authWithSession(r *http.Request) (database.User, bool, error) {
func authWithSession(r *http.Request, p *authMiddlewareParams) (database.User, bool, error) {
db := database.DBConn
var user database.User
@ -180,10 +187,16 @@ func authWithSession(r *http.Request) (database.User, bool, error) {
return user, false, errors.Wrap(err, "finding user from token")
}
if p != nil && p.ProOnly {
if !user.Cloud {
return user, false, ErrForbidden
}
}
return user, true, nil
}
func authWithToken(r *http.Request, tokenType string) (database.User, database.Token, bool, error) {
func authWithToken(r *http.Request, tokenType string, p *authMiddlewareParams) (database.User, database.Token, bool, error) {
db := database.DBConn
var user database.User
var token database.Token
@ -209,6 +222,12 @@ func authWithToken(r *http.Request, tokenType string) (database.User, database.T
return user, token, false, errors.Wrap(err, "finding user")
}
if p != nil && p.ProOnly {
if !user.Cloud {
return user, token, false, ErrForbidden
}
}
return user, token, true, nil
}
@ -218,17 +237,14 @@ type authMiddlewareParams struct {
func auth(next http.HandlerFunc, p *authMiddlewareParams) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok, err := authWithSession(r)
user, ok, err := authWithSession(r, p)
if !ok || err != nil {
respondUnauthorized(w)
return
}
if p != nil && p.ProOnly {
if !user.Cloud {
if err == ErrForbidden {
http.Error(w, "forbidden", http.StatusForbidden)
return
} else {
respondUnauthorized(w)
}
return
}
ctx := context.WithValue(r.Context(), helpers.KeyUser, user)
@ -236,10 +252,15 @@ func auth(next http.HandlerFunc, p *authMiddlewareParams) http.HandlerFunc {
})
}
func tokenAuth(next http.HandlerFunc, tokenType string) http.HandlerFunc {
func tokenAuth(next http.HandlerFunc, tokenType string, p *authMiddlewareParams) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, token, ok, err := authWithToken(r, tokenType)
user, token, ok, err := authWithToken(r, tokenType, p)
if err != nil {
if err == ErrForbidden {
respondForbidden(w)
return
}
// log the error and continue
log.ErrorWrap(err, "authenticating with token")
}
@ -250,14 +271,18 @@ func tokenAuth(next http.HandlerFunc, tokenType string) http.HandlerFunc {
ctx = context.WithValue(ctx, helpers.KeyToken, token)
} else {
// If token-based auth fails, fall back to session-based auth
user, ok, err = authWithSession(r)
user, ok, err = authWithSession(r, p)
if err != nil {
// log the error and continue
log.ErrorWrap(err, "authenticating with session")
}
if !ok {
respondUnauthorized(w)
if err == ErrForbidden {
respondForbidden(w)
} else {
respondUnauthorized(w)
}
return
}
}
@ -353,8 +378,8 @@ func NewRouter(app *App) *mux.Router {
{"PATCH", "/reset-password", app.resetPassword, true},
{"PATCH", "/account/profile", auth(app.updateProfile, nil), true},
{"PATCH", "/account/password", auth(app.updatePassword, nil), true},
{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true},
{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true},
{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference, nil), true},
{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference, nil), true},
{"POST", "/subscriptions", auth(app.createSub, nil), true},
{"PATCH", "/subscriptions", auth(app.updateSub, nil), true},
{"POST", "/webhooks/stripe", app.stripeWebhook, true},
@ -364,6 +389,11 @@ func NewRouter(app *App) *mux.Router {
{"GET", "/notes", auth(app.getNotes, &proOnly), false},
{"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true},
{"GET", "/calendar", auth(app.getCalendar, &proOnly), true},
{"GET", "/repetition_rules", auth(app.getRepetitionRules, &proOnly), true},
{"GET", "/repetition_rules/{repetitionRuleUUID}", tokenAuth(app.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
{"POST", "/repetition_rules", auth(app.createRepetitionRule, &proOnly), true},
{"PATCH", "/repetition_rules/{repetitionRuleUUID}", tokenAuth(app.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
{"DELETE", "/repetition_rules/{repetitionRuleUUID}", auth(app.deleteRepetitionRule, &proOnly), true},
// migration of classic users
{"GET", "/classic/presignin", cors(app.classicPresignin), true},

View file

@ -224,7 +224,7 @@ func TestAuthMiddleware(t *testing.T) {
expectedStatus: http.StatusUnauthorized,
},
{
header: fmt.Sprintf("Bearer neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0="),
header: fmt.Sprintf("Bearer someInvalidSessionKey="),
expectedStatus: http.StatusUnauthorized,
},
}
@ -267,7 +267,7 @@ func TestAuthMiddleware(t *testing.T) {
{
cookie: &http.Cookie{
Name: "id",
Value: "neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0=",
Value: "someInvalidSessionKey=",
HttpOnly: true,
},
expectedStatus: http.StatusUnauthorized,
@ -299,6 +299,96 @@ func TestAuthMiddleware(t *testing.T) {
})
}
func TestAuthMiddleware_ProOnly(t *testing.T) {
defer testutils.ClearData()
// set up
db := database.DBConn
user := testutils.SetupUserData()
testutils.MustExec(t, db.Model(&user).Update("cloud", false), "preparing session")
session := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
UserID: user.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, db.Save(&session), "preparing session")
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
server := httptest.NewServer(auth(handler, &authMiddlewareParams{
ProOnly: true,
}))
defer server.Close()
t.Run("with header", func(t *testing.T) {
testCases := []struct {
header string
expectedStatus int
}{
{
header: fmt.Sprintf("Bearer %s", session.Key),
expectedStatus: http.StatusForbidden,
},
{
header: fmt.Sprintf("Bearer someInvalidSessionKey="),
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.header, func(t *testing.T) {
req := testutils.MakeReq(server, "GET", "/", "")
req.Header.Set("Authorization", tc.header)
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch")
})
}
})
t.Run("with cookie", func(t *testing.T) {
testCases := []struct {
cookie *http.Cookie
expectedStatus int
}{
{
cookie: &http.Cookie{
Name: "id",
Value: session.Key,
HttpOnly: true,
},
expectedStatus: http.StatusForbidden,
},
{
cookie: &http.Cookie{
Name: "id",
Value: "someInvalidSessionKey=",
HttpOnly: true,
},
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.cookie.Value, func(t *testing.T) {
req := testutils.MakeReq(server, "GET", "/", "")
req.AddCookie(tc.cookie)
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch")
})
}
})
}
func TestTokenAuthMiddleWare(t *testing.T) {
defer testutils.ClearData()
@ -322,7 +412,7 @@ func TestTokenAuthMiddleWare(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference))
server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, nil))
defer server.Close()
t.Run("with token", func(t *testing.T) {
@ -335,7 +425,7 @@ func TestTokenAuthMiddleWare(t *testing.T) {
expectedStatus: http.StatusOK,
},
{
token: "UlcKclI67wHfpbc1AX6skw==",
token: "someRandomToken==",
expectedStatus: http.StatusUnauthorized,
},
}
@ -363,7 +453,7 @@ func TestTokenAuthMiddleWare(t *testing.T) {
expectedStatus: http.StatusOK,
},
{
header: fmt.Sprintf("Bearer neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0="),
header: fmt.Sprintf("Bearer someInvalidSessionKey="),
expectedStatus: http.StatusUnauthorized,
},
}
@ -398,7 +488,141 @@ func TestTokenAuthMiddleWare(t *testing.T) {
{
cookie: &http.Cookie{
Name: "id",
Value: "neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0=",
Value: "someInvalidSessionKey=",
HttpOnly: true,
},
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.cookie.Value, func(t *testing.T) {
req := testutils.MakeReq(server, "GET", "/", "")
req.AddCookie(tc.cookie)
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch")
})
}
})
t.Run("without anything", func(t *testing.T) {
req := testutils.MakeReq(server, "GET", "/", "")
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, http.StatusUnauthorized, "status code mismatch")
})
}
func TestTokenAuthMiddleWare_ProOnly(t *testing.T) {
defer testutils.ClearData()
// set up
db := database.DBConn
user := testutils.SetupUserData()
testutils.MustExec(t, db.Model(&user).Update("cloud", false), "preparing session")
tok := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailPreference,
Value: "xpwFnc0MdllFUePDq9DLeQ==",
}
testutils.MustExec(t, db.Save(&tok), "preparing token")
session := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
UserID: user.ID,
ExpiresAt: time.Now().Add(time.Hour * 24),
}
testutils.MustExec(t, db.Save(&session), "preparing session")
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, &authMiddlewareParams{
ProOnly: true,
}))
defer server.Close()
t.Run("with token", func(t *testing.T) {
testCases := []struct {
token string
expectedStatus int
}{
{
token: "xpwFnc0MdllFUePDq9DLeQ==",
expectedStatus: http.StatusForbidden,
},
{
token: "someRandomToken==",
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.token, func(t *testing.T) {
req := testutils.MakeReq(server, "GET", fmt.Sprintf("/?token=%s", tc.token), "")
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch")
})
}
})
t.Run("with session header", func(t *testing.T) {
testCases := []struct {
header string
expectedStatus int
}{
{
header: fmt.Sprintf("Bearer %s", session.Key),
expectedStatus: http.StatusForbidden,
},
{
header: fmt.Sprintf("Bearer someInvalidSessionKey="),
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.header, func(t *testing.T) {
req := testutils.MakeReq(server, "GET", "/", "")
req.Header.Set("Authorization", tc.header)
// execute
res := testutils.HTTPDo(t, req)
// test
assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch")
})
}
})
t.Run("with session cookie", func(t *testing.T) {
testCases := []struct {
cookie *http.Cookie
expectedStatus int
}{
{
cookie: &http.Cookie{
Name: "id",
Value: session.Key,
HttpOnly: true,
},
expectedStatus: http.StatusForbidden,
},
{
cookie: &http.Cookie{
Name: "id",
Value: "someInvalidSessionKey=",
HttpOnly: true,
},
expectedStatus: http.StatusUnauthorized,

View file

@ -331,7 +331,7 @@ func (a *App) getSub(w http.ResponseWriter, r *http.Request) {
resp.Items = append(resp.Items, i)
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
// GetStripeSourceResponse is a response for getStripeToken
@ -345,7 +345,7 @@ type GetStripeSourceResponse struct {
func respondWithEmptyStripeToken(w http.ResponseWriter) {
var resp GetStripeSourceResponse
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
// getStripeCard retrieves card information from stripe and returns a stripe.Card
@ -506,7 +506,7 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) {
ExpYear: cd.ExpYear,
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) {

View file

@ -122,7 +122,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) {
payload[d.Format("2006-1-2")] = count
}
respondJSON(w, payload)
respondJSON(w, http.StatusOK, payload)
}
func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) {
@ -272,7 +272,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) {
}
session := makeSession(user, account)
respondJSON(w, session)
respondJSON(w, http.StatusOK, session)
}
type updateEmailPreferencePayload struct {
@ -321,7 +321,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
tx.Commit()
respondJSON(w, frequency)
respondJSON(w, http.StatusOK, frequency)
}
func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) {
@ -340,7 +340,7 @@ func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) {
}
presented := presenters.PresentEmailPreference(pref)
respondJSON(w, presented)
respondJSON(w, http.StatusOK, presented)
}
type updatePasswordPayload struct {

View file

@ -118,6 +118,10 @@ func TestRegister(t *testing.T) {
assert.Equal(t, user.StripeCustomerID, "", "StripeCustomerID mismatch")
assert.Equal(t, user.MaxUSN, 0, "MaxUSN mismatch")
var repetitionRuleCount int
testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Where("user_id = ?", account.UserID).Count(&repetitionRuleCount), "counting repetition rules")
assert.Equal(t, repetitionRuleCount, 1, "repetitionRuleCount mismatch")
// after register, should sign in user
assertSessionResp(t, res)
})

View file

@ -91,7 +91,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) {
resp := CreateBookResp{
Book: presenters.PresentBook(book),
}
respondJSON(w, resp)
respondJSON(w, http.StatusCreated, resp)
}
// BooksOptions is a handler for OPTIONS endpoint for notes
@ -129,7 +129,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) {
}
presentedBooks := presenters.PresentBooks(books)
respondJSON(w, presentedBooks)
respondJSON(w, http.StatusOK, presentedBooks)
}
// GetDemoBooks returns books for demo
@ -182,7 +182,7 @@ func (a *App) GetBook(w http.ResponseWriter, r *http.Request) {
}
p := presenters.PresentBook(book)
respondJSON(w, p)
respondJSON(w, http.StatusOK, p)
}
type updateBookPayload struct {
@ -231,7 +231,7 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) {
resp := UpdateBookResp{
Book: presenters.PresentBook(book),
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
// DeleteBookResp is the response from create book api
@ -283,5 +283,5 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) {
Status: http.StatusOK,
Book: presenters.PresentBook(b),
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}

View file

@ -373,7 +373,7 @@ func TestCreateBook(t *testing.T) {
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var bookRecord database.Book
var userRecord database.User

View file

@ -101,7 +101,7 @@ func (a *App) UpdateNote(w http.ResponseWriter, r *http.Request) {
Status: http.StatusOK,
Result: presenters.PresentNote(note),
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
type deleteNoteResp struct {
@ -143,7 +143,7 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) {
Status: http.StatusNoContent,
Result: presenters.PresentNote(n),
}
respondJSON(w, resp)
respondJSON(w, http.StatusOK, resp)
}
type createNotePayload struct {
@ -207,7 +207,7 @@ func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) {
resp := CreateNoteResp{
Result: presenters.PresentNote(note),
}
respondJSON(w, resp)
respondJSON(w, http.StatusCreated, resp)
}
// NotesOptions is a handler for OPTIONS endpoint for notes

View file

@ -60,7 +60,7 @@ func TestCreateNote(t *testing.T) {
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var noteRecord database.Note
var bookRecord database.Book

View file

@ -271,7 +271,7 @@ func (a *App) GetSyncFragment(w http.ResponseWriter, r *http.Request) {
response := GetSyncFragmentResp{
Fragment: fragment,
}
respondJSON(w, response)
respondJSON(w, http.StatusOK, response)
}
// GetSyncStateResp represents a response from GetSyncFragment handler
@ -301,5 +301,5 @@ func (a *App) GetSyncState(w http.ResponseWriter, r *http.Request) {
"resp": response,
}).Info("getting sync state")
respondJSON(w, response)
respondJSON(w, http.StatusOK, response)
}

View file

@ -20,8 +20,8 @@ package helpers
import (
"github.com/dnote/dnote/pkg/server/database"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/satori/go.uuid"
)
const (
@ -42,7 +42,19 @@ func GetDemoUserID() (int, error) {
return result.UserID, nil
}
// GenUUID generates a new uuid
func GenUUID() string {
return uuid.NewV4().String()
// GenUUID generates a new uuid v4
func GenUUID() (string, error) {
id, err := uuid.NewRandom()
if err != nil {
return "", errors.Wrap(err, "generating uuid")
}
return id.String(), nil
}
// ValidateUUID validates the given uuid
func ValidateUUID(u string) bool {
_, err := uuid.Parse(u)
return err == nil
}

View file

@ -37,8 +37,13 @@ func CreateBook(user database.User, clock clock.Clock, name string) (database.Bo
return database.Book{}, errors.Wrap(err, "incrementing user max_usn")
}
uuid, err := helpers.GenUUID()
if err != nil {
return database.Book{}, err
}
book := database.Book{
UUID: helpers.GenUUID(),
UUID: uuid,
UserID: user.ID,
Label: name,
AddedOn: clock.Now().UnixNano(),

View file

@ -52,8 +52,13 @@ func CreateNote(user database.User, clock clock.Clock, bookUUID, content string,
noteEditedOn = *editedOn
}
uuid, err := helpers.GenUUID()
if err != nil {
return database.Note{}, err
}
note := database.Note{
UUID: helpers.GenUUID(),
UUID: uuid,
BookUUID: bookUUID,
UserID: user.ID,
AddedOn: noteAddedOn,

View file

@ -74,17 +74,35 @@ func createEmailVerificaitonToken(user database.User, tx *gorm.DB) error {
}
func createEmailPreference(user database.User, tx *gorm.DB) error {
EmailPreference := database.EmailPreference{
UserID: user.ID,
DigestWeekly: true,
p := database.EmailPreference{
UserID: user.ID,
}
if err := tx.Save(&EmailPreference).Error; err != nil {
if err := tx.Save(&p).Error; err != nil {
return errors.Wrap(err, "inserting email preference")
}
return nil
}
func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error {
r := database.RepetitionRule{
Title: "Default repetition - all bookx",
UserID: user.ID,
Enabled: true,
Hour: 20,
Minute: 30,
Frequency: 604800000,
BookDomain: database.BookDomainAll,
Books: []database.Book{},
NoteCount: 20,
}
if err := tx.Save(&r).Error; err != nil {
return errors.Wrap(err, "inserting repetition rule")
}
return nil
}
// CreateUser creates a user
func CreateUser(email, password string) (database.User, error) {
db := database.DBConn
@ -119,6 +137,10 @@ func CreateUser(email, password string) (database.User, error) {
tx.Rollback()
return database.User{}, errors.Wrap(err, "creating email preference")
}
if err := createDefaultRepetitionRule(user, tx); err != nil {
tx.Rollback()
return database.User{}, errors.Wrap(err, "creating default repetition rule")
}
if err := TouchLastLoginAt(user, tx); err != nil {
tx.Rollback()
return database.User{}, errors.Wrap(err, "updating last login")

View file

@ -0,0 +1,57 @@
/* 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/>.
*/
package presenters
import (
"time"
"github.com/dnote/dnote/pkg/server/database"
)
// Book is a result of PresentBooks
type Book struct {
UUID string `json:"uuid"`
USN int `json:"usn"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Label string `json:"label"`
}
// PresentBook presents a book
func PresentBook(book database.Book) Book {
return Book{
UUID: book.UUID,
USN: book.USN,
CreatedAt: FormatTS(book.CreatedAt),
UpdatedAt: FormatTS(book.UpdatedAt),
Label: book.Label,
}
}
// PresentBooks presents books
func PresentBooks(books []database.Book) []Book {
ret := []Book{}
for _, book := range books {
p := PresentBook(book)
ret = append(ret, p)
}
return ret
}

View file

@ -0,0 +1,60 @@
/* 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/>.
*/
package presenters
import (
"time"
"github.com/dnote/dnote/pkg/server/database"
)
// Digest is a presented digest
type Digest struct {
UUID string `json:"uuid"`
Notes []Note `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentDigests presetns digests
func PresentDigests(digests []database.Digest) []Digest {
ret := []Digest{}
for _, digest := range digests {
p := Digest{
UUID: digest.UUID,
CreatedAt: digest.CreatedAt,
UpdatedAt: digest.UpdatedAt,
}
ret = append(ret, p)
}
return ret
}
// PresentDigest presents a digest
func PresentDigest(digest database.Digest) Digest {
ret := Digest{
UUID: digest.UUID,
Notes: PresentNotes(digest.Notes),
}
return ret
}

View file

@ -16,40 +16,28 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
// .form-container {
// .actions {
// margin-top: 12px;
// }
// }
//
// .input-help {
// font-size: 1.3rem;
// color: gray;
// margin-top: 5px;
// }
//
// .input-group {
// .copy-button {
// border-width: 1px;
// background: transparent;
// border-color: #ced4da;
// color: #333744;
// width: 85px;
//
// &:hover {
// box-shadow: 0px 0px 4px 2px #eaeaea;
// background: #fbfbfb;
// }
//
// &.copied {
// }
// }
// }
//
// .danger-zone {
// margin-top: 26px;
//
// .danger-zone-heading {
// margin-bottom: 18px;
// }
// }
package presenters
import (
"time"
"github.com/dnote/dnote/pkg/server/database"
)
// EmailPreference is a presented email digest
type EmailPreference struct {
DigestWeekly bool `json:"digest_weekly"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentEmailPreference presents a digest
func PresentEmailPreference(p database.EmailPreference) EmailPreference {
ret := EmailPreference{
DigestWeekly: p.DigestWeekly,
CreatedAt: FormatTS(p.CreatedAt),
UpdatedAt: FormatTS(p.UpdatedAt),
}
return ret
}

View file

@ -0,0 +1,29 @@
/* 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/>.
*/
package presenters
import (
"time"
)
// FormatTS rounds up the given timestamp to the microsecond
// so as to make the times in the responses consistent
func FormatTS(ts time.Time) time.Time {
return ts.UTC().Round(time.Microsecond)
}

View file

@ -24,44 +24,6 @@ import (
"github.com/dnote/dnote/pkg/server/database"
)
// FormatTS rounds up the given timestamp to the microsecond
// so as to make the times in the responses consistent
func FormatTS(ts time.Time) time.Time {
return ts.UTC().Round(time.Microsecond)
}
// Book is a result of PresentBooks
type Book struct {
UUID string `json:"uuid"`
USN int `json:"usn"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Label string `json:"label"`
}
// PresentBook presents a book
func PresentBook(book database.Book) Book {
return Book{
UUID: book.UUID,
USN: book.USN,
CreatedAt: FormatTS(book.CreatedAt),
UpdatedAt: FormatTS(book.UpdatedAt),
Label: book.Label,
}
}
// PresentBooks presents books
func PresentBooks(books []database.Book) []Book {
ret := []Book{}
for _, book := range books {
p := PresentBook(book)
ret = append(ret, p)
}
return ret
}
// Note is a result of PresentNote
type Note struct {
UUID string `json:"uuid"`
@ -121,56 +83,3 @@ func PresentNotes(notes []database.Note) []Note {
return ret
}
// Digest is a presented digest
type Digest struct {
UUID string `json:"uuid"`
Notes []Note `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentDigests presetns digests
func PresentDigests(digests []database.Digest) []Digest {
ret := []Digest{}
for _, digest := range digests {
p := Digest{
UUID: digest.UUID,
CreatedAt: digest.CreatedAt,
UpdatedAt: digest.UpdatedAt,
}
ret = append(ret, p)
}
return ret
}
// PresentDigest presents a digest
func PresentDigest(digest database.Digest) Digest {
ret := Digest{
UUID: digest.UUID,
Notes: PresentNotes(digest.Notes),
}
return ret
}
// EmailPreference is a presented email digest
type EmailPreference struct {
DigestWeekly bool `json:"digest_weekly"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentEmailPreference presents a digest
func PresentEmailPreference(p database.EmailPreference) EmailPreference {
ret := EmailPreference{
DigestWeekly: p.DigestWeekly,
CreatedAt: FormatTS(p.CreatedAt),
UpdatedAt: FormatTS(p.UpdatedAt),
}
return ret
}

View file

@ -0,0 +1,75 @@
/* 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/>.
*/
package presenters
import (
"time"
"github.com/dnote/dnote/pkg/server/database"
)
// RepetitionRule is a presented digest rule
type RepetitionRule struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
Hour int `json:"hour" gorm:"index"`
Minute int `json:"minute" gorm:"index"`
Frequency int64 `json:"frequency"`
BookDomain string `json:"book_domain"`
LastActive int64 `json:"last_active"`
NextActive int64 `json:"next_active"`
Books []Book `json:"books"`
NoteCount int `json:"note_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentRepetitionRule presents a digest rule
func PresentRepetitionRule(d database.RepetitionRule) RepetitionRule {
ret := RepetitionRule{
UUID: d.UUID,
Title: d.Title,
Enabled: d.Enabled,
Hour: d.Hour,
Minute: d.Minute,
Frequency: d.Frequency,
BookDomain: d.BookDomain,
NoteCount: d.NoteCount,
LastActive: d.LastActive,
NextActive: d.NextActive,
Books: PresentBooks(d.Books),
CreatedAt: FormatTS(d.CreatedAt),
UpdatedAt: FormatTS(d.UpdatedAt),
}
return ret
}
// PresentRepetitionRules presents a slice of digest rules
func PresentRepetitionRules(ds []database.RepetitionRule) []RepetitionRule {
ret := []RepetitionRule{}
for _, d := range ds {
p := PresentRepetitionRule(d)
ret = append(ret, p)
}
return ret
}

View file

@ -0,0 +1,90 @@
/* 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/>.
*/
package presenters
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/server/database"
)
func TestPresentRepetitionRule(t *testing.T) {
b1 := database.Book{UUID: "1cf8794f-4d61-4a9d-a9da-18f8db9e53cc", Label: "foo"}
b2 := database.Book{UUID: "ede00f3b-eab1-469c-ae12-c60cebeeef17", Label: "bar"}
d1 := database.RepetitionRule{
UUID: "c725afb5-8bf1-4581-a0e7-0f683c15f3d0",
Title: "test title",
Enabled: true,
Hour: 1,
Minute: 2,
LastActive: 1571293000,
NextActive: 1571394000,
NoteCount: 10,
BookDomain: database.BookDomainAll,
Books: []database.Book{b1, b2},
}
testCases := []struct {
input database.RepetitionRule
expected RepetitionRule
}{
{
input: d1,
expected: RepetitionRule{
UUID: d1.UUID,
Title: d1.Title,
Enabled: d1.Enabled,
Hour: d1.Hour,
Minute: d1.Minute,
BookDomain: d1.BookDomain,
NoteCount: d1.NoteCount,
LastActive: d1.LastActive,
NextActive: d1.NextActive,
Books: []Book{
{
UUID: b1.UUID,
USN: b1.USN,
CreatedAt: b1.CreatedAt,
UpdatedAt: b1.UpdatedAt,
Label: b1.Label,
},
{
UUID: b2.UUID,
USN: b2.USN,
CreatedAt: b2.CreatedAt,
UpdatedAt: b2.UpdatedAt,
Label: b2.Label,
},
},
CreatedAt: d1.CreatedAt,
UpdatedAt: d1.UpdatedAt,
},
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
result := PresentRepetitionRule(tc.input)
assert.DeepEqual(t, result, tc.expected, "result mismatch")
})
}
}

View file

@ -1,8 +0,0 @@
#!/usr/bin/env bash
# test.sh runs api tests. It is to be invoked by other scripts that set
# appropriate env vars.
set -eux
pushd "$GOPATH"/src/github.com/dnote/dnote/pkg/server/api
go test ./... -cover -p 1
popd

View file

@ -0,0 +1,39 @@
/* 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/>.
*/
package database
const (
// TokenTypeResetPassword is a type of a token for reseting password
TokenTypeResetPassword = "reset_password"
// TokenTypeEmailVerification is a type of a token for verifying email
TokenTypeEmailVerification = "email_verification"
// TokenTypeEmailPreference is a type of a token for updating email preference
TokenTypeEmailPreference = "email_preference"
// TokenTypeRepetition is a type of a token for viewing and editing repetition rules
TokenTypeRepetition = "repetition_rules"
)
const (
// BookDomainAll incidates that all books are eligible to be the source books
BookDomainAll = "all"
// BookDomainIncluding incidates that some specified books are eligible to be the source books
BookDomainIncluding = "including"
// BookDomainExluding incidates that all books except for some specified books are eligible to be the source books
BookDomainExluding = "excluding"
)

View file

@ -100,15 +100,6 @@ var (
DBConn *gorm.DB
)
const (
// TokenTypeResetPassword is a type of a token for reseting password
TokenTypeResetPassword = "reset_password"
// TokenTypeEmailVerification is a type of a token for verifying email
TokenTypeEmailVerification = "email_verification"
// TokenTypeEmailPreference is a type of a token for updating email preference
TokenTypeEmailPreference = "email_preference"
)
// Open opens the connection with the database
func Open(c Config) {
connStr, err := getPGConnectionString(c)
@ -143,6 +134,7 @@ func InitSchema() {
EmailPreference{},
Session{},
Digest{},
RepetitionRule{},
).Error; err != nil {
panic(err)
}

View file

@ -0,0 +1,45 @@
-- create-weekly-repetition.sql creates the default repetition rules for the users
-- that used to have the weekly email digest on Friday 20:00 UTC
-- +migrate Up
WITH next_friday AS (
SELECT * FROM generate_series(
CURRENT_DATE + INTERVAL '1 day',
CURRENT_DATE + INTERVAL '7 days',
INTERVAL '1 day'
) AS day
)
INSERT INTO repetition_rules
(
user_id,
title,
enabled,
hour,
minute,
frequency,
last_active,
book_domain,
note_count,
next_active
) SELECT
t1.id,
'Default weekly repetition',
true,
20,
0,
604800000, -- 7 days
0,
'all',
20,
extract(epoch FROM date_trunc('day', t1.day) + INTERVAL '20 hours') * 1000
FROM (
SELECT * FROM users
INNER JOIN next_friday ON EXTRACT(ISODOW FROM day) = '5' -- next friday
WHERE users.cloud = true
) as t1;
-- +migrate Down
DELETE FROM repetition_rules WHERE title = 'Default weekly repetition' AND enabled AND hour = 8 AND minute = 0;

View file

@ -24,7 +24,7 @@ import (
// Model is the base model definition
type Model struct {
ID int `gorm:"primary_key" json:"id"`
ID int `gorm:"primary_key" json:"-"`
CreatedAt time.Time `json:"created_at" gorm:"default:now()"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -113,7 +113,7 @@ type Notification struct {
type EmailPreference struct {
Model
UserID int `gorm:"index" json:"-"`
DigestWeekly bool `json:"digest_weekly"`
DigestWeekly bool `json:"digest_weekly"` // Deprecated: email digests now sends based on the repetition rule
}
// Session represents a user session
@ -128,8 +128,29 @@ type Session struct {
// Digest is a digest of notes
type Digest struct {
UUID string `json:"uuid" gorm:"primary_key:true;type:uuid;index;default:uuid_generate_v4()"`
RuleID int `gorm:"index"`
UserID int `gorm:"index"`
Notes []Note `gorm:"many2many:digest_notes;association_foreignKey:uuid;association_jointable_foreignkey:note_uuid;jointable_foreignkey:digest_uuid;"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepetitionRule is the rules for sending digest emails
type RepetitionRule struct {
Model
UUID string `json:"uuid" gorm:"type:uuid;index;default:uuid_generate_v4()"`
UserID int `json:"user_id" gorm:"index"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
Hour int `json:"hour" gorm:"index"`
Minute int `json:"minute" gorm:"index"`
// in milliseconds
Frequency int64 `json:"frequency"`
// in milliseconds
LastActive int64 `json:"last_active"`
// in milliseconds
NextActive int64 `json:"next_active"`
BookDomain string `json:"book_domain"`
Books []Book `gorm:"many2many:repetition_rule_books;"`
NoteCount int `json:"note_count"`
}

View file

@ -1,151 +0,0 @@
/* 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/>.
*/
package job
import (
"log"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/pkg/errors"
)
// MakeDigest builds a weekly digest email
func MakeDigest(user database.User, emailAddr string) (*mailer.Email, error) {
log.Printf("Sending for %s", emailAddr)
db := database.DBConn
subject := "Weekly Digest"
tok, err := mailer.GetEmailPreferenceToken(user)
if err != nil {
return nil, errors.Wrap(err, "getting email frequency token")
}
now := time.Now()
threshold1 := int(now.AddDate(0, 0, -1).UnixNano())
threshold2 := int(now.AddDate(0, 0, -3).UnixNano())
threshold3 := int(now.AddDate(0, 0, -7).UnixNano())
var stage1 []database.Note
var stage2 []database.Note
var stage3 []database.Note
// TODO: ordering by random() does not scale if table grows large
if err := db.Where("user_id = ? AND added_on > ? AND added_on < ?", user.ID, threshold2, threshold1).Order("random()").Limit(4).Preload("Book").Find(&stage1).Error; err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 1")
}
if err := db.Where("user_id = ? AND added_on > ? AND added_on < ?", user.ID, threshold3, threshold2).Order("random()").Limit(4).Preload("Book").Find(&stage2).Error; err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 2")
}
if err := db.Where("user_id = ? AND added_on < ?", user.ID, threshold3).Order("random()").Limit(4).Preload("Book").Find(&stage3).Error; err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 3")
}
noteInfos := []mailer.DigestNoteInfo{}
for _, note := range stage1 {
info := mailer.NewNoteInfo(note, 1)
noteInfos = append(noteInfos, info)
}
for _, note := range stage2 {
info := mailer.NewNoteInfo(note, 2)
noteInfos = append(noteInfos, info)
}
for _, note := range stage3 {
info := mailer.NewNoteInfo(note, 3)
noteInfos = append(noteInfos, info)
}
notes := append(append(stage1, stage2...), stage3...)
digest := database.Digest{
UserID: user.ID,
Notes: notes,
}
if err := db.Save(&digest).Error; err != nil {
return nil, errors.Wrap(err, "saving digest")
}
bookCount := 0
bookMap := map[string]bool{}
for _, n := range notes {
if ok := bookMap[n.Book.Label]; !ok {
bookCount++
bookMap[n.Book.Label] = true
}
}
tmplData := mailer.DigestTmplData{
Subject: subject,
NoteInfo: noteInfos,
ActiveBookCount: bookCount,
ActiveNoteCount: len(notes),
EmailSessionToken: tok.Value,
}
email := mailer.NewEmail("notebot@getdnote.com", []string{emailAddr}, subject)
if err := email.ParseTemplate(mailer.EmailTypeWeeklyDigest, tmplData); err != nil {
return nil, err
}
return email, nil
}
// sendDigest sends the weekly digests to users
func sendDigest() error {
db := database.DBConn
var users []database.User
if err := db.
Preload("Account").
Where("cloud = ?", true).
Find(&users).Error; err != nil {
return err
}
for _, user := range users {
account := user.Account
if !account.Email.Valid || !account.EmailVerified {
continue
}
email, err := MakeDigest(user, account.Email.String)
if err != nil {
log.Printf("Error occurred while sending to %s: %s", account.Email.String, err.Error())
continue
}
err = email.Send()
if err != nil {
log.Printf("Error occurred while sending to %s: %s", account.Email.String, err.Error())
continue
}
notif := database.Notification{
Type: "email_weekly",
UserID: user.ID,
}
if err := db.Create(&notif).Error; err != nil {
log.Printf("Error occurred while creating notification for %s: %s", account.Email.String, err.Error())
}
}
return nil
}

View file

@ -21,6 +21,8 @@ package job
import (
"log"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/job/repetition"
"github.com/pkg/errors"
"github.com/robfig/cron"
)
@ -38,9 +40,11 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) {
func Run() {
log.Println("Started background tasks")
cl := clock.New()
// Schedule jobs
c := cron.New()
scheduleJob(c, "0 20 * * 5", func() { sendDigest() })
scheduleJob(c, "* * * * *", func() { repetition.Do(cl) })
c.Start()
// Block forever

View file

@ -0,0 +1,252 @@
/* 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/>.
*/
package repetition
import (
"fmt"
"time"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
// BuildEmail builds an email for the spaced repetition
func BuildEmail(now time.Time, user database.User, emailAddr string, digest database.Digest, rule database.RepetitionRule) (*mailer.Email, error) {
date := now.Format("Jan 02 2006")
subject := fmt.Sprintf("%s %s", rule.Title, date)
tok, err := mailer.GetToken(user, database.TokenTypeRepetition)
if err != nil {
return nil, errors.Wrap(err, "getting email frequency token")
}
t1 := now.AddDate(0, 0, -3).UnixNano()
t2 := now.AddDate(0, 0, -7).UnixNano()
noteInfos := []mailer.DigestNoteInfo{}
for _, note := range digest.Notes {
var stage int
if note.AddedOn > t1 {
stage = 1
} else if note.AddedOn > t2 && note.AddedOn < t1 {
stage = 2
} else if note.AddedOn < t2 {
stage = 3
}
info := mailer.NewNoteInfo(note, stage)
noteInfos = append(noteInfos, info)
}
bookCount := 0
bookMap := map[string]bool{}
for _, n := range digest.Notes {
if ok := bookMap[n.Book.Label]; !ok {
bookCount++
bookMap[n.Book.Label] = true
}
}
tmplData := mailer.DigestTmplData{
Subject: subject,
NoteInfo: noteInfos,
ActiveBookCount: bookCount,
ActiveNoteCount: len(digest.Notes),
EmailSessionToken: tok.Value,
RuleUUID: rule.UUID,
RuleTitle: rule.Title,
}
email := mailer.NewEmail("noreply@getdnote.com", []string{emailAddr}, subject)
if err := email.ParseTemplate(mailer.EmailTypeWeeklyDigest, tmplData); err != nil {
return nil, err
}
return email, nil
}
func getEligibleRules(now time.Time) ([]database.RepetitionRule, error) {
hour := now.Hour()
minute := now.Minute()
var ret []database.RepetitionRule
db := database.DBConn
if err := db.
Where("users.cloud AND repetition_rules.hour = ? AND repetition_rules.minute = ? AND repetition_rules.enabled", hour, minute).
Joins("INNER JOIN users ON users.id = repetition_rules.user_id").
Find(&ret).Error; err != nil {
return nil, errors.Wrap(err, "querying db")
}
return ret, nil
}
func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) {
notes, err := getBalancedNotes(tx, rule)
if err != nil {
return database.Digest{}, errors.Wrap(err, "getting notes")
}
digest := database.Digest{
RuleID: rule.ID,
UserID: rule.UserID,
Notes: notes,
}
if err := tx.Save(&digest).Error; err != nil {
return database.Digest{}, errors.Wrap(err, "saving digest")
}
return digest, nil
}
func notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error {
db := database.DBConn
var account database.Account
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
return errors.Wrap(err, "getting account")
}
if !account.Email.Valid || !account.EmailVerified {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Info("Skipping repetition delivery because email is not valid or verified")
return nil
}
email, err := BuildEmail(now, user, account.Email.String, digest, rule)
if err != nil {
return errors.Wrap(err, "making email")
}
err = email.Send()
if err != nil {
return errors.Wrap(err, "sending email")
}
notif := database.Notification{
Type: "email_weekly",
UserID: user.ID,
}
if err := db.Create(&notif).Error; err != nil {
return errors.Wrap(err, "creating notification")
}
return nil
}
func checkCooldown(now time.Time, rule database.RepetitionRule) bool {
present := now.UnixNano() / int64(time.Millisecond)
return present >= rule.NextActive
}
func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) error {
lastActive := rule.NextActive
rule.LastActive = lastActive
rule.NextActive = lastActive + rule.Frequency
if err := tx.Save(&rule).Error; err != nil {
return errors.Wrap(err, "updating repetition rule")
}
return nil
}
func process(now time.Time, rule database.RepetitionRule) error {
log.WithFields(log.Fields{
"uuid": rule.UUID,
}).Info("processing repetition")
db := database.DBConn
tx := db.Begin()
if !checkCooldown(now, rule) {
return nil
}
var user database.User
if err := tx.Where("id = ?", rule.UserID).First(&user).Error; err != nil {
return errors.Wrap(err, "getting user")
}
if !user.Cloud {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Info("Skipping repetition due to lack of subscription")
return nil
}
digest, err := build(tx, rule)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "building repetition")
}
if err := touchTimestamp(tx, rule, now); err != nil {
tx.Rollback()
return errors.Wrap(err, "touching last_active")
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
return errors.Wrap(err, "committing transaction")
}
if err := notify(now, user, digest, rule); err != nil {
return errors.Wrap(err, "notifying user")
}
log.WithFields(log.Fields{
"uuid": rule.UUID,
}).Info("finished processing repetition")
return nil
}
// Do creates spaced repetitions and delivers the results based on the rules
func Do(c clock.Clock) error {
now := c.Now().UTC()
rules, err := getEligibleRules(now)
if err != nil {
return errors.Wrap(err, "getting eligible repetition rules")
}
log.WithFields(log.Fields{
"hour": now.Hour(),
"minute": now.Minute(),
"num_rules": len(rules),
}).Info("processing rules")
for _, rule := range rules {
if err := process(now, rule); err != nil {
log.WithFields(log.Fields{
"rule uuid": rule.UUID,
}).ErrorWrap(err, "Could not process the repetition rule")
continue
}
}
return nil
}

View file

@ -0,0 +1,384 @@
package repetition
import (
"sort"
"testing"
"time"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
)
func init() {
testutils.InitTestDB()
}
func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) {
db := database.DBConn
var rule database.RepetitionRule
testutils.MustExec(t, db.Where("uuid = ?", ruleUUID).First(&rule), "finding rule1")
assert.Equal(t, rule.LastActive, lastActive, "LastActive mismatch")
}
func assertRepetitionCount(t *testing.T, rule database.RepetitionRule, expected int) {
db := database.DBConn
var digestCount int
testutils.MustExec(t, db.Model(&database.Digest{}).Where("rule_id = ? AND user_id = ?", rule.ID, rule.UserID).Count(&digestCount), "counting digest")
assert.Equal(t, digestCount, expected, "digest count mismatch")
}
func TestDo(t *testing.T) {
t.Run("processes the rule on time", func(t *testing.T) {
defer testutils.ClearData()
// Set up
user := testutils.SetupUserData()
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 3).Milliseconds(), // three days
Hour: 12,
Minute: 2,
Enabled: true,
LastActive: 0,
NextActive: t1.UnixNano() / int64(time.Millisecond),
UserID: user.ID,
BookDomain: database.BookDomainAll,
Model: database.Model{
CreatedAt: t0,
UpdatedAt: t0,
},
}
db := database.DBConn
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
c := clock.NewMock()
// Test
// 1 day later
c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(0))
assertRepetitionCount(t, r1, 0)
// 2 days later
c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(0))
assertRepetitionCount(t, r1, 0)
// 3 days later - should be processed
c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(0))
assertRepetitionCount(t, r1, 0)
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertRepetitionCount(t, r1, 1)
c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertRepetitionCount(t, r1, 1)
// 4 day later
c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertRepetitionCount(t, r1, 1)
// 5 days later
c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertRepetitionCount(t, r1, 1)
// 6 days later - should be processed
c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertRepetitionCount(t, r1, 2)
// 7 days later
c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertRepetitionCount(t, r1, 2)
// 8 days later
c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertRepetitionCount(t, r1, 2)
// 9 days later - should be processed
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
Do(c)
assertLastActive(t, r1.UUID, int64(1257854520000))
assertRepetitionCount(t, r1, 3)
})
}
func TestDo_Disabled(t *testing.T) {
defer testutils.ClearData()
// Set up
user := testutils.SetupUserData()
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 3).Milliseconds(), // three days
Hour: 12,
Minute: 2,
LastActive: 0,
NextActive: t1.UnixNano() / int64(time.Millisecond),
UserID: user.ID,
Enabled: false,
BookDomain: database.BookDomainAll,
Model: database.Model{
CreatedAt: t0,
UpdatedAt: t0,
},
}
db := database.DBConn
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC))
Do(c)
// Test
assertLastActive(t, r1.UUID, int64(0))
assertRepetitionCount(t, r1, 0)
}
func TestDo_BalancedStrategy(t *testing.T) {
type testData struct {
User database.User
Book1 database.Book
Book2 database.Book
Book3 database.Book
Note1 database.Note
Note2 database.Note
Note3 database.Note
}
setup := func() testData {
db := database.DBConn
user := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, db.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: user.ID,
Label: "golang",
}
testutils.MustExec(t, db.Save(&b3), "preparing b3")
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
}
testutils.MustExec(t, db.Save(&n1), "preparing n1")
n2 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
}
testutils.MustExec(t, db.Save(&n2), "preparing n2")
n3 := database.Note{
UserID: user.ID,
BookUUID: b3.UUID,
}
testutils.MustExec(t, db.Save(&n3), "preparing n3")
return testData{
User: user,
Book1: b1,
Book2: b2,
Book3: b3,
Note1: n1,
Note2: n2,
Note3: n3,
}
}
t.Run("all books", func(t *testing.T) {
defer testutils.ClearData()
// Set up
dat := setup()
db := database.DBConn
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 8, 12, 0, 0, 0, time.UTC)
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 7).Milliseconds(),
Hour: 21,
Minute: 0,
LastActive: 0,
NextActive: t1.UnixNano() / int64(time.Millisecond),
Enabled: true,
UserID: dat.User.ID,
BookDomain: database.BookDomainAll,
NoteCount: 5,
Model: database.Model{
CreatedAt: t0,
UpdatedAt: t0,
},
}
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
Do(c)
// Test
assertLastActive(t, r1.UUID, int64(1257681600000))
assertRepetitionCount(t, r1, 1)
var repetition database.Digest
testutils.MustExec(t, db.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
sort.SliceStable(repetition.Notes, func(i, j int) bool {
n1 := repetition.Notes[i]
n2 := repetition.Notes[j]
return n1.ID < n2.ID
})
var n1Record, n2Record, n3Record database.Note
testutils.MustExec(t, db.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
testutils.MustExec(t, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
testutils.MustExec(t, db.Where("uuid = ?", dat.Note3.UUID).First(&n3Record), "finding n3")
expected := []database.Note{n1Record, n2Record, n3Record}
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
})
t.Run("excluding books", func(t *testing.T) {
defer testutils.ClearData()
// Set up
dat := setup()
db := database.DBConn
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 8, 12, 0, 0, 0, time.UTC)
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 7).Milliseconds(),
Hour: 21,
Enabled: true,
Minute: 0,
LastActive: 0,
NextActive: t1.UnixNano() / int64(time.Millisecond),
UserID: dat.User.ID,
BookDomain: database.BookDomainExluding,
Books: []database.Book{dat.Book1},
NoteCount: 5,
Model: database.Model{
CreatedAt: t0,
UpdatedAt: t0,
},
}
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC))
Do(c)
// Test
assertLastActive(t, r1.UUID, int64(1257681600000))
assertRepetitionCount(t, r1, 1)
var repetition database.Digest
testutils.MustExec(t, db.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
sort.SliceStable(repetition.Notes, func(i, j int) bool {
n1 := repetition.Notes[i]
n2 := repetition.Notes[j]
return n1.ID < n2.ID
})
var n2Record, n3Record database.Note
testutils.MustExec(t, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
testutils.MustExec(t, db.Where("uuid = ?", dat.Note3.UUID).First(&n3Record), "finding n3")
expected := []database.Note{n2Record, n3Record}
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
})
t.Run("including books", func(t *testing.T) {
defer testutils.ClearData()
// Set up
dat := setup()
db := database.DBConn
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 8, 12, 0, 0, 0, time.UTC)
r1 := database.RepetitionRule{
Title: "Rule 1",
Frequency: (time.Hour * 24 * 7).Milliseconds(),
Hour: 21,
Enabled: true,
Minute: 0,
LastActive: 0,
NextActive: t1.UnixNano() / int64(time.Millisecond),
UserID: dat.User.ID,
BookDomain: database.BookDomainIncluding,
Books: []database.Book{dat.Book1, dat.Book2},
NoteCount: 5,
Model: database.Model{
CreatedAt: t0,
UpdatedAt: t0,
},
}
testutils.MustExec(t, db.Save(&r1), "preparing rule1")
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
Do(c)
// Test
assertLastActive(t, r1.UUID, int64(1257681600000))
assertRepetitionCount(t, r1, 1)
var repetition database.Digest
testutils.MustExec(t, db.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
sort.SliceStable(repetition.Notes, func(i, j int) bool {
n1 := repetition.Notes[i]
n2 := repetition.Notes[j]
return n1.ID < n2.ID
})
var n1Record, n2Record database.Note
testutils.MustExec(t, db.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
testutils.MustExec(t, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
expected := []database.Note{n1Record, n2Record}
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
})
}

View file

@ -0,0 +1,141 @@
/* 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/>.
*/
package repetition
import (
"sort"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
func getRuleBookIDs(ruleID int) ([]int, error) {
db := database.DBConn
var ret []int
if err := db.Table("repetition_rule_books").Select("book_id").Where("repetition_rule_id = ?", ruleID).Pluck("book_id", &ret).Error; err != nil {
return nil, errors.Wrap(err, "querying book_ids")
}
return ret, nil
}
func applyBookDomain(noteQuery *gorm.DB, rule database.RepetitionRule) (*gorm.DB, error) {
ret := noteQuery
if rule.BookDomain != database.BookDomainAll {
bookIDs, err := getRuleBookIDs(rule.ID)
if err != nil {
return nil, errors.Wrap(err, "getting book_ids")
}
ret = ret.Joins("INNER JOIN books ON notes.book_uuid = books.uuid")
if rule.BookDomain == database.BookDomainExluding {
ret = ret.Where("books.id NOT IN (?)", bookIDs)
} else if rule.BookDomain == database.BookDomainIncluding {
ret = ret.Where("books.id IN (?)", bookIDs)
}
}
return ret, nil
}
func getNotes(conn *gorm.DB, rule database.RepetitionRule, dst *[]database.Note) error {
c, err := applyBookDomain(conn, rule)
if err != nil {
return errors.Wrap(err, "building query for book threahold 1")
}
// TODO: ordering by random() does not scale if table grows large
if err := c.Where("notes.user_id = ?", rule.UserID).Order("random()").Limit(rule.NoteCount).Preload("Book").Find(&dst).Error; err != nil {
return errors.Wrap(err, "getting notes")
}
return nil
}
// getBalancedNotes returns a set of notes with a 'balanced' ratio of added_on dates
func getBalancedNotes(db *gorm.DB, rule database.RepetitionRule) ([]database.Note, error) {
now := time.Now()
t1 := now.AddDate(0, 0, -3).UnixNano()
t2 := now.AddDate(0, 0, -7).UnixNano()
// Get notes into three buckets with different threshold values
var stage1 []database.Note
var stage2 []database.Note
var stage3 []database.Note
if err := getNotes(db.Where("notes.added_on > ?", t1), rule, &stage1); err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 1")
}
if err := getNotes(db.Where("notes.added_on > ? AND notes.added_on < ?", t2, t1), rule, &stage2); err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 2")
}
if err := getNotes(db.Where("notes.added_on < ?", t2), rule, &stage3); err != nil {
return nil, errors.Wrap(err, "Failed to get notes with threshold 3")
}
notes := []database.Note{}
// pick one from each bucket at a time until the result is filled
i1 := 0
i2 := 0
i3 := 0
k := 0
for {
if i1+i2+i3 >= rule.NoteCount {
break
}
// if there are not enough notes to fill the result, break
if len(stage1) == i1 && len(stage2) == i2 && len(stage3) == i3 {
break
}
if k%3 == 0 {
if len(stage1) > i1 {
i1++
}
} else if k%3 == 1 {
if len(stage2) > i2 {
i2++
}
} else if k%3 == 2 {
if len(stage3) > i3 {
i3++
}
}
k++
}
notes = append(notes, stage1[:i1]...)
notes = append(notes, stage2[:i2]...)
notes = append(notes, stage3[:i3]...)
sort.SliceStable(notes, func(i, j int) bool {
n1 := notes[i]
n2 := notes[j]
return n1.AddedOn > n2.AddedOn
})
return notes, nil
}

View file

@ -0,0 +1,114 @@
/* 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/>.
*/
package repetition
import (
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func init() {
testutils.InitTestDB()
}
func TestApplyBookDomain(t *testing.T) {
defer testutils.ClearData()
db := database.DBConn
user := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, db.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, db.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: user.ID,
Label: "golang",
}
testutils.MustExec(t, db.Save(&b3), "preparing b3")
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
}
testutils.MustExec(t, db.Save(&n1), "preparing n1")
n2 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
}
testutils.MustExec(t, db.Save(&n2), "preparing n2")
n3 := database.Note{
UserID: user.ID,
BookUUID: b3.UUID,
}
testutils.MustExec(t, db.Save(&n3), "preparing n3")
var n1Record, n2Record, n3Record database.Note
testutils.MustExec(t, db.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1")
testutils.MustExec(t, db.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2")
testutils.MustExec(t, db.Where("uuid = ?", n3.UUID).First(&n3Record), "finding n3")
t.Run("book domain all", func(t *testing.T) {
rule := database.RepetitionRule{
UserID: user.ID,
BookDomain: database.BookDomainAll,
}
conn, err := applyBookDomain(db, rule)
if err != nil {
t.Fatal(errors.Wrap(err, "executing").Error())
}
var result []database.Note
testutils.MustExec(t, conn.Order("id ASC").Find(&result), "finding notes")
expected := []database.Note{n1Record, n2Record, n3Record}
assert.DeepEqual(t, result, expected, "result mismatch")
})
t.Run("book domain exclude", func(t *testing.T) {
rule := database.RepetitionRule{
UserID: user.ID,
BookDomain: database.BookDomainExluding,
Books: []database.Book{b1},
}
testutils.MustExec(t, db.Save(&rule), "preparing rule")
conn, err := applyBookDomain(db.Debug(), rule)
if err != nil {
t.Fatal(errors.Wrap(err, "executing").Error())
}
var result []database.Note
testutils.MustExec(t, conn.Order("id ASC").Find(&result), "finding notes")
expected := []database.Note{n2Record, n3Record}
assert.DeepEqual(t, result, expected, "result mismatch")
})
}

View file

@ -46,7 +46,7 @@ var (
// EmailTypeResetPassword represents a reset password email
EmailTypeResetPassword = "reset_password"
// EmailTypeWeeklyDigest represents a weekly digest email
EmailTypeWeeklyDigest = "weekly_digest"
EmailTypeWeeklyDigest = "digest"
// EmailTypeEmailVerification represents an email verification email
EmailTypeEmailVerification = "email_verification"
)
@ -124,12 +124,6 @@ func NewEmail(from string, to []string, subject string) *Email {
}
}
// isWhitelisted checks if the email is safe to send in non production env
func isWhitelisted(emails []string) bool {
return false
// return len(emails) == 1 && emails[0] == "mikeswcho@gmail.com"
}
// Send sends the email
func (e *Email) Send() error {
// If not production, never actually send an email

View file

@ -3,4 +3,11 @@
Email templates
* `/src` contains templates.
* Run the server to develop templates locally.
## Development
Run the server to develop templates locally.
```
./dev.sh
```

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -eux
PID=""
function cleanup {
if [ "$PID" != "" ]; then
kill "$PID"
fi
}
trap cleanup EXIT
while true; do
go build main.go
./main &
PID=$!
inotifywait -r -e modify .
kill $PID
done

View file

@ -22,25 +22,46 @@ import (
"log"
"net/http"
"os"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/job"
"github.com/dnote/dnote/pkg/server/job/repetition"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/pkg/errors"
)
func weeklyDigestHandler(w http.ResponseWriter, r *http.Request) {
func digestHandler(w http.ResponseWriter, r *http.Request) {
db := database.DBConn
q := r.URL.Query()
digestUUID := q.Get("digest_uuid")
if digestUUID == "" {
http.Error(w, errors.New("Please provide digest_uuid query param").Error(), http.StatusBadRequest)
return
}
var user database.User
if err := db.First(&user).Error; err != nil {
http.Error(w, errors.Wrap(err, "Failed to find user").Error(), http.StatusInternalServerError)
return
}
email, err := job.MakeDigest(user, "sung@getdnote.com")
var digest database.Digest
if err := db.Where("uuid = ?", digestUUID).Preload("Notes").First(&digest).Error; err != nil {
http.Error(w, errors.Wrap(err, "finding digest").Error(), http.StatusInternalServerError)
return
}
var rule database.RepetitionRule
if err := db.Where("id = ?", digest.RuleID).First(&rule).Error; err != nil {
http.Error(w, errors.Wrap(err, "finding digest").Error(), http.StatusInternalServerError)
return
}
now := time.Now()
email, err := repetition.BuildEmail(now, user, "sung@getdnote.com", digest, rule)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -69,6 +90,10 @@ func emailVerificationHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Email development server is running."))
}
func init() {
err := godotenv.Load(".env.dev")
if err != nil {
@ -89,9 +114,10 @@ func main() {
mailer.InitTemplates(nil)
log.Println("Email template debug server running on http://127.0.0.1:2300")
log.Println("Email template development server running on http://127.0.0.1:2300")
http.HandleFunc("/weekly-digest", weeklyDigestHandler)
http.HandleFunc("/", homeHandler)
http.HandleFunc("/digest", digestHandler)
http.HandleFunc("/email-verification", emailVerificationHandler)
log.Fatal(http.ListenAndServe(":2300", nil))
}

View file

@ -288,6 +288,7 @@
.note {
padding: 6px 9px;
border-radius: 2px;
word-break: break-word;
}
.timeago {
font-weight: 600;
@ -340,16 +341,18 @@
.notes-container {
padding: 12px 0;
}
.rule-title {
font-weight: 600;
}
</style>
</head>
<body class="">
<table border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td class="container">
<div class="content">
<span class="preheader">Here is your weekly Dnote digest.</span>
<span class="preheader">Here is your automated spaced repetition.</span>
{{ template "header" }}
@ -361,7 +364,15 @@
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
This is your weekly Dnote digest, featuring {{ .ActiveNoteCount }} notes from {{ .ActiveBookCount }} books.
This is your Dnote spaced repetition.
</td>
</tr>
<tr>
<td class="content-block">
<span class="rule-title">
{{ .RuleTitle }}
</span>
</td>
</tr>
@ -417,7 +428,7 @@
<tr>
<td>
<span class="apple-link">Dnote</span>
<br>Level 2, 11 York St, Sydney NSW 2000
<br>11 York St, Level 2, Sydney, NSW 2000, Australia
</td>
</tr>
<tr class="spacer">
@ -425,7 +436,7 @@
</tr>
<tr>
<td>
<a href="https://app.getdnote.com/email-preference?token={{ .EmailSessionToken }}">Change email frequency</a>
<a href="https://app.getdnote.com/preferences/repetitions/{{ .RuleUUID }}?token={{ .EmailSessionToken }}">Change digest settings</a>
</td>
</tr>
</table>

View file

@ -4,7 +4,7 @@
<tr>
<td class="content-block">
<span class="apple-link">Dnote</span>
<br>Level 2, 11 York St, Sydney NSW 2000
<br>11 York St, Level 2, Sydney, NSW 2000, Australia
</td>
</tr>
</table>

View file

@ -37,14 +37,14 @@ func generateRandomToken(bits int) (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
// GetEmailPreferenceToken returns an unused email frequency token for the user
// by first looking up any existing record and creating one if none exists.
func GetEmailPreferenceToken(user database.User) (database.Token, error) {
// GetToken returns an token of the given kind for the user
// by first looking up any unused record and creating one if none exists.
func GetToken(user database.User, kind string) (database.Token, error) {
db := database.DBConn
var tok database.Token
conn := db.
Where("user_id = ? AND type =? AND used_at IS NULL", user.ID, database.TokenTypeEmailPreference).
Where("user_id = ? AND type =? AND used_at IS NULL", user.ID, kind).
First(&tok)
tokenVal, err := generateRandomToken(16)
@ -55,7 +55,7 @@ func GetEmailPreferenceToken(user database.User) (database.Token, error) {
if conn.RecordNotFound() {
tok = database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailPreference,
Type: kind,
Value: tokenVal,
}
if err := db.Save(&tok).Error; err != nil {

View file

@ -41,6 +41,8 @@ type DigestTmplData struct {
ActiveBookCount int
ActiveNoteCount int
EmailSessionToken string
RuleUUID string
RuleTitle string
}
// NewNoteInfo returns a new NoteInfo

View file

@ -9,4 +9,4 @@ set -a
source "$basePath/.env.test"
set +a
"$basePath/api/scripts/test.sh"
"$basePath/scripts/test.sh"

16
pkg/server/scripts/test.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# test.sh runs server tests. It is to be invoked by other scripts that set
# appropriate env vars.
set -eux
pushd "$GOPATH"/src/github.com/dnote/dnote/pkg/server
if [ "${WATCH-false}" == true ]; then
set +e
while inotifywait --exclude .swp -e modify -r .; do go test ./... -cover -p 1; done;
set -e
else
go test ./... -cover -p 1
fi
popd

View file

@ -181,6 +181,9 @@ func ClearData() {
if err := db.Delete(&database.Digest{}).Error; err != nil {
panic(errors.Wrap(err, "Failed to clear digests"))
}
if err := db.Delete(&database.RepetitionRule{}).Error; err != nil {
panic(errors.Wrap(err, "Failed to clear digests"))
}
}
// HTTPDo makes an HTTP request and returns a response

View file

@ -39,7 +39,8 @@
"react/jsx-one-expression-per-line": 0,
"@typescript-eslint/no-unused-vars": 1,
"import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*_test.ts"]}],
"lines-between-class-members": 0
"lines-between-class-members": 0,
"react/jsx-fragments": 0
},
"plugins": [
"react", "react-hooks", "import", "prettier", "@typescript-eslint"

View file

@ -37,7 +37,7 @@
</div>
<div id="app"></div>
<div id="modal-root"></div>
<div id="overlay-root"></div>
<!--JS_BUNDLE_PLACEHOLDER-->
<script>

19
web/package-lock.json generated
View file

@ -6563,11 +6563,6 @@
"integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==",
"dev": true
},
"js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
},
"js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@ -7412,11 +7407,6 @@
}
}
},
"moment": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -9366,15 +9356,6 @@
"prop-types": "15.7.2"
}
},
"react-tooltip": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-3.11.1.tgz",
"integrity": "sha512-YCMVlEC2KuHIzOQhPplTK5jmBBwoL+PYJJdJKXj7M/h7oevupd/QSVq6z5U7/ehIGXyHsAqvwpdxexDfyQ0o3A==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
}
},
"read-pkg": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",

View file

@ -72,11 +72,7 @@
"core-js": "^3.2.1",
"highlight.js": "^9.15.10",
"history": "^4.10.1",
"hoist-non-react-statics": "^3.3.0",
"js-cookie": "^2.2.1",
"lodash": "^4.17.15",
"markdown-it": "^9.0.0",
"moment": "^2.24.0",
"qs": "^6.9.0",
"react": "^16.10.1",
"react-dom": "^16.10.1",
@ -87,7 +83,6 @@
"react-router-config": "^5.1.1",
"react-router-dom": "^5.1.2",
"react-stripe-elements": "^5.0.1",
"react-tooltip": "^3.11.1",
"redux": "^4.0.4",
"redux-thunk": "^2.1.0",
"regenerator-runtime": "^0.13.3",

View file

@ -3,9 +3,6 @@ set -eux
basePath="$GOPATH/src/github.com/dnote/dnote"
appPath="$basePath"/web
rootUrl=$ROOT_URL
echo "here is $rootUrl"
(
cd "$appPath" &&

View file

@ -118,13 +118,6 @@ img {
background: #ececec;
}
// override react-tooltip
.__react_component_tooltip {
&.dnote-tooltip {
opacity: 1;
}
}
// Measure scrollbar width for padding body during modal show/hide
.modal-scrollbar-measure {
position: absolute;

View file

@ -161,3 +161,12 @@ button:disabled {
text-align: left;
cursor: pointer;
}
.button-link {
color: $link;
&:hover {
color: $link-hover;
text-decoration: underline;
}
}

View file

@ -18,13 +18,6 @@
@import './variables';
$xl-breakpoint: 1441px;
//$lg-breakpoint: 1280px;
$lg-breakpoint: 992px;
$md-breakpoint: 576px;
// $md-breakpoint: 768px;
$sm-breakpoint: 321px;
@mixin breakpoint($point) {
@if $point == xl {
@media (min-width: $xl-breakpoint) {

View file

@ -42,7 +42,7 @@ input[type='email']:disabled,
input[type='number']:disabled,
input[type='password']:disabled,
textarea:disabled {
background: #ececec;
background-color: $light-gray;
cursor: not-allowed;
}
@ -124,11 +124,12 @@ h6 {
}
// grid
.container.mobile-nopadding {
@include breakpoint(mdonly) {
.container.mobile-fw {
@include breakpoint(mddown) {
max-width: 100%;
}
}
.container.mobile-nopadding {
@include breakpoint(mddown) {
padding-left: 0;
padding-right: 0;
@ -157,8 +158,59 @@ html body {
padding-bottom: 0;
@include breakpoint(lg) {
padding-top: rem(20px);
padding-bottom: rem(20px);
padding-top: rem(32px);
padding-bottom: rem(32px);
}
}
}
.page-header {
margin-top: rem(20px);
margin-bottom: rem(20px);
@include breakpoint(lg) {
padding: 0;
margin-top: 0;
}
}
.form-select {
appearance: none;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACeSURBVHgBzZPBCYQwFERn2Qa2BEuwhJSyHawdrB1oB1qCV6uwhHj0qBXoBPwQRONXEXzwLoEXcCTAzfxogpPEdJyNcZCIWu8CO5+p8WOxoR9NnK3EYrEX/wOxuDmqUcSikejlXfCFfqie5ngE/ie4cVS/ibS0XB4anBhxSaKIU+xQBmLV8m6HZiW2OECEi4/JoXrO78AFHR1oTSvcxQTq7lVcue6CCAAAAABJRU5ErkJggg==');
background-color: #fff;
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 8px 10px;
border: 1px solid $border-color;
min-height: 34px;
padding: 6px 8px;
padding-right: 24px;
outline: none;
vertical-align: middle;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08);
&:focus {
border-color: #2188ff;
outline: none;
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08),
0 0 0 2px rgba(3, 102, 214, 0.3);
}
&:disabled,
&.form-select-disabled {
background-image: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEKSURBVHgBzVTNDYIwFC4NB46OwAi4gY7gETgoE6gTGCcwTgAJ4efGCLCBjMAIXrmA3yOhQazQhJj4JQ0v7fte3/e1hbFfIk3TYxzHp6kc7dtCFEUW5/xBcdM0a9d1S1kel00mSWKCnIkkxDSnXADIMYYEU9O0zPf91WwB6L6NyB3atrUMw7hNFkCbFyROmXYYmypMDMNwo+t6ztSwtW27oEAXrXBuwu2rCht+WPgU7C8gPCBzYOBKhQS5FTwIKBYeQFeJoWyiKNYH5Co6OCuQr/0JdBuPVyElQCd7GRMb3B3HebsHHzexrmvyQvZwqjFZWsDzvCc62BFhSGYD3UMsfs6ToKOd+6EsxgtrtWLW4gUN3AAAAABJRU5ErkJggg==');
background-color: $light-gray;
}
}
.input-label {
// width: 100%;
width: auto;
font-weight: 600;
margin-bottom: rem(4px);
@include font-size('small');
}
.page-heading {
@include font-size('x-large');
}

View file

@ -22,6 +22,7 @@ $white: #ffffff;
$light: #f7f9fa;
$gray: #686868;
$light-gray: #f3f3f3;
$dark-gray: #637283;
// primary colors
$first: #072a40;
@ -39,3 +40,4 @@ $danger-text: #cb2431;
$danger-background: #f8d7da;
$light-blue: #ecf4ff;
$green: #28a755;

View file

@ -18,3 +18,14 @@
$header-height: 60px;
$footer-height: 56px;
// breakpoints
$xl-breakpoint: 1441px;
$lg-breakpoint: 992px;
$md-breakpoint: 576px;
$sm-breakpoint: 321px;
:export {
mdBreakpoint: $md-breakpoint;
smBreakpoint: $sm-breakpoint;
}

View file

@ -1,58 +0,0 @@
/* 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 React from 'react';
import classnames from 'classnames';
import TrashIcon from '../../Icons/Trash';
import Tooltip from '../../Common/Tooltip';
import styles from './Actions.module.scss';
function Actions({ bookUUID, onDeleteBook, shown }) {
return (
<div
className={classnames(styles['actions-wrapper'], {
[styles.shown]: shown
})}
>
<Tooltip
id="tooltip-delete-book"
alignment="right"
direction="bottom"
overlay={<span>Delete this book</span>}
wrapperClassName={styles['action-tooltip-wrapper']}
triggerClassName={styles['action-tooltip-trigger']}
>
<button
type="button"
className={classnames(
'T-delete-book-btn button-no-ui',
styles.action
)}
onClick={() => {
onDeleteBook(bookUUID);
}}
>
<TrashIcon width="16" height="16" />
</button>
</Tooltip>
</div>
);
}
export default Actions;

View file

@ -16,38 +16,13 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../App/responsive';
@import '../../App/theme';
@import '../../App/rem';
@import '../../App/font';
@import '../../App/theme';
.wrapper {
.actions {
position: absolute;
right: 0;
top: 0;
bottom: 0;
height: 100%;
@include breakpoint(lg) {
display: none;
}
}
.trigger {
padding: 0 rem(12px);
height: 100%;
}
.content {
background: $white;
z-index: 99;
width: rem(120px);
}
button.action {
text-align: center;
padding: rem(4px) 0;
}
.danger {
color: $danger-text;
right: rem(8px);
top: 50%;
transform: translateY(-50%);
}

View file

@ -19,52 +19,59 @@
import React, { useState, useRef } from 'react';
import classnames from 'classnames';
import Menu from '../../Common/Menu';
import ItemActions from '../../Common/ItemActions';
import DotsIcon from '../../Icons/Dots';
import styles from './MobileActions.module.scss';
import ItemActionsStyles from '../../Common/ItemActions/ItemActions.scss';
import styles from './Actions.scss';
function MobileActions({ bookUUID, onDeleteBook }) {
interface Props {
bookUUID: string;
onDeleteBook: (string) => void;
isActive: boolean;
}
const Actions: React.FunctionComponent<Props> = ({
bookUUID,
onDeleteBook,
isActive
}) => {
const [isOpen, setIsOpen] = useState(false);
const optRefs = [useRef(null)];
const options = [
{
name: 'home',
name: 'remove',
value: (
<button
ref={optRefs[0]}
type="button"
className={classnames(
'button-no-ui button-stretch',
styles.action,
styles.danger
'button-no-ui button-stretch T-delete-book-btn',
ItemActionsStyles.action
)}
onClick={() => {
setIsOpen(false);
onDeleteBook(bookUUID);
}}
>
Remove
Remove&hellip;
</button>
)
}
];
return (
<Menu
options={options}
<ItemActions
id={`book-actions-${bookUUID}`}
triggerId={`book-actions-trigger-${bookUUID}`}
isOpen={isOpen}
setIsOpen={setIsOpen}
isActive={isActive}
options={options}
optRefs={optRefs}
menuId="mobile-book-actions"
triggerContent={<DotsIcon width="12" height="12" />}
wrapperClassName={styles.wrapper}
triggerClassName={styles.trigger}
contentClassName={styles.content}
alignment="top"
direction="left"
wrapperClassName={styles.actions}
/>
);
}
};
export default MobileActions;
export default Actions;

View file

@ -22,8 +22,7 @@ import { Link } from 'react-router-dom';
import { getHomePath } from 'web/libs/paths';
import Actions from './Actions';
import MobileActions from './MobileActions';
import { BookData } from 'jslib/operations/books';
import { BookData } from 'jslib/operations/types';
import styles from './BookItem.scss';
@ -69,12 +68,10 @@ const BookItem: React.SFC<Props> = ({
<h2 className={styles.label}>{book.label}</h2>
</Link>
<MobileActions bookUUID={book.uuid} onDeleteBook={onDeleteBook} />
<Actions
bookUUID={book.uuid}
onDeleteBook={onDeleteBook}
shown={isActive}
isActive={isActive}
/>
</li>
);

View file

@ -26,6 +26,4 @@
margin-bottom: 0;
border-radius: 2px;
border: 1px solid $border-color;
margin: rem(12px) 0;
}

View file

@ -21,7 +21,7 @@ import classnames from 'classnames';
import BookItem from './BookItem';
import BookHolder from './BookHolder';
import { BookData } from 'jslib/operations/books';
import { BookData } from 'jslib/operations/types';
import styles from './BookList.scss';
function Placeholder() {

View file

@ -20,42 +20,40 @@
@import '../App/responsive';
@import '../App/theme';
.actions {
.header {
display: flex;
padding: rem(12px) rem(20px);
flex-direction: column;
border-radius: rem(4px);
background: $light;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
@include breakpoint(md) {
flex-direction: row;
justify-content: space-between;
align-items: center;
// margin-top: rem(12px);
justify-content: space-between;
}
}
.header-left {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: space-between;
}
.search-input-wrapper {
width: 100%;
margin-top: rem(12px);
@include breakpoint(md) {
width: auto;
margin-top: 0;
}
}
.search-input {
width: 100%;
@include breakpoint(lg) {
width: rem(400px);
margin-top: 0;
}
}
button.create-book-button {
margin-top: rem(12px);
@include breakpoint(md) {
margin-top: 0;
margin-left: rem(12px);
width: auto;
margin-left: rem(24px);
}
// display: none;
// @include breakpoint(md) {
// display: inline-block;
// }
}

View file

@ -21,7 +21,7 @@ import { withRouter, RouteComponentProps } from 'react-router-dom';
import classnames from 'classnames';
import { History } from 'history';
import { BookData } from 'jslib/operations/books';
import { BookData } from 'jslib/operations/types';
import { escapesRegExp } from 'web/libs/string';
import { getHomePath } from 'web/libs/paths';
import {
@ -36,6 +36,9 @@ import EmptyList from './EmptyList';
import SearchInput from '../Common/SearchInput';
import Button from '../Common/Button';
import DeleteBookModal from './DeleteBookModal';
import { usePrevious } from '../../libs/hooks';
import BookPlusIcon from '../Icons/BookPlus';
import CreateBookButton from './CreateBookButton';
import styles from './Content.scss';
function filterBooks(books: BookData[], searchInput: string): BookData[] {
@ -52,7 +55,7 @@ function filterBooks(books: BookData[], searchInput: string): BookData[] {
}
function handleMenuKeydownSelect(history: History): KeydownSelectFn<BookData> {
return option => {
return (option: BookData) => {
const destination = getHomePath({
book: option.label
});
@ -61,19 +64,6 @@ function handleMenuKeydownSelect(history: History): KeydownSelectFn<BookData> {
};
}
function useFocusInput(
isFetching: boolean,
inputRef: React.MutableRefObject<any>
) {
useEffect(() => {
if (!isFetching) {
if (inputRef.current) {
inputRef.current.focus();
}
}
}, [isFetching, inputRef]);
}
function useSetFocusedOptionOnInputFocus({
searchValue,
searchFocus,
@ -93,8 +83,10 @@ function useFocusInputOnReset(
searchValue: string,
inputRef: React.MutableRefObject<any>
) {
const prevSearchValue = usePrevious(searchValue);
useEffect(() => {
if (searchValue === '') {
if (prevSearchValue !== null && searchValue === '') {
if (inputRef.current !== null) {
inputRef.current.focus();
}
@ -124,7 +116,6 @@ const Content: React.SFC<Props> = ({ history, setSuccessMessage }) => {
const filteredBooks = filterBooks(books.data, searchValue);
const containerEl = document.body;
useFocusInput(books.isFetching, inputRef);
useSetFocusedOptionOnInputFocus({
searchValue,
searchFocus,
@ -136,6 +127,7 @@ const Content: React.SFC<Props> = ({ history, setSuccessMessage }) => {
containerEl,
focusedIdx,
setFocusedIdx,
disabled: !searchFocus,
onKeydownSelect: handleMenuKeydownSelect(history)
});
useScrollToFocused({
@ -149,60 +141,62 @@ const Content: React.SFC<Props> = ({ history, setSuccessMessage }) => {
return (
<Fragment>
<div className={styles.actions}>
<SearchInput
placeholder="Find a book"
value={searchValue}
onChange={e => {
const val = e.target.value;
setSearchValue(val);
}}
inputClassName={classnames(
'text-input-small',
styles['search-input']
)}
disabled={books.isFetching}
inputRef={inputRef}
onFocus={() => {
setSearchFocus(true);
}}
onBlur={() => {
setSearchFocus(false);
}}
onReset={() => {
setSearchValue('');
}}
/>
<div className="container mobile-fw">
<div className={classnames(styles.header, 'page-header')}>
<div className={styles['header-left']}>
<h1 className="page-heading">Books</h1>
<CreateBookButton
id="T-create-book-btn"
disabled={books.isFetching}
openModal={() => {
setIsCreateBookModalOpen(true);
}}
/>
</div>
<Button
id="T-create-book-btn"
type="button"
kind="third"
size="normal"
className={styles['create-book-button']}
disabled={books.isFetching}
onClick={() => {
setIsCreateBookModalOpen(true);
}}
>
Create book
</Button>
<SearchInput
placeholder="Filter books"
value={searchValue}
onChange={e => {
const val = e.target.value;
setSearchValue(val);
}}
wrapperClassName={styles['search-input-wrapper']}
inputClassName={classnames(
'text-input-small',
styles['search-input']
)}
disabled={books.isFetching}
inputRef={inputRef}
onFocus={() => {
setSearchFocus(true);
}}
onBlur={() => {
setSearchFocus(false);
}}
onReset={() => {
setSearchValue('');
}}
/>
</div>
</div>
{hasNoBooks ? (
<EmptyList />
) : (
<BookList
isFetching={books.isFetching}
isFetched={books.isFetched}
books={filteredBooks}
focusedIdx={focusedIdx}
setFocusedOptEl={setFocusedOptEl}
onDeleteBook={bookUUID => {
setBookUUIDToDelete(bookUUID);
}}
/>
)}
<div className="container mobile-nopadding">
{hasNoBooks ? (
<EmptyList />
) : (
<BookList
isFetching={books.isFetching}
isFetched={books.isFetched}
books={filteredBooks}
focusedIdx={focusedIdx}
setFocusedOptEl={setFocusedOptEl}
onDeleteBook={bookUUID => {
setBookUUIDToDelete(bookUUID);
}}
/>
)}
</div>
<DeleteBookModal
isOpen={Boolean(bookUUIDToDelete)}

View file

@ -0,0 +1,36 @@
/* 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 '../App/rem';
@import '../App/responsive';
@import '../App/theme';
@import '../App/font';
button.create-button {
padding: 0;
@include font-size('small');
}
.create-button-content {
display: flex;
align-items: center;
}
.create-button-text {
margin-left: rem(4px);
}

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 React, { Fragment, useState, useEffect, useRef } from 'react';
import classnames from 'classnames';
import BookPlusIcon from '../Icons/BookPlus';
import styles from './CreateBookButton.scss';
interface Props {
disabled: boolean;
openModal: () => void;
className?: string;
id?: string;
}
const CreateBookButton: React.SFC<Props> = ({
id,
disabled,
openModal,
className
}) => {
return (
<button
id={id}
type="button"
className={classnames(
'button-no-ui button-link',
styles['create-button'],
className
)}
disabled={disabled}
onClick={() => {
openModal();
}}
>
<span className={styles['create-button-content']}>
<BookPlusIcon id={`${id}-icon`} width={16} height={16} fill="#6f53c0" />
<span className={styles['create-button-text']}>Create book</span>
</span>
</button>
);
};
export default React.memo(CreateBookButton);

View file

@ -38,18 +38,18 @@ const Books: React.SFC = () => {
<HeadData />
<PayWall>
<h1 className="sr-only">Books</h1>
<div className="container mobile-nopadding page page-mobile-full">
<Flash
kind="success"
when={Boolean(successMessage)}
onDismiss={() => {
setSuccessMessage('');
}}
>
{successMessage}
</Flash>
<div className="page page-mobile-full">
<div className="container mobile-nopadding">
<Flash
kind="success"
when={Boolean(successMessage)}
onDismiss={() => {
setSuccessMessage('');
}}
>
{successMessage}
</Flash>
</div>
<Content setSuccessMessage={setSuccessMessage} />;
</div>
</PayWall>

View file

@ -0,0 +1,72 @@
/* 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 '../../App/responsive';
@import '../../App/theme';
@import '../../App/rem';
@import '../../App/font';
.wrapper {
position: relative;
display: flex;
align-items: center;
}
.active {
button.trigger {
border-color: $border-color;
}
}
.is-open {
z-index: 1;
}
button.trigger {
padding: rem(12px) rem(12px);
transition: none;
border-radius: 4px;
border-width: 1px;
border-style: solid;
border-color: transparent;
@include breakpoint(mddown) {
border-color: $border-color;
}
}
.content {
background: $white;
z-index: 99;
width: rem(120px);
}
a.action,
button.action {
display: block;
text-align: center;
padding: rem(4px) 0;
color: inherit;
@include font-size('small');
}
a.action {
&:hover {
color: inherit;
}
}

View file

@ -0,0 +1,71 @@
/* 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 React, { useState, useRef } from 'react';
import classnames from 'classnames';
import Menu, { MenuOption } from '../../Common/Menu';
import DotsIcon from '../../Icons/Dots';
import styles from './ItemActions.scss';
interface Props {
id: string;
triggerId: string;
isActive: boolean;
options: MenuOption[];
optRefs: React.MutableRefObject<any>[];
isOpen: boolean;
setIsOpen: React.Dispatch<any>;
wrapperClassName?: string;
}
const ItemActions: React.FunctionComponent<Props> = ({
id,
triggerId,
isActive,
options,
optRefs,
isOpen,
setIsOpen,
wrapperClassName
}) => {
return (
<div
className={classnames(styles.wrapper, wrapperClassName, {
[styles['is-open']]: isOpen,
[styles.active]: isActive
})}
>
<Menu
options={options}
isOpen={isOpen}
setIsOpen={setIsOpen}
optRefs={optRefs}
menuId={id}
triggerId={triggerId}
triggerContent={<DotsIcon width={12} height={12} />}
triggerClassName={styles['trigger']}
contentClassName={styles['content']}
alignment="right"
direction="bottom"
/>
</div>
);
};
export default ItemActions;

View file

@ -16,3 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16),
0 8px 16px rgba(27, 39, 51, 0.08);
}

View file

@ -22,15 +22,22 @@ import classnames from 'classnames';
import { KEYCODE_UP, KEYCODE_DOWN } from 'jslib/helpers/keyboard';
import { useEventListener } from 'web/libs/hooks';
import Popover from '../Popover';
import { Direction, Alignment } from './types';
import styles from './Menu.scss';
interface ContentProps {
options: any[];
menuId: string;
setContentEl: (any) => void;
headerContent: React.ReactNode;
export interface MenuOption {
name: string;
value: React.ReactElement;
}
const Content: React.SFC<ContentProps> = ({
interface ContentProps {
options: MenuOption[];
menuId: string;
setContentEl: (any) => void;
headerContent?: React.ReactNode;
}
const Content: React.FunctionComponent<ContentProps> = ({
options,
menuId,
setContentEl,
@ -59,27 +66,24 @@ const Content: React.SFC<ContentProps> = ({
);
};
type Direction = 'top' | 'bottom';
type Alignment = 'top' | 'bottom' | 'left' | 'right';
interface MenuProps {
options: any[];
options: MenuOption[];
isOpen: boolean;
setIsOpen: (boolean) => void;
optRefs: any;
optRefs: React.MutableRefObject<any>[];
triggerContent: React.ReactNode;
triggerClassName?: string;
contentClassName: string;
alignment: Alignment;
direction: Direction;
headerContent: React.ReactNode;
wrapperClassName: string;
headerContent?: React.ReactNode;
wrapperClassName?: string;
menuId: string;
triggerId: string;
disabled?: boolean;
}
const Menu: React.SFC<MenuProps> = ({
const Menu: React.FunctionComponent<MenuProps> = ({
options,
isOpen,
setIsOpen,
@ -115,6 +119,11 @@ const Menu: React.SFC<MenuProps> = ({
const { keyCode } = e;
if (keyCode === KEYCODE_UP || keyCode === KEYCODE_DOWN) {
// Avoid scrolling the whole page down
e.preventDefault();
// Stop event propagation in case any parent is also listening on the same set of keys.
e.stopPropagation();
let nextOptionIdx;
if (currentOptionIdx === 0 && keyCode === KEYCODE_UP) {
nextOptionIdx = options.length - 1;
@ -163,7 +172,7 @@ const Menu: React.SFC<MenuProps> = ({
</button>
);
}}
contentClassName={contentClassName}
contentClassName={classnames(styles.content, contentClassName)}
wrapperClassName={wrapperClassName}
isOpen={isOpen}
setIsOpen={setIsOpen}

View file

@ -0,0 +1,2 @@
export type Direction = 'top' | 'bottom';
export type Alignment = 'top' | 'bottom' | 'left' | 'right';

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