mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
Remove repetition (#432)
* Remove repetition and digest * Document change * Fix test * Fix test
This commit is contained in:
parent
b2da22acae
commit
a9f052b19c
124 changed files with 37 additions and 9847 deletions
|
|
@ -12,6 +12,7 @@ before_install:
|
|||
- sudo apt-get --yes remove postgresql\*
|
||||
- sudo apt-get install -y postgresql-11 postgresql-client-11
|
||||
- sudo cp /etc/postgresql/{9.6,11}/main/pg_hba.conf
|
||||
- sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/11/main/postgresql.conf
|
||||
- sudo service postgresql restart 11
|
||||
|
||||
before_script:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ All notable changes to the projects under this repository will be documented in
|
|||
|
||||
The following log documents the history of the server project.
|
||||
|
||||
### Unreleased
|
||||
|
||||
- Remove the deprecated features related to digests and repetition rules.
|
||||
|
||||
### 0.5.0 - 2020-02-06
|
||||
|
||||
#### Changed
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 initDigestsService from '../services/digests';
|
||||
import { HttpClientConfig } from '../helpers/http';
|
||||
|
||||
export default function init(c: HttpClientConfig) {
|
||||
const digestsService = initDigestsService(c);
|
||||
|
||||
return {
|
||||
fetchAll: params => {
|
||||
return digestsService.fetchAll(params);
|
||||
},
|
||||
|
||||
fetch: (noteUUID: string) => {
|
||||
return digestsService.fetch(noteUUID);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -19,18 +19,15 @@
|
|||
import { HttpClientConfig } from '../helpers/http';
|
||||
import initBooksOperation from './books';
|
||||
import initNotesOperation from './notes';
|
||||
import initDigestsOperation from './digests';
|
||||
|
||||
// init initializes operations with the given http configuration
|
||||
// and returns an object of all services.
|
||||
export default function initOperations(c: HttpClientConfig) {
|
||||
const booksOperation = initBooksOperation(c);
|
||||
const notesOperation = initNotesOperation(c);
|
||||
const digestsOperation = initDigestsOperation(c);
|
||||
|
||||
return {
|
||||
books: booksOperation,
|
||||
notes: notesOperation,
|
||||
digests: digestsOperation
|
||||
notes: notesOperation
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,49 +56,3 @@ export type BookData = {
|
|||
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;
|
||||
}
|
||||
|
||||
export interface ReceiptData {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
uuid: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
notes: DigestNoteData[];
|
||||
isRead: boolean;
|
||||
repetitionRule: RepetitionRuleData;
|
||||
}
|
||||
|
||||
export interface DigestNoteData extends NoteData {
|
||||
isReviewed: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
import { getPath } from '../helpers/url';
|
||||
import { DigestData, DigestNoteData } from '../operations/types';
|
||||
import { mapNote } from './notes';
|
||||
|
||||
function mapDigestNote(item): DigestNoteData {
|
||||
const note = mapNote(item);
|
||||
|
||||
return {
|
||||
...note,
|
||||
isReviewed: item.is_reviewed
|
||||
};
|
||||
}
|
||||
|
||||
// mapDigest maps the presented digest response to DigestData
|
||||
function mapDigest(item): DigestData {
|
||||
return {
|
||||
uuid: item.uuid,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
version: item.version,
|
||||
notes: item.notes.map(mapDigestNote),
|
||||
repetitionRule: {
|
||||
uuid: item.repetition_rule.uuid,
|
||||
title: item.repetition_rule.title,
|
||||
enabled: item.repetition_rule.enabled,
|
||||
hour: item.repetition_rule.hour,
|
||||
minute: item.repetition_rule.minute,
|
||||
bookDomain: item.repetition_rule.book_domain,
|
||||
frequency: item.repetition_rule.frequency,
|
||||
books: item.repetition_rule.books,
|
||||
lastActive: item.repetition_rule.last_active,
|
||||
nextActive: item.repetition_rule.next_active,
|
||||
noteCount: item.repetition_rule.note_count,
|
||||
createdAt: item.repetition_rule.created_at,
|
||||
updatedAt: item.repetition_rule.updated_at
|
||||
},
|
||||
isRead: item.is_read
|
||||
};
|
||||
}
|
||||
|
||||
export interface FetchAllResult {
|
||||
total: number;
|
||||
items: DigestData[];
|
||||
}
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
fetch: (digestUUID: string): Promise<DigestData> => {
|
||||
const endpoint = `/digests/${digestUUID}`;
|
||||
|
||||
return client.get(endpoint).then(mapDigest);
|
||||
},
|
||||
|
||||
fetchAll: ({ page, status }): Promise<FetchAllResult> => {
|
||||
const path = '/digests';
|
||||
|
||||
const endpoint = getPath(path, { page, status });
|
||||
|
||||
return client.get(endpoint).then(res => {
|
||||
return {
|
||||
total: res.total,
|
||||
items: res.items.map(mapDigest)
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -21,9 +21,6 @@ import initUsersService from './users';
|
|||
import initBooksService from './books';
|
||||
import initNotesService from './notes';
|
||||
import initPaymentService from './payment';
|
||||
import initDigestsService from './digests';
|
||||
import initRepetitionRulesService from './repetitionRules';
|
||||
import initNoteReviews from './noteReviews';
|
||||
|
||||
// init initializes service helpers with the given http configuration
|
||||
// and returns an object of all services.
|
||||
|
|
@ -32,17 +29,11 @@ export default function initServices(c: HttpClientConfig) {
|
|||
const booksService = initBooksService(c);
|
||||
const notesService = initNotesService(c);
|
||||
const paymentService = initPaymentService(c);
|
||||
const digestsService = initDigestsService(c);
|
||||
const repetitionRulesService = initRepetitionRulesService(c);
|
||||
const noteReviewsService = initNoteReviews(c);
|
||||
|
||||
return {
|
||||
users: usersService,
|
||||
books: booksService,
|
||||
notes: notesService,
|
||||
payment: paymentService,
|
||||
digests: digestsService,
|
||||
repetitionRules: repetitionRulesService,
|
||||
noteReviews: noteReviewsService
|
||||
payment: paymentService
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
|
||||
export interface CreateDeleteNoteReviewPayload {
|
||||
digestUUID: string;
|
||||
noteUUID: string;
|
||||
}
|
||||
|
||||
export default function init(config: HttpClientConfig) {
|
||||
const client = getHttpClient(config);
|
||||
|
||||
return {
|
||||
create: ({
|
||||
digestUUID,
|
||||
noteUUID
|
||||
}: CreateDeleteNoteReviewPayload): Promise<void> => {
|
||||
const endpoint = '/note_review';
|
||||
const payload = {
|
||||
digest_uuid: digestUUID,
|
||||
note_uuid: noteUUID
|
||||
};
|
||||
|
||||
return client.post(endpoint, payload);
|
||||
},
|
||||
|
||||
remove: ({
|
||||
digestUUID,
|
||||
noteUUID
|
||||
}: CreateDeleteNoteReviewPayload): Promise<void> => {
|
||||
const endpoint = '/note_review';
|
||||
const payload = {
|
||||
digest_uuid: digestUUID,
|
||||
note_uuid: noteUUID
|
||||
};
|
||||
|
||||
return client.del(endpoint, payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { RepetitionRuleData, BookDomain } from '../operations/types';
|
||||
import { getHttpClient, HttpClientConfig } from '../helpers/http';
|
||||
import { getPath } from '../helpers/url';
|
||||
|
||||
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>;
|
||||
|
||||
function mapData(d): 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(endpoint).then(resp => {
|
||||
return mapData(resp);
|
||||
});
|
||||
},
|
||||
fetchAll: (): Promise<RepetitionRuleData[]> => {
|
||||
const endpoint = '/repetition_rules';
|
||||
|
||||
return client.get(endpoint).then(resp => {
|
||||
return resp.map(mapData);
|
||||
});
|
||||
},
|
||||
create: (params: CreateParams) => {
|
||||
const endpoint = '/repetition_rules';
|
||||
|
||||
return client.post(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(endpoint, params).then(resp => {
|
||||
return mapData(resp);
|
||||
});
|
||||
},
|
||||
remove: (uuid: string) => {
|
||||
const endpoint = `/repetition_rules/${uuid}`;
|
||||
|
||||
return client.del(endpoint);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -51,7 +51,8 @@ func TestMain(m *testing.M) {
|
|||
|
||||
func TestInit(t *testing.T) {
|
||||
// Execute
|
||||
testutils.RunDnoteCmd(t, opts, binaryName)
|
||||
// run an arbitrary command "view" due to https://github.com/spf13/cobra/issues/1056
|
||||
testutils.RunDnoteCmd(t, opts, binaryName, "view")
|
||||
defer testutils.RemoveDir(t, opts.HomeDir)
|
||||
|
||||
db := database.OpenTestDB(t, opts.DnoteDir)
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *App) getExistingDigestReceipt(userID, digestID int) (*database.DigestReceipt, error) {
|
||||
var ret database.DigestReceipt
|
||||
conn := a.DB.Where("user_id = ? AND digest_id = ?", userID, digestID).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "querying existing digest receipt")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// GetUserDigestByUUID retrives a digest by the uuid for the given user
|
||||
func (a *App) GetUserDigestByUUID(userID int, uuid string) (*database.Digest, error) {
|
||||
var ret database.Digest
|
||||
conn := a.DB.Where("user_id = ? AND uuid = ?", userID, uuid).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "finding digest")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// MarkDigestRead creates a new digest receipt. If one already exists for
|
||||
// the given digest and the user, it is a noop.
|
||||
func (a *App) MarkDigestRead(digest database.Digest, user database.User) (database.DigestReceipt, error) {
|
||||
db := a.DB
|
||||
|
||||
existing, err := a.getExistingDigestReceipt(user.ID, digest.ID)
|
||||
if err != nil {
|
||||
return database.DigestReceipt{}, errors.Wrap(err, "checking existing digest receipt")
|
||||
}
|
||||
if existing != nil {
|
||||
return *existing, nil
|
||||
}
|
||||
|
||||
dat := database.DigestReceipt{
|
||||
UserID: user.ID,
|
||||
DigestID: digest.ID,
|
||||
}
|
||||
if err := db.Create(&dat).Error; err != nil {
|
||||
return database.DigestReceipt{}, errors.Wrap(err, "creating digest receipt")
|
||||
}
|
||||
|
||||
return dat, nil
|
||||
}
|
||||
|
||||
// GetDigestsParam is the params for getting a list of digests
|
||||
type GetDigestsParam struct {
|
||||
UserID int
|
||||
Status string
|
||||
Offset int
|
||||
PerPage int
|
||||
Order string
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getSubQuery() string {
|
||||
orderClause := p.getOrderClause("digests")
|
||||
|
||||
return fmt.Sprintf(`SELECT
|
||||
digests.id AS digest_id,
|
||||
digests.created_at AS created_at,
|
||||
COUNT(digest_receipts.id) AS receipt_count
|
||||
FROM digests
|
||||
LEFT JOIN digest_receipts ON digest_receipts.digest_id = digests.id
|
||||
WHERE digests.user_id = %d
|
||||
GROUP BY digests.id, digests.created_at
|
||||
%s`, p.UserID, orderClause)
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getSubQueryWhere() string {
|
||||
var ret string
|
||||
|
||||
if p.Status == "unread" {
|
||||
ret = "WHERE t1.receipt_count = 0"
|
||||
} else if p.Status == "read" {
|
||||
ret = "WHERE t1.receipt_count > 0"
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getOrderClause(table string) string {
|
||||
if p.Order == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`ORDER BY %s.%s`, table, p.Order)
|
||||
}
|
||||
|
||||
// CountDigests counts digests with the given user using the given criteria
|
||||
func (a *App) CountDigests(p GetDigestsParam) (int, error) {
|
||||
subquery := p.getSubQuery()
|
||||
whereClause := p.getSubQueryWhere()
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) FROM (%s) AS t1 %s`, subquery, whereClause)
|
||||
|
||||
result := struct {
|
||||
Count int
|
||||
}{}
|
||||
if err := a.DB.Raw(query).Scan(&result).Error; err != nil {
|
||||
return 0, errors.Wrap(err, "running count query")
|
||||
}
|
||||
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
func (a *App) queryDigestIDs(p GetDigestsParam) ([]int, error) {
|
||||
subquery := p.getSubQuery()
|
||||
whereClause := p.getSubQueryWhere()
|
||||
orderClause := p.getOrderClause("t1")
|
||||
query := fmt.Sprintf(`SELECT t1.digest_id FROM (%s) AS t1 %s %s OFFSET ? LIMIT ?;`, subquery, whereClause, orderClause)
|
||||
|
||||
ret := []int{}
|
||||
rows, err := a.DB.Raw(query, p.Offset, p.PerPage).Rows()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting rows")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return []int{}, errors.Wrap(err, "scanning row")
|
||||
}
|
||||
|
||||
ret = append(ret, id)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// GetDigests queries digests for the given user using the given criteria
|
||||
func (a *App) GetDigests(p GetDigestsParam) ([]database.Digest, error) {
|
||||
IDs, err := a.queryDigestIDs(p)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "querying digest IDs")
|
||||
}
|
||||
|
||||
var ret []database.Digest
|
||||
conn := a.DB.Where("id IN (?)", IDs).
|
||||
Order(p.Order).Preload("Rule").Preload("Receipts").
|
||||
Find(&ret)
|
||||
if err := conn.Error; err != nil && !conn.RecordNotFound() {
|
||||
return nil, errors.Wrap(err, "finding digests")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// PreloadDigest preloads associations for the given digest. It returns a new digest.
|
||||
func (a *App) PreloadDigest(d database.Digest) (database.Digest, error) {
|
||||
var ret database.Digest
|
||||
|
||||
conn := a.DB.Where("id = ?", d.ID).
|
||||
Preload("Notes", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("notes.created_at DESC")
|
||||
}).
|
||||
Preload("Notes.Book").
|
||||
Preload("Notes.NoteReview", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("note_reviews.digest_id = ?", d.ID)
|
||||
}).
|
||||
Preload("Rule").
|
||||
Preload("Receipts", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("digest_receipts.user_id = ?", d.UserID)
|
||||
}).First(&ret)
|
||||
|
||||
if err := conn.Error; err != nil {
|
||||
return ret, errors.Wrap(err, "preloading")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestMarkDigestRead(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
a := NewTest(nil)
|
||||
|
||||
// Multiple calls should not create more than 1 receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
ret, err := a.MarkDigestRead(digest, user)
|
||||
if err != nil {
|
||||
t.Fatal(err, "failed to perform")
|
||||
}
|
||||
|
||||
var receiptCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts")
|
||||
assert.Equalf(t, receiptCount, 1, "receipt count mismatch")
|
||||
|
||||
var receipt database.DigestReceipt
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", ret.ID).First(&receipt), "getting receipt")
|
||||
assert.Equalf(t, receipt.UserID, user.ID, "receipt UserID mismatch")
|
||||
assert.Equalf(t, receipt.DigestID, digest.ID, "receipt DigestID mismatch")
|
||||
}
|
||||
}
|
||||
|
|
@ -157,11 +157,6 @@ func (a *App) DeleteNote(tx *gorm.DB, user database.User, note database.Note) (d
|
|||
return note, errors.Wrap(err, "deleting note")
|
||||
}
|
||||
|
||||
// Delete associations
|
||||
if err := tx.Where("note_id = ?", note.ID).Delete(&database.DigestNote{}).Error; err != nil {
|
||||
return note, errors.Wrap(err, "deleting digest_notes")
|
||||
}
|
||||
|
||||
return note, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -259,45 +259,3 @@ func TestDeleteNote(t *testing.T) {
|
|||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNote_DigestNotes(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{UserID: user.ID, Label: "testBook"}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{UserID: user.ID, Deleted: false, Body: "n1", BookUUID: b1.UUID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{UserID: user.ID, Deleted: false, Body: "n2", BookUUID: b1.UUID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
|
||||
d1 := database.Digest{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1")
|
||||
dn1 := database.DigestNote{NoteID: n1.ID, DigestID: d1.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&dn1), "preparing dn1")
|
||||
dn2 := database.DigestNote{NoteID: n2.ID, DigestID: d1.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&dn2), "preparing dn2")
|
||||
|
||||
a := NewTest(nil)
|
||||
|
||||
tx := testutils.DB.Begin()
|
||||
if _, err := a.DeleteNote(tx, user, n1); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatal(errors.Wrap(err, "deleting note"))
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
var noteCount, digestNoteCount int
|
||||
var dn2Record database.DigestNote
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(¬eCount), "counting notes")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.DigestNote{}).Count(&digestNoteCount), "counting digest_notes")
|
||||
|
||||
assert.Equal(t, noteCount, 2, "note count mismatch")
|
||||
assert.Equal(t, digestNoteCount, 1, "digest_notes count mismatch")
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", dn2.ID).First(&dn2Record), "finding dn2")
|
||||
assert.Equal(t, dn2Record.NoteID, dn2.NoteID, "dn2 NoteID mismatch")
|
||||
assert.Equal(t, dn2Record.DigestID, dn2.DigestID, "dn2 DigestID mismatch")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,25 +49,6 @@ func createEmailPreference(user database.User, tx *gorm.DB) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error {
|
||||
r := database.RepetitionRule{
|
||||
Title: "Default repetition - all books",
|
||||
UserID: user.ID,
|
||||
Enabled: false,
|
||||
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 (a *App) CreateUser(email, password string) (database.User, error) {
|
||||
tx := a.DB.Begin()
|
||||
|
|
@ -111,10 +92,6 @@ func (a *App) 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 := a.TouchLastLoginAt(user, tx); err != nil {
|
||||
tx.Rollback()
|
||||
return database.User{}, errors.Wrap(err, "updating last login")
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ const (
|
|||
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 (
|
||||
|
|
|
|||
|
|
@ -47,11 +47,6 @@ func InitSchema(db *gorm.DB) {
|
|||
Token{},
|
||||
EmailPreference{},
|
||||
Session{},
|
||||
Digest{},
|
||||
DigestNote{},
|
||||
RepetitionRule{},
|
||||
DigestReceipt{},
|
||||
NoteReview{},
|
||||
).Error; err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,21 +46,20 @@ type Book struct {
|
|||
// Note is a model for a note
|
||||
type Note struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
Book Book `json:"book" gorm:"foreignkey:BookUUID"`
|
||||
User User `json:"user"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
BookUUID string `json:"book_uuid" gorm:"index;type:uuid"`
|
||||
Body string `json:"content"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
EditedOn int64 `json:"edited_on"`
|
||||
TSV string `json:"-" gorm:"type:tsvector"`
|
||||
Public bool `json:"public" gorm:"default:false"`
|
||||
USN int `json:"-" gorm:"index"`
|
||||
Deleted bool `json:"-" gorm:"default:false"`
|
||||
Encrypted bool `json:"-" gorm:"default:false"`
|
||||
NoteReview NoteReview `json:"-"`
|
||||
Client string `gorm:"index"`
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
Book Book `json:"book" gorm:"foreignkey:BookUUID"`
|
||||
User User `json:"user"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
BookUUID string `json:"book_uuid" gorm:"index;type:uuid"`
|
||||
Body string `json:"content"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
EditedOn int64 `json:"edited_on"`
|
||||
TSV string `json:"-" gorm:"type:tsvector"`
|
||||
Public bool `json:"public" gorm:"default:false"`
|
||||
USN int `json:"-" gorm:"index"`
|
||||
Deleted bool `json:"-" gorm:"default:false"`
|
||||
Encrypted bool `json:"-" gorm:"default:false"`
|
||||
Client string `gorm:"index"`
|
||||
}
|
||||
|
||||
// User is a model for a user
|
||||
|
|
@ -127,59 +126,3 @@ type Session struct {
|
|||
LastUsedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Digest is a digest of notes
|
||||
type Digest struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"type:uuid;index;default:uuid_generate_v4()"`
|
||||
RuleID int `gorm:"index"`
|
||||
Rule RepetitionRule `json:"rule"`
|
||||
UserID int `gorm:"index"`
|
||||
Version int `gorm:"version"`
|
||||
Notes []Note `gorm:"many2many:digest_notes;"`
|
||||
Receipts []DigestReceipt `gorm:"polymorphic:Target;"`
|
||||
}
|
||||
|
||||
// DigestNote is an intermediary to represent many-to-many relationship
|
||||
// between digests and notes
|
||||
type DigestNote struct {
|
||||
Model
|
||||
NoteID int `gorm:"index"`
|
||||
DigestID int `gorm:"index"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// DigestReceipt is a read receipt for digests
|
||||
type DigestReceipt struct {
|
||||
Model
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
DigestID int `json:"digest_id" gorm:"index"`
|
||||
}
|
||||
|
||||
// NoteReview is a record for reviewing a note in a digest
|
||||
type NoteReview struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
DigestID int `json:"digest_id" gorm:"index"`
|
||||
NoteID int `json:"note_id" gorm:"index"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *API) getDigest(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)
|
||||
digestUUID := vars["digestUUID"]
|
||||
|
||||
d, err := a.App.GetUserDigestByUUID(user.ID, digestUUID)
|
||||
if d == nil {
|
||||
RespondNotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.PreloadDigest(*d)
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// mark as read
|
||||
if _, err := a.App.MarkDigestRead(digest, user); err != nil {
|
||||
log.ErrorWrap(err, fmt.Sprintf("marking digest as read for %s", digest.UUID))
|
||||
}
|
||||
|
||||
presented := presenters.PresentDigest(digest)
|
||||
respondJSON(w, http.StatusOK, presented)
|
||||
}
|
||||
|
||||
// DigestsResponse is a response for getting digests
|
||||
type DigestsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Items []presenters.Digest `json:"items"`
|
||||
}
|
||||
|
||||
type getDigestsParams struct {
|
||||
page int
|
||||
status string
|
||||
}
|
||||
|
||||
func parseGetDigestsParams(r *http.Request) (getDigestsParams, error) {
|
||||
var page int
|
||||
var err error
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
pageStr := q.Get("page")
|
||||
if pageStr != "" {
|
||||
page, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return getDigestsParams{}, errors.Wrap(err, "parsing page")
|
||||
}
|
||||
} else {
|
||||
page = 1
|
||||
}
|
||||
|
||||
status := q.Get("status")
|
||||
|
||||
return getDigestsParams{
|
||||
page: page,
|
||||
status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *API) getDigests(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 := parseGetDigestsParams(r)
|
||||
if err != nil {
|
||||
HandleError(w, "parsing params", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
perPage := 30
|
||||
offset := (params.page - 1) * perPage
|
||||
p := app.GetDigestsParam{
|
||||
UserID: user.ID,
|
||||
Offset: offset,
|
||||
PerPage: perPage,
|
||||
Status: params.status,
|
||||
Order: "created_at DESC",
|
||||
}
|
||||
|
||||
digests, err := a.App.GetDigests(p)
|
||||
if err != nil {
|
||||
HandleError(w, "querying digests", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
total, err := a.App.CountDigests(p)
|
||||
if err != nil {
|
||||
HandleError(w, "counting digests", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, DigestsResponse{
|
||||
Total: total,
|
||||
Items: presenters.PresentDigests(digests),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestGetDigest_Permission(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
owner := testutils.SetupUserData()
|
||||
nonOwner := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: owner.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
t.Run("owner", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, owner)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
})
|
||||
|
||||
t.Run("non owner", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, nonOwner)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
})
|
||||
|
||||
t.Run("guest", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDigest_Receipt(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
// multiple requests should create at most one receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
// Execute and test
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var receiptCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts")
|
||||
assert.Equal(t, receiptCount, 1, "counting receipt")
|
||||
|
||||
var receipt database.DigestReceipt
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&receipt), "finding receipt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDigests(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
t.Run("user", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/digests", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
})
|
||||
|
||||
t.Run("guest", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type createNoteReviewParams struct {
|
||||
DigestUUID string `json:"digest_uuid"`
|
||||
NoteUUID string `json:"note_uuid"`
|
||||
}
|
||||
|
||||
func getDigestByUUID(db *gorm.DB, uuid string) (*database.Digest, error) {
|
||||
var ret database.Digest
|
||||
conn := db.Where("uuid = ?", uuid).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "finding digest")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (a *API) createNoteReview(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
|
||||
}
|
||||
|
||||
var params createNoteReviewParams
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
HandleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID)
|
||||
if digest == nil {
|
||||
http.Error(w, "digest not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID)
|
||||
if note == nil {
|
||||
http.Error(w, "note not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var nr database.NoteReview
|
||||
if err := a.App.DB.FirstOrCreate(&nr, database.NoteReview{
|
||||
UserID: user.ID,
|
||||
DigestID: digest.ID,
|
||||
NoteID: note.ID,
|
||||
}).Error; err != nil {
|
||||
HandleError(w, "saving note review", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type deleteNoteReviewParams struct {
|
||||
DigestUUID string `json:"digest_uuid"`
|
||||
NoteUUID string `json:"note_uuid"`
|
||||
}
|
||||
|
||||
func (a *API) deleteNoteReview(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
|
||||
}
|
||||
|
||||
var params deleteNoteReviewParams
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
HandleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
db := a.App.DB
|
||||
|
||||
note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID)
|
||||
if note == nil {
|
||||
http.Error(w, "note not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID)
|
||||
if digest == nil {
|
||||
http.Error(w, "digest not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var nr database.NoteReview
|
||||
conn := db.Where("note_id = ? AND digest_id = ? AND user_id = ?", note.ID, digest.ID, user.ID).First(&nr)
|
||||
if conn.RecordNotFound() {
|
||||
http.Error(w, "no record found", http.StatusBadRequest)
|
||||
return
|
||||
} else if err := conn.Error; err != nil {
|
||||
HandleError(w, "finding record", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Delete(&nr).Error; err != nil {
|
||||
HandleError(w, "deleting record", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestCreateNoteReview(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
d1 := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1")
|
||||
|
||||
// multiple requests should create at most one receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID)
|
||||
req := testutils.MakeReq(server.URL, http.MethodPost, "/note_review", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var noteReviewCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews")
|
||||
assert.Equalf(t, noteReviewCount, 1, "counting note_review")
|
||||
|
||||
var noteReviewRecord database.NoteReview
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND note_id = ? AND digest_id = ?", user.ID, n1.ID, d1.ID).First(¬eReviewRecord), "finding note_review record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNoteReview(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
d1 := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1")
|
||||
nr1 := database.NoteReview{
|
||||
UserID: user.ID,
|
||||
NoteID: n1.ID,
|
||||
DigestID: d1.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&nr1), "preparing nr1")
|
||||
|
||||
dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID)
|
||||
req := testutils.MakeReq(server.URL, http.MethodDelete, "/note_review", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var noteReviewCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews")
|
||||
assert.Equal(t, noteReviewCount, 0, "counting note_review")
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *API) 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
|
||||
}
|
||||
|
||||
var repetitionRule database.RepetitionRule
|
||||
if err := a.App.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 *API) 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
|
||||
}
|
||||
|
||||
var repetitionRules []database.RepetitionRule
|
||||
if err := a.App.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 *API) 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
|
||||
}
|
||||
|
||||
var books []database.Book
|
||||
if err := a.App.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.App.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 := a.App.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 *API) 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"]
|
||||
|
||||
var rule database.RepetitionRule
|
||||
conn := a.App.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 := a.App.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 *API) 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
|
||||
}
|
||||
|
||||
tx := a.App.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.App.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.App.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)
|
||||
}
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestGetRepetitionRule(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.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, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "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, testutils.DB.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.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, testutils.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, testutils.DB.Save(&r2), "preparing rule2")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "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, testutils.DB.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r2.UUID).First(&r2Record), "finding r2Record")
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
|
||||
server := MustNewServer(t, &app.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.URL, "POST", "/repetition_rules", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.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.URL, "POST", "/repetition_rules", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Preload("Books").First(&rule), "finding b1Record")
|
||||
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
server := MustNewServer(t, &app.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, testutils.DB.Save(&r1), "preparing r1")
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.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.URL, "PATCH", endpoint, dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var totalRuleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
|
||||
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Preload("Books").First(&rule), "finding b1Record")
|
||||
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.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, testutils.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, testutils.DB.Save(&r2), "preparing r2")
|
||||
|
||||
endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var totalRuleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
|
||||
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var r2Count int
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", tc)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
// 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, testutils.DB.Save(&r1), "preparing r1")
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1")
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "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, testutils.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()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", tc)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -352,15 +352,6 @@ func (a *API) NewRouter() (*mux.Router, error) {
|
|||
{"GET", "/notes", a.auth(a.getNotes, nil), false},
|
||||
{"GET", "/notes/{noteUUID}", a.getNote, true},
|
||||
{"GET", "/calendar", a.auth(a.getCalendar, nil), true},
|
||||
{"GET", "/repetition_rules", a.auth(a.getRepetitionRules, nil), true},
|
||||
{"GET", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
|
||||
{"POST", "/repetition_rules", a.auth(a.createRepetitionRule, &proOnly), true},
|
||||
{"PATCH", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
|
||||
{"DELETE", "/repetition_rules/{repetitionRuleUUID}", a.auth(a.deleteRepetitionRule, &proOnly), true},
|
||||
{"GET", "/digests/{digestUUID}", a.auth(a.getDigest, nil), true},
|
||||
{"GET", "/digests", a.auth(a.getDigests, nil), true},
|
||||
{"POST", "/note_review", a.auth(a.createNoteReview, nil), true},
|
||||
{"DELETE", "/note_review", a.auth(a.deleteNoteReview, nil), true},
|
||||
|
||||
// migration of classic users
|
||||
{"GET", "/classic/presignin", cors(a.classicPresignin), true},
|
||||
|
|
|
|||
|
|
@ -130,10 +130,6 @@ 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, testutils.DB.Model(&database.RepetitionRule{}).Where("user_id = ?", account.UserID).Count(&repetitionRuleCount), "counting repetition rules")
|
||||
assert.Equal(t, repetitionRuleCount, 1, "repetitionRuleCount mismatch")
|
||||
|
||||
// welcome email
|
||||
assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch")
|
||||
assert.DeepEqual(t, emailBackend.Emails[0].To, []string{tc.email}, "email to mismatch")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/job/remind"
|
||||
"github.com/dnote/dnote/pkg/server/job/repetition"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
|
@ -103,7 +102,6 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) {
|
|||
func (r *Runner) schedule(ch chan error) {
|
||||
// Schedule jobs
|
||||
cr := cron.New()
|
||||
scheduleJob(cr, "* * * * *", func() { r.DoRepetition() })
|
||||
scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() })
|
||||
cr.Start()
|
||||
|
||||
|
|
@ -131,29 +129,6 @@ func (r *Runner) Do() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DoRepetition creates spaced repetitions and delivers the results based on the rules
|
||||
func (r *Runner) DoRepetition() {
|
||||
c := repetition.Context{
|
||||
DB: r.DB,
|
||||
Clock: r.Clock,
|
||||
EmailTmpl: r.EmailTmpl,
|
||||
EmailBackend: r.EmailBackend,
|
||||
Config: r.Config,
|
||||
}
|
||||
|
||||
result, err := repetition.Do(c)
|
||||
m := log.WithFields(log.Fields{
|
||||
"success_count": result.SuccessCount,
|
||||
"failed_rule_uuids": result.FailedRuleUUIDs,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
m.Info("successfully processed repetition job")
|
||||
} else {
|
||||
m.ErrorWrap(err, "error processing repetition job")
|
||||
}
|
||||
}
|
||||
|
||||
// RemindNoRecentNotes remind users if no notes have been added recently
|
||||
func (r *Runner) RemindNoRecentNotes() {
|
||||
c := remind.Context{
|
||||
|
|
|
|||
|
|
@ -143,17 +143,22 @@ func (c *Context) process(info inactiveUserInfo) error {
|
|||
return errors.Wrap(err, "getting sender email")
|
||||
}
|
||||
|
||||
tok, err := mailer.GetToken(c.DB, info.userID, database.TokenTypeEmailPreference)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting email token")
|
||||
}
|
||||
|
||||
tmplData := mailer.InactiveReminderTmplData{
|
||||
WebURL: c.Config.WebURL,
|
||||
SampleNoteUUID: info.sampleNoteUUID,
|
||||
Token: "blah",
|
||||
Token: tok.Value,
|
||||
}
|
||||
body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "executing inactive email template")
|
||||
}
|
||||
|
||||
if err := c.EmailBackend.Queue("Your knowledge base stopped growing", sender, []string{info.email}, mailer.EmailKindText, body); err != nil {
|
||||
if err := c.EmailBackend.Queue("Your Dnote stopped growing", sender, []string{info.email}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrap(err, "queueing email")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Context holds data that repetition job needs in order to perform
|
||||
type Context struct {
|
||||
DB *gorm.DB
|
||||
Clock clock.Clock
|
||||
EmailTmpl mailer.Templates
|
||||
EmailBackend mailer.Backend
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
// BuildEmailParams is the params for building an email
|
||||
type BuildEmailParams struct {
|
||||
Now time.Time
|
||||
User database.User
|
||||
Digest database.Digest
|
||||
Rule database.RepetitionRule
|
||||
}
|
||||
|
||||
// BuildEmail builds an email for the spaced repetition
|
||||
func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (string, string, error) {
|
||||
subject := fmt.Sprintf("%s #%d", p.Rule.Title, p.Digest.Version)
|
||||
tok, err := mailer.GetToken(db, p.User, database.TokenTypeRepetition)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "getting email frequency token")
|
||||
}
|
||||
|
||||
t1 := p.Now.AddDate(0, 0, -3).UnixNano()
|
||||
t2 := p.Now.AddDate(0, 0, -7).UnixNano()
|
||||
|
||||
noteInfos := []mailer.DigestNoteInfo{}
|
||||
for _, note := range p.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 p.Digest.Notes {
|
||||
if ok := bookMap[n.Book.Label]; !ok {
|
||||
bookCount++
|
||||
bookMap[n.Book.Label] = true
|
||||
}
|
||||
}
|
||||
|
||||
tmplData := mailer.DigestTmplData{
|
||||
EmailSessionToken: tok.Value,
|
||||
DigestUUID: p.Digest.UUID,
|
||||
DigestVersion: p.Digest.Version,
|
||||
RuleUUID: p.Rule.UUID,
|
||||
RuleTitle: p.Rule.Title,
|
||||
WebURL: os.Getenv("WebURL"),
|
||||
}
|
||||
body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindText, tmplData)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "executing digest email template")
|
||||
}
|
||||
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func (c Context) getEligibleRules(now time.Time) ([]database.RepetitionRule, error) {
|
||||
hour := now.Hour()
|
||||
minute := now.Minute()
|
||||
|
||||
var ret []database.RepetitionRule
|
||||
if err := c.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, err := operations.CreateDigest(tx, rule, notes)
|
||||
if err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "creating digest")
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (c Context) notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error {
|
||||
var account database.Account
|
||||
if err := c.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
|
||||
}
|
||||
|
||||
subject, body, err := BuildEmail(c.DB, c.EmailTmpl, BuildEmailParams{
|
||||
Now: now,
|
||||
User: user,
|
||||
Digest: digest,
|
||||
Rule: rule,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "making email")
|
||||
}
|
||||
|
||||
sender, err := app.GetSenderEmail(c.Config, "noreply@getdnote.com")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting sender email")
|
||||
}
|
||||
|
||||
if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrap(err, "queueing email")
|
||||
}
|
||||
|
||||
if err := c.DB.Create(&database.Notification{
|
||||
Type: mailer.EmailTypeDigest,
|
||||
UserID: user.ID,
|
||||
}).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 getNextActive(base int64, frequency int64, now time.Time) int64 {
|
||||
candidate := base + frequency
|
||||
if candidate >= now.UnixNano()/int64(time.Millisecond) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return getNextActive(candidate, frequency, now)
|
||||
}
|
||||
|
||||
func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) error {
|
||||
lastActive := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location()).UnixNano() / int64(time.Millisecond)
|
||||
|
||||
rule.LastActive = lastActive
|
||||
rule.NextActive = getNextActive(rule.LastActive, rule.Frequency, now)
|
||||
|
||||
if err := tx.Save(&rule).Error; err != nil {
|
||||
return errors.Wrap(err, "updating repetition rule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Context) process(now time.Time, rule database.RepetitionRule) error {
|
||||
log.WithFields(log.Fields{
|
||||
"uuid": rule.UUID,
|
||||
}).Info("processing repetition")
|
||||
|
||||
tx := c.DB.Begin()
|
||||
|
||||
if !checkCooldown(now, rule) {
|
||||
log.WithFields(log.Fields{
|
||||
"uuid": rule.UUID,
|
||||
}).Info("Skipping repetition processing due to cooldown")
|
||||
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 := c.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
|
||||
}
|
||||
|
||||
// Result holds the result of the job
|
||||
type Result struct {
|
||||
SuccessCount int
|
||||
FailedRuleUUIDs []string
|
||||
}
|
||||
|
||||
// Do creates spaced repetitions and delivers the results based on the rules
|
||||
func Do(c Context) (Result, error) {
|
||||
now := c.Clock.Now().UTC()
|
||||
result := Result{}
|
||||
|
||||
rules, err := c.getEligibleRules(now)
|
||||
if err != nil {
|
||||
return result, 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 {
|
||||
err := c.process(now, rule)
|
||||
|
||||
if err == nil {
|
||||
result.SuccessCount = result.SuccessCount + 1
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"rule uuid": rule.UUID,
|
||||
}).ErrorWrap(err, "Could not process the repetition rule")
|
||||
|
||||
result.FailedRuleUUIDs = append(result.FailedRuleUUIDs, rule.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.FailedRuleUUIDs) > 0 {
|
||||
return result, errors.New("failed to process some rules")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,501 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 (
|
||||
"os"
|
||||
"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/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) {
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", ruleUUID).First(&rule), "finding rule1")
|
||||
|
||||
assert.Equal(t, rule.LastActive, lastActive, "LastActive mismatch")
|
||||
}
|
||||
|
||||
func assertDigestCount(t *testing.T, rule database.RepetitionRule, expected int) {
|
||||
var digestCount int
|
||||
testutils.MustExec(t, testutils.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 getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
|
||||
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
|
||||
|
||||
return Context{
|
||||
DB: testutils.DB,
|
||||
Clock: c,
|
||||
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
|
||||
EmailBackend: be,
|
||||
}
|
||||
}
|
||||
|
||||
func mustDo(t *testing.T, c Context) {
|
||||
_, err := Do(c)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo(t *testing.T) {
|
||||
t.Run("processes the rule on time", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
c := clock.NewMock()
|
||||
be := testutils.MockEmailbackendImplementation{}
|
||||
con := getTestContext(c, &be)
|
||||
|
||||
// Test
|
||||
// 1 day later
|
||||
c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equalf(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
// 2 days later
|
||||
c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
// 3 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 4 day later
|
||||
c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 5 days later
|
||||
c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 6 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 7 days later
|
||||
c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 8 days later
|
||||
c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 9 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257854520000))
|
||||
assertDigestCount(t, r1, 3)
|
||||
assert.Equal(t, len(be.Emails), 3, "email queue count mismatch")
|
||||
})
|
||||
|
||||
/*
|
||||
* |----|----|----|----|----|----|----|----|----|----|----|----|----|
|
||||
* t0 t1 td t2 tu t3 t4
|
||||
*
|
||||
* Suppose a repetition with a frequency of 3 days.
|
||||
*
|
||||
* t0 - original last_active value (Nov 1, 2009)
|
||||
* t1 - original next_active value (Nov 4, 2009)
|
||||
* td - server goes down
|
||||
* t2 - repetition processing is missed (Nov 7, 2009)
|
||||
* tu - server comes up
|
||||
* t3 - new last_active value (Nov 10, 2009)
|
||||
* t4 - new next_active value (Nov 13, 2009)
|
||||
*/
|
||||
t.Run("recovers correct next_active value if missed processing in the past", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 2, 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: t0.UnixNano() / int64(time.Millisecond),
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&rule), "finding rule1")
|
||||
|
||||
assert.Equal(t, rule.LastActive, time.Date(2009, time.November, 10, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "LastActive mismsatch")
|
||||
assert.Equal(t, rule.NextActive, time.Date(2009, time.November, 13, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "NextActive mismsatch")
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDo_Disabled(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
}
|
||||
|
||||
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 {
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "golang",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b3.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 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, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.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, testutils.DB.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 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, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.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, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.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()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 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, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.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, testutils.DB.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
expected := []database.Note{n1Record, n2Record}
|
||||
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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(db *gorm.DB, ruleID int) ([]int, error) {
|
||||
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(db *gorm.DB, noteQuery *gorm.DB, rule database.RepetitionRule) (*gorm.DB, error) {
|
||||
ret := noteQuery
|
||||
|
||||
if rule.BookDomain != database.BookDomainAll {
|
||||
bookIDs, err := getRuleBookIDs(db, 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(db, conn *gorm.DB, rule database.RepetitionRule, dst *[]database.Note) error {
|
||||
c, err := applyBookDomain(db, 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()
|
||||
|
||||
baseConn := db.Where("notes.deleted IS NOT true")
|
||||
|
||||
// Get notes into three buckets with different threshold values
|
||||
var stage1, stage2, stage3 []database.Note
|
||||
if err := getNotes(db, baseConn.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, baseConn.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, baseConn.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
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "golang",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b3.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
|
||||
var n1Record, n2Record, n3Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.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(testutils.DB, testutils.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, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
conn, err := applyBookDomain(testutils.DB, testutils.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{n2Record, n3Record}
|
||||
assert.DeepEqual(t, result, expected, "result mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -36,8 +36,6 @@ var (
|
|||
EmailTypeResetPassword = "reset_password"
|
||||
// EmailTypeResetPasswordAlert represents a password change notification email
|
||||
EmailTypeResetPasswordAlert = "reset_password_alert"
|
||||
// EmailTypeDigest represents a weekly digest email
|
||||
EmailTypeDigest = "digest"
|
||||
// EmailTypeEmailVerification represents an email verification email
|
||||
EmailTypeEmailVerification = "verify_email"
|
||||
// EmailTypeWelcome represents an welcome email
|
||||
|
|
@ -117,10 +115,6 @@ func NewTemplates(srcDir *string) Templates {
|
|||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing password reset template"))
|
||||
}
|
||||
digestText, err := initTextTmpl(box, EmailTypeDigest)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing digest template"))
|
||||
}
|
||||
|
||||
T := Templates{}
|
||||
T.set(EmailTypeResetPassword, EmailKindText, passwordResetText)
|
||||
|
|
@ -129,7 +123,6 @@ func NewTemplates(srcDir *string) Templates {
|
|||
T.set(EmailTypeWelcome, EmailKindText, welcomeText)
|
||||
T.set(EmailTypeInactiveReminder, EmailKindText, inactiveReminderText)
|
||||
T.set(EmailTypeSubscriptionConfirmation, EmailKindText, subscriptionConfirmationText)
|
||||
T.set(EmailTypeDigest, EmailKindText, digestText)
|
||||
|
||||
return T
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,61 +21,15 @@ package main
|
|||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/job/repetition"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c Context) digestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
db := c.DB
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
_, body, err := repetition.BuildEmail(db, c.Tmpl, repetition.BuildEmailParams{
|
||||
Now: now,
|
||||
User: user,
|
||||
Digest: digest,
|
||||
Rule: rule,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func (c Context) passwordResetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := mailer.EmailResetPasswordTmplData{
|
||||
AccountEmail: "alice@example.com",
|
||||
|
|
@ -176,7 +130,6 @@ func main() {
|
|||
ctx := Context{DB: db, Tmpl: tmpl}
|
||||
|
||||
http.HandleFunc("/", ctx.homeHandler)
|
||||
http.HandleFunc("/digest", ctx.digestHandler)
|
||||
http.HandleFunc("/email-verification", ctx.emailVerificationHandler)
|
||||
http.HandleFunc("/password-reset", ctx.passwordResetHandler)
|
||||
http.HandleFunc("/password-reset-alert", ctx.passwordResetAlertHandler)
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
REFRESH YOUR MEMORY
|
||||
|
||||
There is a new automated spaced repetition "{{ .RuleTitle }} #{{ .DigestVersion }}"
|
||||
|
||||
{{ .WebURL }}/digests/{{ .DigestUUID }}
|
||||
|
||||
|
||||
MANAGE THE RULE
|
||||
|
||||
Go to the following link to manage the notification and other settings for "{{ .RuleTitle }}"
|
||||
|
||||
{{ .WebURL }}/preferences/repetitions/{{ .RuleUUID }}?token={{ .EmailSessionToken }}
|
||||
|
||||
- Dnote team
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
Hi, nothing has been added to your Dnote for some time.
|
||||
|
||||
What about revisiting one of your previous knowledge? {{ .WebURL }}/notes/{{ .SampleNoteUUID }}
|
||||
What about revisiting one of your previous notes? {{ .WebURL }}/notes/{{ .SampleNoteUUID }}
|
||||
|
||||
Expand your knowledge base at {{ .WebURL }}/new or using Dnote apps.
|
||||
You can add new notes at {{ .WebURL }}/new or using Dnote apps.
|
||||
|
||||
- Dnote team
|
||||
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ func generateRandomToken(bits int) (string, 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(db *gorm.DB, user database.User, kind string) (database.Token, error) {
|
||||
func GetToken(db *gorm.DB, userID int, kind string) (database.Token, error) {
|
||||
var tok database.Token
|
||||
conn := db.
|
||||
Where("user_id = ? AND type =? AND used_at IS NULL", user.ID, kind).
|
||||
Where("user_id = ? AND type =? AND used_at IS NULL", userID, kind).
|
||||
First(&tok)
|
||||
|
||||
tokenVal, err := generateRandomToken(16)
|
||||
|
|
@ -53,7 +53,7 @@ func GetToken(db *gorm.DB, user database.User, kind string) (database.Token, err
|
|||
|
||||
if conn.RecordNotFound() {
|
||||
tok = database.Token{
|
||||
UserID: user.ID,
|
||||
UserID: userID,
|
||||
Type: kind,
|
||||
Value: tokenVal,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,45 +18,6 @@
|
|||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/justincampbell/timeago"
|
||||
)
|
||||
|
||||
// DigestNoteInfo contains note information for digest emails
|
||||
type DigestNoteInfo struct {
|
||||
UUID string
|
||||
Content string
|
||||
BookLabel string
|
||||
TimeAgo string
|
||||
Stage int
|
||||
}
|
||||
|
||||
// NewNoteInfo returns a new NoteInfo
|
||||
func NewNoteInfo(note database.Note, stage int) DigestNoteInfo {
|
||||
tm := time.Unix(0, int64(note.AddedOn))
|
||||
|
||||
return DigestNoteInfo{
|
||||
UUID: note.UUID,
|
||||
Content: note.Body,
|
||||
BookLabel: note.Book.Label,
|
||||
TimeAgo: timeago.FromTime(tm),
|
||||
Stage: stage,
|
||||
}
|
||||
}
|
||||
|
||||
// DigestTmplData is a template data for digest emails
|
||||
type DigestTmplData struct {
|
||||
EmailSessionToken string
|
||||
DigestUUID string
|
||||
DigestVersion int
|
||||
RuleUUID string
|
||||
RuleTitle string
|
||||
WebURL string
|
||||
}
|
||||
|
||||
// EmailVerificationTmplData is a template data for email verification emails
|
||||
type EmailVerificationTmplData struct {
|
||||
Token string
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 operations
|
||||
|
||||
import (
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// CreateDigest creates a new digest
|
||||
func CreateDigest(db *gorm.DB, rule database.RepetitionRule, notes []database.Note) (database.Digest, error) {
|
||||
var maxVersion int
|
||||
if err := db.Raw("SELECT COALESCE(max(version), 0) FROM digests WHERE rule_id = ?", rule.ID).Row().Scan(&maxVersion); err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "finding max version")
|
||||
}
|
||||
|
||||
digest := database.Digest{
|
||||
RuleID: rule.ID,
|
||||
UserID: rule.UserID,
|
||||
Version: maxVersion + 1,
|
||||
Notes: notes,
|
||||
}
|
||||
if err := db.Save(&digest).Error; err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "saving digest")
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 operations
|
||||
|
||||
import (
|
||||
// "fmt"
|
||||
"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 TestCreateDigest(t *testing.T) {
|
||||
t.Run("no previous digest", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
db := testutils.DB
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
rule := database.RepetitionRule{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
result, err := CreateDigest(db, rule, nil)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, result.Version, 1, "Version mismatch")
|
||||
})
|
||||
|
||||
t.Run("with previous digest", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
db := testutils.DB
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
rule := database.RepetitionRule{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
d := database.Digest{UserID: user.ID, RuleID: rule.ID, Version: 8}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d), "preparing digest")
|
||||
|
||||
result, err := CreateDigest(db, rule, nil)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, result.Version, 9, "Version mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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"`
|
||||
Version int `json:"version"`
|
||||
RepetitionRule RepetitionRule `json:"repetition_rule"`
|
||||
Notes []DigestNote `json:"notes"`
|
||||
IsRead bool `json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DigestNote is a presented note inside a digest
|
||||
type DigestNote struct {
|
||||
Note
|
||||
IsReviewed bool `json:"is_reviewed"`
|
||||
}
|
||||
|
||||
func presentDigestNote(note database.Note) DigestNote {
|
||||
ret := DigestNote{
|
||||
Note: PresentNote(note),
|
||||
IsReviewed: note.NoteReview.UUID != "",
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func presentDigestNotes(notes []database.Note) []DigestNote {
|
||||
ret := []DigestNote{}
|
||||
|
||||
for _, note := range notes {
|
||||
n := presentDigestNote(note)
|
||||
ret = append(ret, n)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigest presents a digest
|
||||
func PresentDigest(digest database.Digest) Digest {
|
||||
ret := Digest{
|
||||
UUID: digest.UUID,
|
||||
Notes: presentDigestNotes(digest.Notes),
|
||||
Version: digest.Version,
|
||||
RepetitionRule: PresentRepetitionRule(digest.Rule),
|
||||
IsRead: len(digest.Receipts) > 0,
|
||||
CreatedAt: digest.CreatedAt,
|
||||
UpdatedAt: digest.UpdatedAt,
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigests presetns digests
|
||||
func PresentDigests(digests []database.Digest) []Digest {
|
||||
ret := []Digest{}
|
||||
|
||||
for _, digest := range digests {
|
||||
p := PresentDigest(digest)
|
||||
ret = append(ret, p)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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"
|
||||
)
|
||||
|
||||
// DigestReceipt is a presented receipt
|
||||
type DigestReceipt struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PresentDigestReceipt presents a receipt
|
||||
func PresentDigestReceipt(receipt database.DigestReceipt) DigestReceipt {
|
||||
ret := DigestReceipt{
|
||||
CreatedAt: receipt.CreatedAt,
|
||||
UpdatedAt: receipt.UpdatedAt,
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigestReceipts presents receipts
|
||||
func PresentDigestReceipts(receipts []database.DigestReceipt) []DigestReceipt {
|
||||
ret := []DigestReceipt{}
|
||||
|
||||
for _, receipt := range receipts {
|
||||
r := PresentDigestReceipt(receipt)
|
||||
ret = append(ret, r)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -84,21 +84,6 @@ func ClearData() {
|
|||
if err := DB.Delete(&database.Session{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear sessions"))
|
||||
}
|
||||
if err := DB.Delete(&database.Digest{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digests"))
|
||||
}
|
||||
if err := DB.Delete(&database.DigestNote{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digests"))
|
||||
}
|
||||
if err := DB.Delete(&database.DigestReceipt{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digest receipts"))
|
||||
}
|
||||
if err := DB.Delete(&database.RepetitionRule{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear repetition rules"))
|
||||
}
|
||||
if err := DB.Delete(&database.NoteReview{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear note review"))
|
||||
}
|
||||
}
|
||||
|
||||
// SetupUserData creates and returns a new user for testing purposes
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/>.
|
||||
*/
|
||||
|
||||
.heading {
|
||||
font-size: 1.7rem;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: inline-block;
|
||||
|
||||
& ~ .radio {
|
||||
margin-left: 26px;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch } from '../../store';
|
||||
import { receiveEmailPreference } from '../../store/auth';
|
||||
import Button from './Button';
|
||||
|
||||
import styles from './EmailPreferenceForm.scss';
|
||||
|
||||
const digestWeekly = 'weekly';
|
||||
const digestNever = 'never';
|
||||
|
||||
function getDigestFrequency(emailPreference: any): string {
|
||||
if (emailPreference.inactive_reminder) {
|
||||
return digestWeekly;
|
||||
}
|
||||
|
||||
return digestNever;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
emailPreference: any;
|
||||
setSuccessMsg: (string) => void;
|
||||
setFailureMsg: (string) => void;
|
||||
token?: string;
|
||||
actionsClassName?: string;
|
||||
}
|
||||
|
||||
const EmailPreferenceForm: React.FunctionComponent<Props> = ({
|
||||
emailPreference,
|
||||
token,
|
||||
setSuccessMsg,
|
||||
setFailureMsg,
|
||||
actionsClassName
|
||||
}) => {
|
||||
const freq = getDigestFrequency(emailPreference);
|
||||
const [digestFrequency, setDigestFrequency] = useState(freq);
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
setSuccessMsg('');
|
||||
setFailureMsg('');
|
||||
setInProgress(true);
|
||||
|
||||
services.users
|
||||
.updateEmailPreference({ inactiveReminder: true, token })
|
||||
.then(updatedPreference => {
|
||||
dispatch(receiveEmailPreference(updatedPreference));
|
||||
|
||||
setSuccessMsg('Updated email preference');
|
||||
setInProgress(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setFailureMsg(`Failed to update. Error: ${err.message}`);
|
||||
setInProgress(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<form id="T-email-pref-form" onSubmit={handleSubmit}>
|
||||
<div className={styles.heading}>Email digest frequency</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.radio}>
|
||||
<label htmlFor="digest-never">
|
||||
<input
|
||||
id="digest-never"
|
||||
type="radio"
|
||||
name="digest"
|
||||
value={digestNever}
|
||||
checked={digestFrequency === digestNever}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setDigestFrequency(val);
|
||||
}}
|
||||
/>
|
||||
Never
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.radio}>
|
||||
<label htmlFor="digest-weekly">
|
||||
<input
|
||||
id="digest-weekly"
|
||||
type="radio"
|
||||
name="digest"
|
||||
value={digestWeekly}
|
||||
checked={digestFrequency === digestWeekly}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setDigestFrequency(val);
|
||||
}}
|
||||
/>
|
||||
Weekly (Friday)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={actionsClassName}>
|
||||
<Button
|
||||
type="submit"
|
||||
kind="first"
|
||||
size="normal"
|
||||
isBusy={inProgress}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailPreferenceForm;
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../App/font';
|
||||
@import '../App/rem';
|
||||
|
||||
.wrapper {
|
||||
margin-top: rem(12px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button {
|
||||
color: $gray;
|
||||
|
||||
&:hover {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
@include font-size('small');
|
||||
|
||||
margin-left: rem(4px);
|
||||
margin-top: rem(2px);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link } from 'react-router-dom';
|
||||
|
||||
import { getDigestPath } from 'web/libs/paths';
|
||||
import { SearchParams } from './types';
|
||||
import CloseIcon from '../Icons/Close';
|
||||
import styles from './ClearSearchBar.scss';
|
||||
|
||||
interface Props {
|
||||
params: SearchParams;
|
||||
digestUUID: string;
|
||||
}
|
||||
|
||||
const ClearSearchBar: React.FunctionComponent<Props> = ({
|
||||
params,
|
||||
digestUUID
|
||||
}) => {
|
||||
const isActive = params.sort !== '' || params.status !== '';
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Link className={styles.button} to={getDigestPath(digestUUID)}>
|
||||
<CloseIcon width={20} height={20} />
|
||||
<span className={styles.text}>
|
||||
Clear the current filters, and sorts
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearSearchBar;
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../App/font';
|
||||
@import '../App/rem';
|
||||
|
||||
.item {
|
||||
text-align: left;
|
||||
|
||||
& ~ & {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: rem(12px);
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action {
|
||||
color: $light-gray;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& ~ & {
|
||||
margin-left: rem(12px);
|
||||
}
|
||||
}
|
||||
|
||||
.error-flash {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
|
||||
.clear-search-bar {
|
||||
margin-top: rem(12px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../App/font';
|
||||
@import '../App/rem';
|
||||
|
||||
.wrapper {
|
||||
padding: rem(40px) rem(16px);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SearchParams, Status } from './types';
|
||||
import styles from './Empty.scss';
|
||||
|
||||
interface Props {
|
||||
params: SearchParams;
|
||||
}
|
||||
|
||||
const Empty: React.FunctionComponent<Props> = ({ params }) => {
|
||||
if (params.status === Status.Unreviewed) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
You have completed reviewing this digest.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={styles.wrapper}>No results matched your filters.</div>;
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.meta {
|
||||
margin-top: rem(4px);
|
||||
@include font-size('small');
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 rem(8px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
z-index: 1;
|
||||
margin-bottom: rem(20px);
|
||||
|
||||
padding-top: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-container {
|
||||
z-index: 1;
|
||||
|
||||
&.header-sticky {
|
||||
background-color: $white;
|
||||
position: sticky;
|
||||
top: $header-height;
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.header {
|
||||
padding-top: rem(12px);
|
||||
padding-bottom: rem(12px);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
|
||||
import { pluralize } from 'web/libs/string';
|
||||
import { DigestData, DigestNoteData } from 'jslib/operations/types';
|
||||
import Time from '../../Common/Time';
|
||||
import formatTime from '../../../helpers/time/format';
|
||||
import { getDigestTitle } from '../helpers';
|
||||
import Progress from './Progress';
|
||||
import styles from './Content.scss';
|
||||
|
||||
function formatCreatedAt(d: Date) {
|
||||
const now = new Date();
|
||||
|
||||
const currentYear = now.getFullYear();
|
||||
const year = d.getFullYear();
|
||||
|
||||
if (currentYear === year) {
|
||||
return formatTime(d, '%MMM %DD');
|
||||
}
|
||||
|
||||
return formatTime(d, '%MMM %DD, %YYYY');
|
||||
}
|
||||
|
||||
function getViewedCount(notes: DigestNoteData[]): number {
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < notes.length; ++i) {
|
||||
const n = notes[i];
|
||||
|
||||
if (n.isReviewed) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
digest: DigestData;
|
||||
}
|
||||
|
||||
const Content: React.FunctionComponent<Props> = ({ digest }) => {
|
||||
const viewedCount = getViewedCount(digest.notes);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
<h1 className="page-heading">{getDigestTitle(digest)}</h1>
|
||||
<div className={styles.meta}>
|
||||
Contains {pluralize('note', digest.notes.length, true)}
|
||||
<span className={styles.sep}>·</span>
|
||||
Created on{' '}
|
||||
<Time
|
||||
id="digest-ts"
|
||||
text={formatCreatedAt(new Date(digest.createdAt))}
|
||||
ms={new Date(digest.createdAt).getTime()}
|
||||
tooltipAlignment="left"
|
||||
tooltipDirection="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress total={digest.notes.length} current={viewedCount} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: rem(24px);
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(md) {
|
||||
height: rem(32px);
|
||||
width: rem(400px);
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
width: rem(80px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
height: rem(20px);
|
||||
width: rem(320px);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 styles from './Placeholder.scss';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HeaderPlaceholder: React.FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={classnames('holder holder-dark', styles.title)} />
|
||||
|
||||
<div className={classnames('holder holder-dark', styles.meta)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderPlaceholder;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
margin-top: rem(4px);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: rem(220px);
|
||||
margin-top: rem(0);
|
||||
margin-bottom: rem(8px);
|
||||
|
||||
display: initial;
|
||||
align-items: initial;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-wrapper {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #c5c6c8;
|
||||
border-radius: 4px;
|
||||
width: rem(120px);
|
||||
margin-left: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: rem(220px);
|
||||
margin-top: rem(4px);
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
transition: width 0.5s ease-out 0s;
|
||||
width: 0%;
|
||||
background: $first;
|
||||
}
|
||||
|
||||
.perc {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.caption {
|
||||
@include font-size('small');
|
||||
|
||||
&.caption-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { pluralize } from 'web/libs/string';
|
||||
import styles from './Progress.scss';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
current: number;
|
||||
}
|
||||
|
||||
function calcPercentage(current: number, total: number): number {
|
||||
if (total === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return (current / total) * 100;
|
||||
}
|
||||
|
||||
function getCaption(current, total): string {
|
||||
if (current === total && total !== 0) {
|
||||
return 'Review completed';
|
||||
}
|
||||
|
||||
return `${current} of ${total} ${pluralize('note', current)} reviewed`;
|
||||
}
|
||||
|
||||
const Progress: React.FunctionComponent<Props> = ({ total, current }) => {
|
||||
const isComplete = current === total;
|
||||
const perc = calcPercentage(current, total);
|
||||
const width = `${perc}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
className={classnames(styles.caption, {
|
||||
[styles['caption-strong']]: isComplete
|
||||
})}
|
||||
>
|
||||
{getCaption(current, total)}{' '}
|
||||
<span className={styles.perc}>({perc.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles['bar-wrapper']}
|
||||
role="progressbar"
|
||||
aria-valuenow={perc}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className={styles.bar} style={{ width }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { DigestData } from 'jslib/operations/types';
|
||||
import { useEventListener } from 'web/libs/hooks';
|
||||
import { getScrollYPos } from 'web/libs/dom';
|
||||
import Placeholder from './Placeholder';
|
||||
import Content from './Content';
|
||||
import styles from './Content.scss';
|
||||
|
||||
interface Props {
|
||||
isFetched: boolean;
|
||||
digest: DigestData;
|
||||
}
|
||||
|
||||
const stickyThresholdY = 24;
|
||||
|
||||
function checkSticky(y: number): boolean {
|
||||
return y > stickyThresholdY;
|
||||
}
|
||||
|
||||
const Header: React.FunctionComponent<Props> = ({ digest, isFetched }) => {
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
|
||||
function handleScroll() {
|
||||
const y = getScrollYPos();
|
||||
const nextSticky = checkSticky(y);
|
||||
|
||||
if (nextSticky) {
|
||||
setIsSticky(true);
|
||||
} else if (!nextSticky) {
|
||||
setIsSticky(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document, 'scroll', handleScroll);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(styles['header-container'], {
|
||||
[styles['header-sticky']]: isSticky
|
||||
})}
|
||||
>
|
||||
<div className="container mobile-fw">
|
||||
<div className={styles.header}>
|
||||
{isFetched ? <Content digest={digest} /> : <Placeholder />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: rem(24px);
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(md) {
|
||||
height: rem(32px);
|
||||
width: rem(400px);
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
width: rem(80px);
|
||||
height: rem(16px);
|
||||
margin-top: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
height: rem(20px);
|
||||
width: rem(320px);
|
||||
}
|
||||
}
|
||||
|
||||
.caret-collapsed {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.header-action {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.book-label {
|
||||
max-width: rem(200px);
|
||||
margin-left: rem(8px);
|
||||
|
||||
@include breakpoint(sm) {
|
||||
max-width: rem(200px);
|
||||
}
|
||||
@include breakpoint(md) {
|
||||
max-width: rem(420px);
|
||||
}
|
||||
@include breakpoint(lg) {
|
||||
max-width: rem(600px);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { DigestNoteData } from 'jslib/operations/types';
|
||||
import { getHomePath } from 'web/libs/paths';
|
||||
import ReviewButton from './ReviewButton';
|
||||
import Button from '../../Common/Button';
|
||||
import CaretIcon from '../../Icons/CaretSolid';
|
||||
|
||||
import noteStyles from '../../Common/Note/Note.scss';
|
||||
import styles from './Header.scss';
|
||||
|
||||
interface Props {
|
||||
note: DigestNoteData;
|
||||
setCollapsed: (boolean) => void;
|
||||
onSetReviewed: (string, boolean) => Promise<any>;
|
||||
setErrMessage: (string) => void;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const Header: React.FunctionComponent<Props> = ({
|
||||
note,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
onSetReviewed,
|
||||
setErrMessage
|
||||
}) => {
|
||||
let fill;
|
||||
if (collapsed) {
|
||||
fill = '#8c8c8c';
|
||||
} else {
|
||||
fill = '#000000';
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={noteStyles.header}>
|
||||
<div className={noteStyles['header-left']}>
|
||||
<Button
|
||||
className={styles['header-action']}
|
||||
type="button"
|
||||
kind="no-ui"
|
||||
onClick={() => {
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
>
|
||||
<CaretIcon
|
||||
fill={fill}
|
||||
width={12}
|
||||
height={12}
|
||||
className={classnames({ [styles['caret-collapsed']]: collapsed })}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<h1
|
||||
className={classnames(noteStyles['book-label'], styles['book-label'])}
|
||||
>
|
||||
<Link to={getHomePath({ book: note.book.label })}>
|
||||
{note.book.label}
|
||||
</Link>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className={noteStyles['header-right']}>
|
||||
<ReviewButton
|
||||
isReviewed={note.isReviewed}
|
||||
noteUUID={note.uuid}
|
||||
setCollapsed={setCollapsed}
|
||||
onSetReviewed={onSetReviewed}
|
||||
setErrMessage={setErrMessage}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
border: 1px solid $border-color;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
// align-items: center;
|
||||
padding: rem(4px) rem(8px);
|
||||
border-radius: rem(4px);
|
||||
margin-left: rem(12px);
|
||||
}
|
||||
|
||||
.text {
|
||||
@include font-size('small');
|
||||
margin-left: rem(4px);
|
||||
user-select: none;
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import digestStyles from '../Digest.scss';
|
||||
import styles from './ReviewButton.scss';
|
||||
|
||||
interface Props {
|
||||
noteUUID: string;
|
||||
isReviewed: boolean;
|
||||
setCollapsed: (boolean) => void;
|
||||
onSetReviewed: (string, boolean) => Promise<any>;
|
||||
setErrMessage: (string) => void;
|
||||
}
|
||||
|
||||
const ReviewButton: React.FunctionComponent<Props> = ({
|
||||
noteUUID,
|
||||
isReviewed,
|
||||
setCollapsed,
|
||||
onSetReviewed,
|
||||
setErrMessage
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(isReviewed);
|
||||
|
||||
return (
|
||||
<label className={styles.wrapper}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => {
|
||||
const val = e.target.checked;
|
||||
|
||||
// update UI optimistically
|
||||
setErrMessage('');
|
||||
setChecked(val);
|
||||
setCollapsed(val);
|
||||
|
||||
onSetReviewed(noteUUID, val).catch(err => {
|
||||
// roll back the UI update in case of error
|
||||
setChecked(!val);
|
||||
setCollapsed(!val);
|
||||
|
||||
setErrMessage(err.message);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className={classnames(digestStyles['header-action'], styles.text)}>
|
||||
Reviewed
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewButton;
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DigestNoteData } from 'jslib/operations/types';
|
||||
import { getNotePath } from 'web/libs/paths';
|
||||
import Note from '../../Common/Note';
|
||||
import Flash from '../../Common/Flash';
|
||||
import NoteItemHeader from './Header';
|
||||
import styles from '../Digest.scss';
|
||||
|
||||
interface Props {
|
||||
note: DigestNoteData;
|
||||
onSetReviewed: (string, boolean) => Promise<any>;
|
||||
}
|
||||
|
||||
const NoteItem: React.FunctionComponent<Props> = ({ note, onSetReviewed }) => {
|
||||
const [collapsed, setCollapsed] = useState(note.isReviewed);
|
||||
const [errorMessage, setErrMessage] = useState('');
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<Note
|
||||
collapsed={collapsed}
|
||||
note={note}
|
||||
header={
|
||||
<Fragment>
|
||||
<NoteItemHeader
|
||||
note={note}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
onSetReviewed={onSetReviewed}
|
||||
setErrMessage={setErrMessage}
|
||||
/>
|
||||
|
||||
<Flash kind="danger" when={errorMessage !== ''}>
|
||||
{errorMessage}
|
||||
</Flash>
|
||||
</Fragment>
|
||||
}
|
||||
footerActions={
|
||||
<Link
|
||||
className={styles.action}
|
||||
to={getNotePath(note.uuid)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Go to note ›
|
||||
</Link>
|
||||
}
|
||||
footerUseTimeAgo
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteItem;
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 Helmet from 'react-helmet';
|
||||
|
||||
import { DigestData } from 'jslib/operations/types';
|
||||
import { DigestNoteData } from 'jslib/operations/types';
|
||||
import { getDigestTitle } from './helpers';
|
||||
import { useDispatch } from '../../store';
|
||||
import { setDigestNoteReviewed } from '../../store/digest';
|
||||
import Placeholder from '../Common/Note/Placeholder';
|
||||
import NoteItem from './NoteItem';
|
||||
import Empty from './Empty';
|
||||
import { SearchParams } from './types';
|
||||
import styles from './Digest.scss';
|
||||
|
||||
interface Props {
|
||||
notes: DigestNoteData[];
|
||||
digest: DigestData;
|
||||
params: SearchParams;
|
||||
isFetched: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const NoteList: React.FunctionComponent<Props> = ({
|
||||
isFetched,
|
||||
isFetching,
|
||||
params,
|
||||
notes,
|
||||
digest
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
function handleSetReviewed(noteUUID: string, isReviewed: boolean) {
|
||||
return dispatch(
|
||||
setDigestNoteReviewed({ digestUUID: digest.uuid, noteUUID, isReviewed })
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Placeholder wrapperClassName={styles.item} />
|
||||
<Placeholder wrapperClassName={styles.item} />
|
||||
<Placeholder wrapperClassName={styles.item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isFetched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
return <Empty params={params} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Helmet>
|
||||
<title>{`${getDigestTitle(digest)} - Digest`}</title>
|
||||
</Helmet>
|
||||
|
||||
<ul id="T-digest-note-list" className={styles.list}>
|
||||
{notes.map(note => {
|
||||
return (
|
||||
<NoteItem
|
||||
key={note.uuid}
|
||||
note={note}
|
||||
onSetReviewed={handleSetReviewed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteList;
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { parseSearchString } from 'jslib/helpers/url';
|
||||
import { getDigestPath } from 'web/libs/paths';
|
||||
import { blacklist } from 'jslib/helpers/obj';
|
||||
import SelectMenu from '../../Common/PageToolbar/SelectMenu';
|
||||
import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss';
|
||||
import { Sort } from '../types';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
digestUUID: string;
|
||||
sort: Sort;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortMenu: React.FunctionComponent<Props> = ({
|
||||
digestUUID,
|
||||
sort,
|
||||
disabled,
|
||||
location
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const optRefs = [useRef(null), useRef(null)];
|
||||
const searchObj = parseSearchString(location.search);
|
||||
|
||||
const options = [
|
||||
{
|
||||
name: 'newest',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestPath(digestUUID, blacklist(searchObj, ['sort']))}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[0]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Newest
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'oldest',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestPath(digestUUID, {
|
||||
...searchObj,
|
||||
sort: Sort.Oldest
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[1]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Oldest
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const isActive = sort === Sort.Oldest;
|
||||
|
||||
let defaultCurrentOptionIdx: number;
|
||||
let sortText: string;
|
||||
if (sort === Sort.Oldest) {
|
||||
defaultCurrentOptionIdx = 1;
|
||||
sortText = 'Oldest';
|
||||
} else {
|
||||
defaultCurrentOptionIdx = 0;
|
||||
sortText = 'Newest';
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectMenu
|
||||
wrapperClassName={styles['menu-trigger']}
|
||||
defaultCurrentOptionIdx={defaultCurrentOptionIdx}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
optRefs={optRefs}
|
||||
triggerId="sort-menu-trigger"
|
||||
menuId="sort-menu"
|
||||
headerText="Sort by"
|
||||
triggerClassName={classnames('button-no-padding', {
|
||||
[styles['active-menu-trigger']]: isActive
|
||||
})}
|
||||
triggerText={`Sort: ${sortText}`}
|
||||
alignment="right"
|
||||
direction="bottom"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(SortMenu);
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { getDigestPath } from 'web/libs/paths';
|
||||
import { parseSearchString } from 'jslib/helpers/url';
|
||||
import { blacklist } from 'jslib/helpers/obj';
|
||||
import SelectMenu from '../../Common/PageToolbar/SelectMenu';
|
||||
import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss';
|
||||
import { Status } from '../types';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
digestUUID: string;
|
||||
status: Status;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const StatusMenu: React.FunctionComponent<Props> = ({
|
||||
digestUUID,
|
||||
status,
|
||||
disabled,
|
||||
location
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const optRefs = [useRef(null), useRef(null), useRef(null)];
|
||||
const searchObj = parseSearchString(location.search);
|
||||
|
||||
const options = [
|
||||
{
|
||||
name: 'unreviewed',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestPath(digestUUID, blacklist(searchObj, ['status']))}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[0]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Unreviewed
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'reviewed',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestPath(digestUUID, {
|
||||
...searchObj,
|
||||
status: Status.Reviewed
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[1]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Reviewed
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'all',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestPath(digestUUID, {
|
||||
...searchObj,
|
||||
status: Status.All
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[2]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const isActive = status === Status.Reviewed || status === Status.All;
|
||||
|
||||
let defaultCurrentOptionIdx: number;
|
||||
let statusText: string;
|
||||
if (status === Status.Reviewed) {
|
||||
defaultCurrentOptionIdx = 1;
|
||||
statusText = 'Reviewed';
|
||||
} else if (status === Status.All) {
|
||||
defaultCurrentOptionIdx = 2;
|
||||
statusText = 'All';
|
||||
} else {
|
||||
defaultCurrentOptionIdx = 0;
|
||||
statusText = 'Unreviewed';
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectMenu
|
||||
wrapperClassName={styles['menu-trigger']}
|
||||
defaultCurrentOptionIdx={defaultCurrentOptionIdx}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
optRefs={optRefs}
|
||||
triggerId="status-menu-trigger"
|
||||
menuId="status-menu"
|
||||
headerText="Filter by status"
|
||||
triggerClassName={classnames('button-no-padding', {
|
||||
[styles['active-menu-trigger']]: isActive
|
||||
})}
|
||||
triggerText={`Status: ${statusText}`}
|
||||
alignment="left"
|
||||
alignmentMd="right"
|
||||
direction="bottom"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(StatusMenu);
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
padding: 0 rem(16px);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.active-menu-trigger {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-trigger ~ .menu-trigger {
|
||||
margin-left: rem(12px);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 PageToolbar from '../../Common/PageToolbar';
|
||||
import SortMenu from './SortMenu';
|
||||
import StatusMenu from './StatusMenu';
|
||||
import { Sort, Status } from '../types';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
interface Props {
|
||||
digestUUID: string;
|
||||
sort: Sort;
|
||||
status: Status;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
const Toolbar: React.FunctionComponent<Props> = ({
|
||||
digestUUID,
|
||||
sort,
|
||||
status,
|
||||
isFetched
|
||||
}) => {
|
||||
return (
|
||||
<PageToolbar wrapperClassName={styles.wrapper}>
|
||||
<StatusMenu
|
||||
digestUUID={digestUUID}
|
||||
status={status}
|
||||
disabled={!isFetched}
|
||||
/>
|
||||
|
||||
<SortMenu digestUUID={digestUUID} sort={sort} disabled={!isFetched} />
|
||||
</PageToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { DigestData } from 'jslib/operations/types';
|
||||
|
||||
// getDigestTitle returns a title for the digest
|
||||
export function getDigestTitle(digest: DigestData) {
|
||||
return `${digest.repetitionRule.title} #${digest.version}`;
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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, { useEffect } from 'react';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Location } from 'history';
|
||||
|
||||
import { DigestNoteData } from 'jslib/operations/types';
|
||||
import { parseSearchString } from 'jslib/helpers/url';
|
||||
import { usePrevious } from 'web/libs/hooks';
|
||||
import { Sort, Status, SearchParams } from './types';
|
||||
import { getDigest } from '../../store/digest';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import Header from './Header';
|
||||
import Toolbar from './Toolbar';
|
||||
import NoteList from './NoteList';
|
||||
import Flash from '../Common/Flash';
|
||||
import ClearSearchBar from './ClearSearchBar';
|
||||
import styles from './Digest.scss';
|
||||
|
||||
function useFetchData(digestUUID: string) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { digest } = useSelector(state => {
|
||||
return {
|
||||
digest: state.digest
|
||||
};
|
||||
});
|
||||
|
||||
const prevDigestUUID = usePrevious(digestUUID);
|
||||
|
||||
useEffect(() => {
|
||||
if (!digest.isFetched || (digestUUID && prevDigestUUID !== digestUUID)) {
|
||||
dispatch(getDigest(digestUUID));
|
||||
}
|
||||
}, [dispatch, digestUUID, digest.isFetched, prevDigestUUID]);
|
||||
}
|
||||
|
||||
interface Match {
|
||||
digestUUID: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Match> {}
|
||||
|
||||
function getNotes(notes: DigestNoteData[], p: SearchParams): DigestNoteData[] {
|
||||
const filtered = notes.filter(note => {
|
||||
if (p.status === Status.Reviewed) {
|
||||
return note.isReviewed;
|
||||
}
|
||||
if (p.status === Status.Unreviewed) {
|
||||
return !note.isReviewed;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return filtered.concat().sort((i, j) => {
|
||||
if (p.sort === Sort.Oldest) {
|
||||
return new Date(i.createdAt).getTime() - new Date(j.createdAt).getTime();
|
||||
}
|
||||
|
||||
return new Date(j.createdAt).getTime() - new Date(i.createdAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
[Status.All]: Status.All,
|
||||
[Status.Reviewed]: Status.Reviewed,
|
||||
[Status.Unreviewed]: Status.Unreviewed
|
||||
};
|
||||
|
||||
const sortMap = {
|
||||
[Sort.Newest]: Sort.Newest,
|
||||
[Sort.Oldest]: Sort.Oldest
|
||||
};
|
||||
|
||||
function parseSearchParams(location: Location): SearchParams {
|
||||
const searchObj = parseSearchString(location.search);
|
||||
|
||||
const status = statusMap[searchObj.status] || Status.Unreviewed;
|
||||
const sort = sortMap[searchObj.sort] || Sort.Newest;
|
||||
|
||||
return {
|
||||
sort,
|
||||
status,
|
||||
books: []
|
||||
};
|
||||
}
|
||||
|
||||
const Digest: React.FunctionComponent<Props> = ({ location, match }) => {
|
||||
const { digestUUID } = match.params;
|
||||
|
||||
useFetchData(digestUUID);
|
||||
|
||||
const { digest } = useSelector(state => {
|
||||
return {
|
||||
digest: state.digest
|
||||
};
|
||||
});
|
||||
|
||||
const params = parseSearchParams(location);
|
||||
const notes = getNotes(digest.data.notes, params);
|
||||
|
||||
return (
|
||||
<div className="page page-mobile-full">
|
||||
<Helmet>
|
||||
<title>Digest</title>
|
||||
</Helmet>
|
||||
|
||||
<Header digest={digest.data} isFetched={digest.isFetched} />
|
||||
|
||||
<div className="container mobile-fw">
|
||||
<Flash kind="danger" wrapperClassName={styles.flash}>
|
||||
Spaced repetition is deprecated and will be removed in the next major
|
||||
release.
|
||||
</Flash>
|
||||
|
||||
<Toolbar
|
||||
digestUUID={digest.data.uuid}
|
||||
sort={params.sort}
|
||||
status={params.status}
|
||||
isFetched={digest.isFetched}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mobile-fw">
|
||||
<ClearSearchBar params={params} digestUUID={digest.data.uuid} />
|
||||
</div>
|
||||
|
||||
<div className="container mobile-nopadding">
|
||||
<Flash
|
||||
kind="danger"
|
||||
when={digest.errorMessage !== null}
|
||||
wrapperClassName={styles['error-flash']}
|
||||
>
|
||||
Error getting digest: {digest.errorMessage}
|
||||
</Flash>
|
||||
</div>
|
||||
|
||||
<div className="container mobile-nopadding">
|
||||
<NoteList
|
||||
digest={digest.data}
|
||||
params={params}
|
||||
notes={notes}
|
||||
isFetched={digest.isFetched}
|
||||
isFetching={digest.isFetching}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(Digest);
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/>.
|
||||
*/
|
||||
|
||||
// Sort is a set of possible values for sort query parameters
|
||||
export enum Sort {
|
||||
Newest = '',
|
||||
Oldest = 'created-asc'
|
||||
}
|
||||
|
||||
// Status is a set of possible values for status query parameters
|
||||
export enum Status {
|
||||
Unreviewed = '',
|
||||
Reviewed = 'reviewed',
|
||||
All = 'all'
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
sort: Sort;
|
||||
status: Status;
|
||||
books: string[];
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../App/font';
|
||||
@import '../App/rem';
|
||||
|
||||
.flash {
|
||||
margin-top: rem(20px);
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/variables';
|
||||
@import '../App/font';
|
||||
@import '../App/rem';
|
||||
|
||||
.wrapper {
|
||||
padding: rem(40px) rem(16px);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.support {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
|
||||
.md-support {
|
||||
display: none;
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getRepetitionsPath } from 'web/libs/paths';
|
||||
import styles from './Empty.scss';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Empty: React.FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h3>No digests were found.</h3>
|
||||
|
||||
<p className={styles.support}>
|
||||
You could <Link to={getRepetitionsPath()}>create repetition rules</Link>{' '}
|
||||
first.
|
||||
</p>
|
||||
|
||||
<p className={styles['md-support']}>
|
||||
Digests are automatically created based on your repetition rules.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 {
|
||||
background: white;
|
||||
position: relative;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: rem(4px);
|
||||
border-top-right-radius: rem(4px);
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom-left-radius: rem(4px);
|
||||
border-bottom-right-radius: rem(4px);
|
||||
}
|
||||
|
||||
&.unread {
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&.read {
|
||||
.title {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: $black;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: rem(12px) rem(16px);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: $light-blue;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ts {
|
||||
color: $gray;
|
||||
@include font-size('small');
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { DigestData } from 'jslib/operations/types';
|
||||
import { getDigestPath } from 'web/libs/paths';
|
||||
import Time from '../Common/Time';
|
||||
import { timeAgo } from '../../helpers/time';
|
||||
import styles from './Item.scss';
|
||||
|
||||
interface Props {
|
||||
item: DigestData;
|
||||
}
|
||||
|
||||
const Item: React.FunctionComponent<Props> = ({ item }) => {
|
||||
const createdAt = new Date(item.createdAt);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classnames(styles.wrapper, {
|
||||
[styles.read]: item.isRead,
|
||||
[styles.unread]: !item.isRead
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
id={`digest-item-${item.uuid}`}
|
||||
to={getDigestPath(item.uuid)}
|
||||
className={styles.link}
|
||||
>
|
||||
<span className={styles.title}>
|
||||
{item.repetitionRule.title} #{item.version}
|
||||
</span>
|
||||
<Time
|
||||
id={`${item.uuid}-ts`}
|
||||
text={timeAgo(createdAt.getTime())}
|
||||
ms={createdAt.getTime()}
|
||||
wrapperClassName={styles.ts}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 {
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
|
||||
border-radius: rem(4px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
margin-top: rem(16px);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { DigestData } from 'jslib/operations/types';
|
||||
import { getRange } from 'jslib/helpers/arr';
|
||||
import Item from './Item';
|
||||
import Empty from './Empty';
|
||||
import Placeholder from './Placeholder';
|
||||
import styles from './List.scss';
|
||||
|
||||
interface Props {
|
||||
isFetched: boolean;
|
||||
isFetching: boolean;
|
||||
items: DigestData[];
|
||||
}
|
||||
|
||||
const List: React.FunctionComponent<Props> = ({
|
||||
items,
|
||||
isFetched,
|
||||
isFetching
|
||||
}) => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{getRange(10).map(key => {
|
||||
return <Placeholder key={key} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isFetched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={classnames('list-unstyled', styles.wrapper)}>
|
||||
{items.map(item => {
|
||||
return <Item key={item.uuid} item={item} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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';
|
||||
|
||||
.wrapper {
|
||||
padding: rem(12px) rem(16px);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: rem(20px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: 152px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 itemStyles from './Item.scss';
|
||||
import styles from './Placeholder.scss';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<div className={classnames(itemStyles.wrapper, styles.wrapper)}>
|
||||
<div className={classnames('holder', styles.title)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { Link } from 'react-router-dom';
|
||||
|
||||
import { getDigestsPath } from 'web/libs/paths';
|
||||
import SelectMenu from '../../Common/PageToolbar/SelectMenu';
|
||||
import selectMenuStyles from '../../Common/PageToolbar/SelectMenu.scss';
|
||||
import { Status } from '../types';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
interface Props {
|
||||
status: Status;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const StatusMenu: React.FunctionComponent<Props> = ({ status, disabled }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const optRefs = [useRef(null), useRef(null), useRef(null)];
|
||||
|
||||
const options = [
|
||||
{
|
||||
name: 'all',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestsPath({ status: Status.All })}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[0]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'unread',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestsPath({ status: Status.Unread })}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[1]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Unread
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'read',
|
||||
value: (
|
||||
<Link
|
||||
role="menuitem"
|
||||
className={selectMenuStyles.link}
|
||||
to={getDigestsPath({ status: Status.Read })}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
ref={optRefs[2]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Read
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
let defaultCurrentOptionIdx: number;
|
||||
let triggerText: string;
|
||||
if (status === Status.Read) {
|
||||
defaultCurrentOptionIdx = 2;
|
||||
triggerText = 'Read';
|
||||
} else if (status === Status.Unread) {
|
||||
defaultCurrentOptionIdx = 1;
|
||||
triggerText = 'Unread';
|
||||
} else {
|
||||
defaultCurrentOptionIdx = 0;
|
||||
triggerText = 'All';
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectMenu
|
||||
defaultCurrentOptionIdx={defaultCurrentOptionIdx}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
optRefs={optRefs}
|
||||
triggerId="sort-menu-trigger"
|
||||
menuId="sort-menu"
|
||||
headerText="Status"
|
||||
triggerText={`Status: ${triggerText}`}
|
||||
wrapperClassName={styles['select-menu-wrapper']}
|
||||
alignment="left"
|
||||
direction="bottom"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusMenu;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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';
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.select-menu-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: rem(8px);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { getDigestsPath } from 'web/libs/paths';
|
||||
import PageToolbar from '../../Common/PageToolbar';
|
||||
import Paginator from '../../Common/PageToolbar/Paginator';
|
||||
import StatusMenu from './StatusMenu';
|
||||
import { Status } from '../types';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
page: number;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const PER_PAGE = 30;
|
||||
|
||||
const Toolbar: React.FunctionComponent<Props> = ({ total, page, status }) => {
|
||||
return (
|
||||
<PageToolbar wrapperClassName={styles.toolbar}>
|
||||
<StatusMenu status={status} />
|
||||
|
||||
<Paginator
|
||||
perPage={PER_PAGE}
|
||||
total={total}
|
||||
currentPage={page}
|
||||
getPath={(p: number) => {
|
||||
return getDigestsPath({ page: p });
|
||||
}}
|
||||
/>
|
||||
</PageToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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, { useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { usePrevious } from 'web/libs/hooks';
|
||||
import { parseSearchString } from 'jslib/helpers/url';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { getDigests } from '../../store/digests';
|
||||
import { Status } from './types';
|
||||
import Flash from '../Common/Flash';
|
||||
import List from './List';
|
||||
import Toolbar from './Toolbar';
|
||||
import styles from './Digests.scss';
|
||||
|
||||
function useFetchDigests(params: { page: number; status: Status }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const prevParams = usePrevious(params);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!prevParams ||
|
||||
prevParams.page !== params.page ||
|
||||
prevParams.status !== params.status
|
||||
) {
|
||||
dispatch(getDigests(params));
|
||||
}
|
||||
}, [dispatch, params, prevParams]);
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
const Digests: React.FunctionComponent<Props> = ({ location }) => {
|
||||
const { digests } = useSelector(state => {
|
||||
return {
|
||||
digests: state.digests,
|
||||
user: state.auth.user.data
|
||||
};
|
||||
});
|
||||
const { page, status } = parseSearchString(location.search);
|
||||
useFetchDigests({
|
||||
page: page || 1,
|
||||
status
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page page-mobile-full">
|
||||
<Helmet>
|
||||
<title>Digests</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="container mobile-fw">
|
||||
<div className="page-header">
|
||||
<h1 className="page-heading">Digests</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mobile-nopadding">
|
||||
<Flash
|
||||
kind="danger"
|
||||
when={Boolean(digests.errorMessage)}
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
Error getting digests: {digests.errorMessage}
|
||||
</Flash>
|
||||
|
||||
<Flash kind="danger" wrapperClassName={styles.flash}>
|
||||
Spaced repetition is deprecated and will be removed in the next major
|
||||
release.
|
||||
</Flash>
|
||||
</div>
|
||||
|
||||
<div className="container mobile-nopadding">
|
||||
<Toolbar total={digests.total} page={digests.page} status={status} />
|
||||
|
||||
<List
|
||||
isFetching={digests.isFetching}
|
||||
isFetched={digests.isFetched}
|
||||
items={digests.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Digests;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/>.
|
||||
*/
|
||||
|
||||
export enum Status {
|
||||
All = '',
|
||||
Read = 'read',
|
||||
Unread = 'unread'
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
|
||||
import { RepetitionRuleData } from 'jslib/operations/types';
|
||||
import services from 'web/libs/services';
|
||||
import Button from '../../Common/Button';
|
||||
import styles from './EmailPreferenceRepetition.scss';
|
||||
|
||||
interface Props {
|
||||
data: RepetitionRuleData;
|
||||
setSuccessMsg: (string) => void;
|
||||
setFailureMsg: (string) => void;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const Content: React.FunctionComponent<Props> = ({
|
||||
data,
|
||||
token,
|
||||
setSuccessMsg,
|
||||
setFailureMsg
|
||||
}) => {
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(data.enabled);
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
setSuccessMsg('');
|
||||
setFailureMsg('');
|
||||
setInProgress(true);
|
||||
|
||||
services.repetitionRules
|
||||
.update(data.uuid, { enabled: isEnabled }, { token })
|
||||
.then(() => {
|
||||
setSuccessMsg('Updated the repetition.');
|
||||
setInProgress(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setFailureMsg(`Failed to update. Error: ${err.message}`);
|
||||
setInProgress(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Toggle the repetition for "{data.title}"</p>
|
||||
|
||||
<form id="T-pref-repetition-form" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<div className={styles.radio}>
|
||||
<label htmlFor="repetition-off">
|
||||
<input
|
||||
id="repetition-off"
|
||||
type="radio"
|
||||
name="repetition"
|
||||
value="off"
|
||||
checked={!isEnabled}
|
||||
onChange={() => {
|
||||
setIsEnabled(false);
|
||||
}}
|
||||
/>
|
||||
Disable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.radio}>
|
||||
<label htmlFor="repetition-on">
|
||||
<input
|
||||
id="repetition-on"
|
||||
type="radio"
|
||||
name="repetition"
|
||||
value="on"
|
||||
checked={isEnabled}
|
||||
onChange={() => {
|
||||
setIsEnabled(true);
|
||||
}}
|
||||
/>
|
||||
Enable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" kind="first" size="normal" isBusy={inProgress}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 {
|
||||
text-align: center;
|
||||
height: 100vh;
|
||||
padding: rem(52px) 0;
|
||||
background: $lighter-gray;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@include font-size('2x-large');
|
||||
color: $black;
|
||||
font-weight: 300;
|
||||
margin-top: rem(16px);
|
||||
}
|
||||
|
||||
.body {
|
||||
text-align: left;
|
||||
padding: rem(20px) rem(28px);
|
||||
margin-top: rem(20px);
|
||||
max-width: rem(700px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@include font-size('small');
|
||||
text-align: center;
|
||||
margin-top: rem(20px);
|
||||
|
||||
a {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: inline-block;
|
||||
|
||||
& ~ .radio {
|
||||
margin-left: 26px;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 classnames from 'classnames';
|
||||
import { parseSearchString } from 'jslib/helpers/url';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { getLoginPath } from 'web/libs/paths';
|
||||
import services from 'web/libs/services';
|
||||
import Flash from '../../Common/Flash';
|
||||
import Logo from '../../Icons/Logo';
|
||||
import Content from './Content';
|
||||
import styles from './EmailPreferenceRepetition.scss';
|
||||
|
||||
interface Match {
|
||||
repetitionUUID: string;
|
||||
}
|
||||
interface Props extends RouteComponentProps<Match> {}
|
||||
|
||||
const EmailPreferenceRepetition: React.FunctionComponent<Props> = ({
|
||||
location,
|
||||
match
|
||||
}) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [failureMsg, setFailureMsg] = useState('');
|
||||
|
||||
const { token } = parseSearchString(location.search);
|
||||
const { repetitionUUID } = match.params;
|
||||
|
||||
useEffect(() => {
|
||||
if (data !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetching(true);
|
||||
|
||||
services.repetitionRules
|
||||
.fetch(repetitionUUID, { token })
|
||||
.then(repetition => {
|
||||
setData(repetition);
|
||||
setIsFetching(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.response.status === 401) {
|
||||
setFailureMsg('Your email token has expired or is not valid.');
|
||||
} else {
|
||||
setFailureMsg(err.message);
|
||||
}
|
||||
|
||||
setIsFetching(false);
|
||||
});
|
||||
}, [data, repetitionUUID, setData, setFailureMsg, setIsFetching, token]);
|
||||
|
||||
const isFetched = data !== null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Helmet>
|
||||
<title>Toggle repetition</title>
|
||||
</Helmet>
|
||||
|
||||
<Link to="/">
|
||||
<Logo fill="#252833" width={60} height={60} />
|
||||
</Link>
|
||||
<h1 className={styles.heading}>Toggle repetition</h1>
|
||||
|
||||
<div className="container">
|
||||
<div className={styles.body}>
|
||||
<Flash
|
||||
when={failureMsg !== ''}
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
{failureMsg}{' '}
|
||||
<span>
|
||||
Please <Link to={getLoginPath()}>login</Link> and try again.
|
||||
</span>
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
when={successMsg !== ''}
|
||||
kind="success"
|
||||
wrapperClassName={classnames(styles.flash, 'T-success')}
|
||||
>
|
||||
{successMsg}
|
||||
</Flash>
|
||||
|
||||
{isFetching && <div>Loading</div>}
|
||||
|
||||
{isFetched && (
|
||||
<Content
|
||||
token={token}
|
||||
data={data}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Link to="/">Back to Dnote home</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(EmailPreferenceRepetition);
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
getNewRepetitionPath,
|
||||
getSettingsPath,
|
||||
SettingSections,
|
||||
repetitionsPathDef
|
||||
} from 'web/libs/paths';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { getRepetitionRules } from '../../store/repetitionRules';
|
||||
import RepetitionList from './RepetitionList';
|
||||
import DeleteRepetitionRuleModal from './DeleteRepetitionRuleModal';
|
||||
import Flash from '../Common/Flash';
|
||||
import { setMessage } from '../../store/ui';
|
||||
import styles from './Repetition.scss';
|
||||
|
||||
const Content: React.FunctionComponent = () => {
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(getRepetitionRules());
|
||||
}, [dispatch]);
|
||||
|
||||
const { repetitionRules, user } = useSelector(state => {
|
||||
return {
|
||||
repetitionRules: state.repetitionRules,
|
||||
user: state.auth.user.data
|
||||
};
|
||||
});
|
||||
|
||||
const [ruleUUIDToDelete, setRuleUUIDToDelete] = useState('');
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="container mobile-fw">
|
||||
<div className={classnames('page-header', styles.header)}>
|
||||
<h1 className="page-heading">Repetition</h1>
|
||||
|
||||
{!user.pro ? (
|
||||
<button
|
||||
disabled
|
||||
type="button"
|
||||
className="button button-first button-normal"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
id="T-new-rule-btn"
|
||||
className="button button-first button-normal"
|
||||
to={getNewRepetitionPath()}
|
||||
>
|
||||
New
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mobile-nopadding">
|
||||
<Flash kind="danger" wrapperClassName={styles.flash}>
|
||||
Spaced repetition is deprecated and will be removed in the next major
|
||||
release.
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
when={user.pro && !user.emailVerified}
|
||||
kind="warning"
|
||||
wrapperClassName={styles.flash}
|
||||
>
|
||||
Please verify your email address in order to receive digests.{' '}
|
||||
<Link to={getSettingsPath(SettingSections.account)}>
|
||||
Go to settings.
|
||||
</Link>
|
||||
</Flash>
|
||||
|
||||
<div className={styles.content}>
|
||||
<RepetitionList
|
||||
isFetching={repetitionRules.isFetching}
|
||||
isFetched={repetitionRules.isFetched}
|
||||
items={repetitionRules.data}
|
||||
setRuleUUIDToDelete={setRuleUUIDToDelete}
|
||||
pro={user.pro}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteRepetitionRuleModal
|
||||
repetitionRuleUUID={ruleUUIDToDelete}
|
||||
isOpen={ruleUUIDToDelete !== ''}
|
||||
onDismiss={() => {
|
||||
setRuleUUIDToDelete('');
|
||||
}}
|
||||
setSuccessMessage={message => {
|
||||
dispatch(
|
||||
setMessage({
|
||||
message,
|
||||
kind: 'info',
|
||||
path: repetitionsPathDef
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/font';
|
||||
@import '../App/theme';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.title {
|
||||
@include font-size('medium');
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 100%;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
.rule-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { RepetitionRuleData } from 'jslib/operations/types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { removeRepetitionRule } from '../../store/repetitionRules';
|
||||
import Button from '../Common/Button';
|
||||
import Flash from '../Common/Flash';
|
||||
import Modal, { Body, Header } from '../Common/Modal';
|
||||
import styles from './DeleteRepetitionRuleModal.scss';
|
||||
|
||||
function getRepetitionRuleByUUID(
|
||||
repetitionRules,
|
||||
uuid
|
||||
): RepetitionRuleData | null {
|
||||
for (let i = 0; i < repetitionRules.length; ++i) {
|
||||
const r = repetitionRules[i];
|
||||
|
||||
if (r.uuid === uuid) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
setSuccessMessage: (string) => void;
|
||||
repetitionRuleUUID: string;
|
||||
}
|
||||
|
||||
const DeleteRepetitionModal: React.FunctionComponent<Props> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
setSuccessMessage,
|
||||
repetitionRuleUUID
|
||||
}) => {
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { repetitionRules } = useSelector(state => {
|
||||
return {
|
||||
repetitionRules: state.repetitionRules
|
||||
};
|
||||
});
|
||||
|
||||
const rule = getRepetitionRuleByUUID(
|
||||
repetitionRules.data,
|
||||
repetitionRuleUUID
|
||||
);
|
||||
|
||||
const labelId = 'delete-rule-modal-label';
|
||||
const descId = 'delete-rule-modal-desc';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setErrMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (rule === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalId="T-delete-repetition-rule-modal"
|
||||
isOpen={isOpen}
|
||||
onDismiss={onDismiss}
|
||||
ariaLabelledBy={labelId}
|
||||
ariaDescribedBy={descId}
|
||||
size="small"
|
||||
>
|
||||
<Header
|
||||
labelId={labelId}
|
||||
heading="Delete the repetition rule"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
|
||||
<Flash
|
||||
kind="danger"
|
||||
onDismiss={() => {
|
||||
setErrMessage('');
|
||||
}}
|
||||
hasBorder={false}
|
||||
when={Boolean(errMessage)}
|
||||
noMargin
|
||||
>
|
||||
{errMessage}
|
||||
</Flash>
|
||||
|
||||
<Flash kind="warning" id={descId} noMargin>
|
||||
<span>
|
||||
This action will permanently remove the following repetition rule:{' '}
|
||||
</span>
|
||||
<span className={styles['rule-label']}>{rule.title}</span>
|
||||
</Flash>
|
||||
|
||||
<Body>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
setSuccessMessage('');
|
||||
setInProgress(true);
|
||||
|
||||
services.repetitionRules
|
||||
.remove(repetitionRuleUUID)
|
||||
.then(() => {
|
||||
dispatch(removeRepetitionRule(repetitionRuleUUID));
|
||||
setInProgress(false);
|
||||
onDismiss();
|
||||
|
||||
// Scroll to top so that the message is visible.
|
||||
setSuccessMessage(
|
||||
`Successfully removed the rule "${rule.title}"`
|
||||
);
|
||||
window.scrollTo(0, 0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('Error deleting rule', err);
|
||||
setInProgress(false);
|
||||
setErrMessage(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
size="normal"
|
||||
kind="second"
|
||||
onClick={onDismiss}
|
||||
disabled={inProgress}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="normal"
|
||||
kind="danger"
|
||||
disabled={inProgress}
|
||||
isBusy={inProgress}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(DeleteRepetitionModal);
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 { booksToOptions } from 'jslib/helpers/select';
|
||||
import { RepetitionRuleData } from 'jslib/operations/types';
|
||||
import React from 'react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { getRepetitionsPath, repetitionsPathDef } from 'web/libs/paths';
|
||||
import services from 'web/libs/services';
|
||||
import { useDispatch } from '../../../store';
|
||||
import { setMessage } from '../../../store/ui';
|
||||
import Form, { FormState, serializeFormState } from '../Form';
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
setErrMsg: (string) => void;
|
||||
data: RepetitionRuleData;
|
||||
}
|
||||
|
||||
const RepetitionEditContent: React.FunctionComponent<Props> = ({
|
||||
history,
|
||||
setErrMsg,
|
||||
data
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function handleSubmit(state: FormState) {
|
||||
const payload = serializeFormState(state);
|
||||
|
||||
try {
|
||||
await services.repetitionRules.update(data.uuid, payload);
|
||||
|
||||
const dest = getRepetitionsPath();
|
||||
history.push(dest);
|
||||
|
||||
dispatch(
|
||||
setMessage({
|
||||
message: `Updated the repetition rule: "${data.title}"`,
|
||||
kind: 'info',
|
||||
path: repetitionsPathDef
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setErrMsg(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const initialFormState = {
|
||||
title: data.title,
|
||||
enabled: data.enabled,
|
||||
hour: data.hour,
|
||||
minute: data.minute,
|
||||
frequency: data.frequency,
|
||||
noteCount: data.noteCount,
|
||||
bookDomain: data.bookDomain,
|
||||
books: booksToOptions(data.books)
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
isEditing
|
||||
onSubmit={handleSubmit}
|
||||
setErrMsg={setErrMsg}
|
||||
initialState={initialFormState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(RepetitionEditContent);
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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, { useEffect, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { RepetitionRuleData } from 'jslib/operations/types';
|
||||
import services from 'web/libs/services';
|
||||
import { getRepetitionsPath } from 'web/libs/paths';
|
||||
import PayWall from '../../Common/PayWall';
|
||||
import { useDispatch } from '../../../store';
|
||||
import Flash from '../../Common/Flash';
|
||||
import repetitionStyles from '../Repetition.scss';
|
||||
import Content from './Content';
|
||||
|
||||
interface Match {
|
||||
repetitionUUID: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Match> {}
|
||||
|
||||
const EditRepetition: React.FunctionComponent<Props> = ({ match }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [data, setData] = useState<RepetitionRuleData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { repetitionUUID } = match.params;
|
||||
services.repetitionRules
|
||||
.fetch(repetitionUUID)
|
||||
.then(rule => {
|
||||
setData(rule);
|
||||
})
|
||||
.catch(err => {
|
||||
setErrMsg(err.message);
|
||||
});
|
||||
}, [dispatch, match]);
|
||||
|
||||
return (
|
||||
<div id="page-edit-repetition" className="page page-mobile-full">
|
||||
<Helmet>
|
||||
<title>Edit Repetition</title>
|
||||
</Helmet>
|
||||
|
||||
<PayWall>
|
||||
<div className="container mobile-fw">
|
||||
<div className={classnames('page-header', repetitionStyles.header)}>
|
||||
<h1 className="page-heading">Edit Repetition</h1>
|
||||
|
||||
<Link to={getRepetitionsPath()}>Back</Link>
|
||||
</div>
|
||||
|
||||
<Flash
|
||||
kind="danger"
|
||||
when={errMsg !== ''}
|
||||
onDismiss={() => {
|
||||
setErrMsg('');
|
||||
}}
|
||||
>
|
||||
Error: {errMsg}
|
||||
</Flash>
|
||||
|
||||
{data === null ? (
|
||||
<div>loading</div>
|
||||
) : (
|
||||
<Content setErrMsg={setErrMsg} data={data} />
|
||||
)}
|
||||
</div>
|
||||
</PayWall>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRepetition;
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/font';
|
||||
@import '../../App/theme';
|
||||
|
||||
.form {
|
||||
background: white;
|
||||
border: 1px solid $border-color;
|
||||
padding: rem(12px) rem(20px);
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-top: rem(4px);
|
||||
margin-bottom: 0;
|
||||
color: $dark-gray;
|
||||
@include font-size('x-small');
|
||||
}
|
||||
|
||||
.schedule-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
@include breakpoint(md) {
|
||||
justify-content: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include breakpoint(md) {
|
||||
& ~ .schedule-input-wrapper {
|
||||
margin-left: rem(24px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-select {
|
||||
width: auto;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: rem(60px);
|
||||
}
|
||||
}
|
||||
|
||||
.timezone {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.book-domain-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: rem(4px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.book-domain-option {
|
||||
& ~ .book-domain-option {
|
||||
margin-left: 0;
|
||||
margin-top: rem(4px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
margin-left: rem(8px);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.book-domain-label {
|
||||
margin-left: rem(4px);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
& ~ .input-row {
|
||||
margin-top: rem(16px);
|
||||
}
|
||||
}
|
||||
|
||||
.book-selector {
|
||||
margin-top: rem(12px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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, { useEffect, useReducer, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { booksToOptions, Option } from 'jslib/helpers/select';
|
||||
|
||||
import { BookDomain } from 'jslib/operations/types';
|
||||
import { CreateParams } from 'jslib/services/repetitionRules';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getRepetitionsPath } from 'web/libs/paths';
|
||||
import { daysToMs } from '../../../helpers/time';
|
||||
import { useSelector } from '../../../store';
|
||||
import Button from '../../Common/Button';
|
||||
import modalStyles from '../../Common/Modal/Modal.scss';
|
||||
import MultiSelect from '../../Common/MultiSelect';
|
||||
import styles from './Form.scss';
|
||||
|
||||
export interface FormState {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
hour: number;
|
||||
minute: number;
|
||||
frequency: number;
|
||||
noteCount: number;
|
||||
bookDomain: BookDomain;
|
||||
books: Option[];
|
||||
}
|
||||
|
||||
// serializeFormState serializes the given form state into a payload
|
||||
export function serializeFormState(s: FormState): CreateParams {
|
||||
let bookUUIDs = [];
|
||||
if (s.bookDomain === BookDomain.All) {
|
||||
bookUUIDs = [];
|
||||
} else {
|
||||
bookUUIDs = s.books.map(b => {
|
||||
return b.value;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: s.title,
|
||||
hour: s.hour,
|
||||
minute: s.minute,
|
||||
frequency: s.frequency,
|
||||
book_domain: s.bookDomain,
|
||||
book_uuids: bookUUIDs,
|
||||
note_count: s.noteCount,
|
||||
enabled: s.enabled
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (formState) => void;
|
||||
setErrMsg: (string) => void;
|
||||
cancelPath?: string;
|
||||
initialState?: FormState;
|
||||
isEditing?: boolean;
|
||||
// TODO: implement inProgress
|
||||
inProgress?: boolean;
|
||||
}
|
||||
|
||||
enum Action {
|
||||
setTitle,
|
||||
setFrequency,
|
||||
setHour,
|
||||
setMinutes,
|
||||
setNoteCount,
|
||||
setBookDomain,
|
||||
setBooks,
|
||||
toggleEnabled
|
||||
}
|
||||
|
||||
function formReducer(state, action): FormState {
|
||||
switch (action.type) {
|
||||
case Action.setTitle:
|
||||
return {
|
||||
...state,
|
||||
title: action.data
|
||||
};
|
||||
case Action.setFrequency:
|
||||
return {
|
||||
...state,
|
||||
frequency: action.data
|
||||
};
|
||||
case Action.setHour:
|
||||
return {
|
||||
...state,
|
||||
hour: action.data
|
||||
};
|
||||
case Action.setMinutes:
|
||||
return {
|
||||
...state,
|
||||
minute: action.data
|
||||
};
|
||||
case Action.setNoteCount:
|
||||
return {
|
||||
...state,
|
||||
noteCount: action.data
|
||||
};
|
||||
case Action.setBooks:
|
||||
return {
|
||||
...state,
|
||||
books: action.data
|
||||
};
|
||||
case Action.setBookDomain:
|
||||
return {
|
||||
...state,
|
||||
bookDomain: action.data
|
||||
};
|
||||
case Action.toggleEnabled:
|
||||
return {
|
||||
...state,
|
||||
enabled: !state.enabled
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const formInitialState: FormState = {
|
||||
title: '',
|
||||
enabled: true,
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
frequency: daysToMs(7),
|
||||
noteCount: 20,
|
||||
bookDomain: BookDomain.All,
|
||||
books: []
|
||||
};
|
||||
|
||||
function validateForm(state: FormState): Error | null {
|
||||
if (state.title === '') {
|
||||
return new Error('Title is required.');
|
||||
}
|
||||
if (state.bookDomain !== BookDomain.All && state.books.length === 0) {
|
||||
return new Error('Please select books.');
|
||||
}
|
||||
if (state.noteCount <= 0) {
|
||||
return new Error('Please specify note count greater than 0.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const Form: React.FunctionComponent<Props> = ({
|
||||
onSubmit,
|
||||
setErrMsg,
|
||||
cancelPath = getRepetitionsPath(),
|
||||
initialState = formInitialState,
|
||||
isEditing = false,
|
||||
inProgress = false
|
||||
}) => {
|
||||
const bookSelectorInputRef = useRef(null);
|
||||
const [formState, formDispatch] = useReducer(formReducer, initialState);
|
||||
const { books } = useSelector(state => {
|
||||
return {
|
||||
books: state.books.data
|
||||
};
|
||||
});
|
||||
const bookOptions = booksToOptions(books);
|
||||
const booksSelectTextId = 'book-select-text-input';
|
||||
|
||||
let bookSelectorPlaceholder;
|
||||
if (formState.bookDomain === BookDomain.All) {
|
||||
bookSelectorPlaceholder = 'All books';
|
||||
} else if (formState.bookDomain === BookDomain.Including) {
|
||||
bookSelectorPlaceholder = 'Select books to include';
|
||||
} else if (formState.bookDomain === BookDomain.Excluding) {
|
||||
bookSelectorPlaceholder = 'Select books to exclude';
|
||||
}
|
||||
|
||||
let bookSelectorCurrentOptions;
|
||||
if (formState.bookDomain === BookDomain.All) {
|
||||
bookSelectorCurrentOptions = [];
|
||||
} else {
|
||||
bookSelectorCurrentOptions = formState.books;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.bookDomain === BookDomain.All) {
|
||||
if (bookSelectorInputRef.current) {
|
||||
bookSelectorInputRef.current.blur();
|
||||
}
|
||||
} else if (bookSelectorInputRef.current) {
|
||||
bookSelectorInputRef.current.focus();
|
||||
}
|
||||
}, [formState.bookDomain, isEditing]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
const err = validateForm(formState);
|
||||
if (err !== null) {
|
||||
setErrMsg(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(formState);
|
||||
}}
|
||||
className={styles.form}
|
||||
>
|
||||
<div className={styles['input-row']}>
|
||||
<label className="input-label" htmlFor="title">
|
||||
Name
|
||||
</label>
|
||||
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
id="title"
|
||||
className="text-input text-input-small text-input-stretch"
|
||||
placeholder="Weekly vocabulary reminder"
|
||||
value={formState.title}
|
||||
onChange={e => {
|
||||
const data = e.target.value;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setTitle,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles['input-row']}>
|
||||
<label className="input-label" htmlFor={booksSelectTextId}>
|
||||
Eligible books
|
||||
</label>
|
||||
|
||||
<div className={styles['book-domain-wrapper']}>
|
||||
<div className={styles['book-domain-option']}>
|
||||
<input
|
||||
type="radio"
|
||||
id="book-domain-all"
|
||||
name="book-domain"
|
||||
value="all"
|
||||
checked={formState.bookDomain === BookDomain.All}
|
||||
onChange={e => {
|
||||
const data = e.target.value;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setBookDomain,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className={styles['book-domain-label']}
|
||||
htmlFor="book-domain-all"
|
||||
>
|
||||
All
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles['book-domain-option']}>
|
||||
<input
|
||||
type="radio"
|
||||
id="book-domain-including"
|
||||
name="book-domain"
|
||||
value="including"
|
||||
checked={formState.bookDomain === BookDomain.Including}
|
||||
onChange={e => {
|
||||
const data = e.target.value;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setBookDomain,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className={styles['book-domain-label']}
|
||||
htmlFor="book-domain-including"
|
||||
>
|
||||
Including
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles['book-domain-option']}>
|
||||
<input
|
||||
type="radio"
|
||||
id="book-domain-excluding"
|
||||
name="book-domain"
|
||||
value="excluding"
|
||||
checked={formState.bookDomain === BookDomain.Excluding}
|
||||
onChange={e => {
|
||||
const data = e.target.value;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setBookDomain,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className={styles['book-domain-label']}
|
||||
htmlFor="book-domain-excluding"
|
||||
>
|
||||
Excluding
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MultiSelect
|
||||
disabled={formState.bookDomain === BookDomain.All}
|
||||
textInputId={booksSelectTextId}
|
||||
options={bookOptions}
|
||||
currentOptions={bookSelectorCurrentOptions}
|
||||
setCurrentOptions={data => {
|
||||
formDispatch({ type: Action.setBooks, data });
|
||||
}}
|
||||
placeholder={bookSelectorPlaceholder}
|
||||
wrapperClassName={styles['book-selector']}
|
||||
inputInnerRef={bookSelectorInputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classnames(styles['input-row'], styles['schedule-wrapper'])}
|
||||
>
|
||||
<div className={styles['schedule-content']}>
|
||||
<div className={classnames(styles['schedule-input-wrapper'])}>
|
||||
<label className="input-label" htmlFor="frequency">
|
||||
How often?
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="frequency"
|
||||
className="form-select"
|
||||
value={formState.frequency}
|
||||
onChange={e => {
|
||||
const { value } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setFrequency,
|
||||
data: Number.parseInt(value, 10)
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value={daysToMs(1)}>Every day</option>
|
||||
<option value={daysToMs(2)}>Every 2 days</option>
|
||||
<option value={daysToMs(3)}>Every 3 days</option>
|
||||
<option value={daysToMs(4)}>Every 4 days</option>
|
||||
<option value={daysToMs(5)}>Every 5 days</option>
|
||||
<option value={daysToMs(6)}>Every 6 days</option>
|
||||
<option value={daysToMs(7)}>Every week</option>
|
||||
<option value={daysToMs(14)}>Every 2 weeks</option>
|
||||
<option value={daysToMs(21)}>Every 3 weeks</option>
|
||||
<option value={daysToMs(28)}>Every 4 weeks</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles['schedule-input-wrapper']}>
|
||||
<label className="input-label" htmlFor="hour">
|
||||
Hour
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="hour"
|
||||
className={classnames('form-select', styles['time-select'])}
|
||||
value={formState.hour}
|
||||
onChange={e => {
|
||||
const { value } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setHour,
|
||||
data: Number.parseInt(value, 10)
|
||||
});
|
||||
}}
|
||||
>
|
||||
{[...Array(24)].map((_, i) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<option key={i} value={i}>
|
||||
{i}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles['schedule-input-wrapper']}>
|
||||
<label className="input-label" htmlFor="minutes">
|
||||
Minutes
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="minutes"
|
||||
className={classnames('form-select', styles['time-select'])}
|
||||
value={formState.minute}
|
||||
onChange={e => {
|
||||
const { value } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setMinutes,
|
||||
data: Number.parseInt(value, 10)
|
||||
});
|
||||
}}
|
||||
>
|
||||
{[...Array(60)].map((_, i) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<option key={i} value={i}>
|
||||
{i}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.help}>
|
||||
When to deliver a digest in the UTC (Coordinated Universal Time).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles['input-row']}>
|
||||
<label className="input-label" htmlFor="num-notes">
|
||||
Number of notes
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
id="num-notes"
|
||||
className="text-input text-input-small"
|
||||
placeholder="10"
|
||||
value={formState.noteCount}
|
||||
onChange={e => {
|
||||
const { value } = e.target;
|
||||
|
||||
let data;
|
||||
if (value === '') {
|
||||
data = '';
|
||||
} else {
|
||||
data = Number.parseInt(value, 10);
|
||||
}
|
||||
|
||||
formDispatch({
|
||||
type: Action.setNoteCount,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.help}>
|
||||
Maximum number of notes to include in each repetition
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles['input-row']}>
|
||||
<label className="input-label" htmlFor="enabled">
|
||||
Enabled?
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={formState.enabled}
|
||||
onChange={e => {
|
||||
const data = e.target.value;
|
||||
|
||||
formDispatch({
|
||||
type: Action.toggleEnabled,
|
||||
data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<Button type="submit" kind="first" size="normal" isBusy={inProgress}>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to={cancelPath}
|
||||
onClick={e => {
|
||||
const ok = window.confirm('Are you sure?');
|
||||
if (!ok) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="button button-second button-normal"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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, useEffect } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { getRepetitionsPath, repetitionsPathDef } from 'web/libs/paths';
|
||||
import PayWall from '../../Common/PayWall';
|
||||
import {
|
||||
getRepetitionRules,
|
||||
createRepetitionRule
|
||||
} from '../../../store/repetitionRules';
|
||||
import { useDispatch } from '../../../store';
|
||||
import Form, { FormState, serializeFormState } from '../Form';
|
||||
import Flash from '../../Common/Flash';
|
||||
import { setMessage } from '../../../store/ui';
|
||||
import repetitionStyles from '../Repetition.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
const NewRepetition: React.FunctionComponent<Props> = ({ history }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getRepetitionRules());
|
||||
}, [dispatch]);
|
||||
|
||||
async function handleSubmit(state: FormState) {
|
||||
const payload = serializeFormState(state);
|
||||
|
||||
try {
|
||||
await dispatch(createRepetitionRule(payload));
|
||||
|
||||
const dest = getRepetitionsPath();
|
||||
history.push(dest);
|
||||
|
||||
dispatch(
|
||||
setMessage({
|
||||
message: 'Created a repetition rule',
|
||||
kind: 'info',
|
||||
path: repetitionsPathDef
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setErrMsg(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="page-new-repetition" className="page page-mobile-full">
|
||||
<Helmet>
|
||||
<title>New Repetition</title>
|
||||
</Helmet>
|
||||
|
||||
<PayWall>
|
||||
<div className="container mobile-fw">
|
||||
<div className={classnames('page-header', repetitionStyles.header)}>
|
||||
<h1 className="page-heading">New Repetition</h1>
|
||||
|
||||
<Link to={getRepetitionsPath()}>Back</Link>
|
||||
</div>
|
||||
|
||||
<Flash
|
||||
kind="danger"
|
||||
when={errMsg !== ''}
|
||||
onDismiss={() => {
|
||||
setErrMsg('');
|
||||
}}
|
||||
>
|
||||
Error creating a rule: {errMsg}
|
||||
</Flash>
|
||||
|
||||
<Form onSubmit={handleSubmit} setErrMsg={setErrMsg} />
|
||||
</div>
|
||||
</PayWall>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(NewRepetition);
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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/font';
|
||||
@import '../App/theme';
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-top: rem(20px);
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 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 classnames from 'classnames';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getEditRepetitionPath } from '../../../libs/paths';
|
||||
import ItemActions from '../../Common/ItemActions';
|
||||
import ItemActionsStyles from '../../Common/ItemActions/ItemActions.scss';
|
||||
|
||||
interface Props {
|
||||
isActive: boolean;
|
||||
onDelete: () => void;
|
||||
repetitionUUID: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Actions: React.FunctionComponent<Props> = ({
|
||||
isActive,
|
||||
onDelete,
|
||||
repetitionUUID,
|
||||
disabled
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const optRefs = [useRef(null), useRef(null)];
|
||||
const options = [
|
||||
{
|
||||
name: 'edit',
|
||||
value: (
|
||||
<Link
|
||||
ref={optRefs[0]}
|
||||
type="button"
|
||||
className={classnames(
|
||||
'button-no-ui button-stretch T-edit-repetition-rule-btn',
|
||||
ItemActionsStyles.action
|
||||
)}
|
||||
to={getEditRepetitionPath(repetitionUUID)}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
value: (
|
||||
<button
|
||||
ref={optRefs[1]}
|
||||
type="button"
|
||||
className={classnames(
|
||||
'button-no-ui button-stretch T-delete-repetition-rule-btn',
|
||||
ItemActionsStyles.action
|
||||
)}
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Remove…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemActions
|
||||
id={`repetition-rule-${repetitionUUID}-actions`}
|
||||
triggerId={`repetition-rule-actions-trigger-${repetitionUUID}`}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
isActive={isActive}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
optRefs={optRefs}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Actions;
|
||||
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