mirror of
https://github.com/dnote/dnote
synced 2026-03-18 00:09:56 +01:00
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:
parent
9f6d4dbbaa
commit
5902585216
176 changed files with 7746 additions and 1551 deletions
|
|
@ -2,7 +2,7 @@ language: go
|
|||
dist: xenial
|
||||
|
||||
go:
|
||||
- 1.12
|
||||
- 1.13
|
||||
|
||||
env:
|
||||
- NODE_VERSION=10.15.0
|
||||
|
|
|
|||
|
|
@ -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
42
Gopkg.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
116
jslib/src/services/repetitionRules.ts
Normal file
116
jslib/src/services/repetitionRules.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
460
pkg/server/api/handlers/repetition_rules.go
Normal file
460
pkg/server/api/handlers/repetition_rules.go
Normal 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)
|
||||
}
|
||||
649
pkg/server/api/handlers/repetition_rules_test.go
Normal file
649
pkg/server/api/handlers/repetition_rules_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
57
pkg/server/api/presenters/book.go
Normal file
57
pkg/server/api/presenters/book.go
Normal 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
|
||||
}
|
||||
60
pkg/server/api/presenters/digest.go
Normal file
60
pkg/server/api/presenters/digest.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
29
pkg/server/api/presenters/helpers.go
Normal file
29
pkg/server/api/presenters/helpers.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
75
pkg/server/api/presenters/repetition_rule.go
Normal file
75
pkg/server/api/presenters/repetition_rule.go
Normal 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
|
||||
}
|
||||
90
pkg/server/api/presenters/repetition_rule_test.go
Normal file
90
pkg/server/api/presenters/repetition_rule_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
39
pkg/server/database/consts.go
Normal file
39
pkg/server/database/consts.go
Normal 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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬if).Error; err != nil {
|
||||
log.Printf("Error occurred while creating notification for %s: %s", account.Email.String, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
252
pkg/server/job/repetition/repetition.go
Normal file
252
pkg/server/job/repetition/repetition.go
Normal 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(¬if).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
|
||||
}
|
||||
384
pkg/server/job/repetition/repetition_test.go
Normal file
384
pkg/server/job/repetition/repetition_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
141
pkg/server/job/repetition/strategy.go
Normal file
141
pkg/server/job/repetition/strategy.go
Normal 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
|
||||
}
|
||||
114
pkg/server/job/repetition/strategy_test.go
Normal file
114
pkg/server/job/repetition/strategy_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
21
pkg/server/mailer/templates/dev.sh
Executable file
21
pkg/server/mailer/templates/dev.sh
Executable 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
|
||||
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -41,6 +41,8 @@ type DigestTmplData struct {
|
|||
ActiveBookCount int
|
||||
ActiveNoteCount int
|
||||
EmailSessionToken string
|
||||
RuleUUID string
|
||||
RuleTitle string
|
||||
}
|
||||
|
||||
// NewNoteInfo returns a new NoteInfo
|
||||
|
|
|
|||
|
|
@ -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
16
pkg/server/scripts/test.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
19
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -161,3 +161,12 @@ button:disabled {
|
|||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
color: $link;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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%);
|
||||
}
|
||||
|
|
@ -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…
|
||||
</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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,4 @@
|
|||
margin-bottom: 0;
|
||||
border-radius: 2px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
margin: rem(12px) 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
36
web/src/components/Books/CreateBookButton.scss
Normal file
36
web/src/components/Books/CreateBookButton.scss
Normal 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);
|
||||
}
|
||||
59
web/src/components/Books/CreateBookButton.tsx
Normal file
59
web/src/components/Books/CreateBookButton.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
72
web/src/components/Common/ItemActions/ItemActions.scss
Normal file
72
web/src/components/Common/ItemActions/ItemActions.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
71
web/src/components/Common/ItemActions/index.tsx
Normal file
71
web/src/components/Common/ItemActions/index.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
2
web/src/components/Common/Menu/types.ts
Normal file
2
web/src/components/Common/Menu/types.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue