Remove repetition (#432)

* Remove repetition and digest

* Document change

* Fix test

* Fix test
This commit is contained in:
Sung Won Cho 2020-03-22 10:36:13 +11:00 committed by GitHub
commit a9f052b19c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
124 changed files with 37 additions and 9847 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(&noteCount), "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")
}

View file

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

View file

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

View file

@ -47,11 +47,6 @@ func InitSchema(db *gorm.DB) {
Token{},
EmailPreference{},
Session{},
Digest{},
DigestNote{},
RepetitionRule{},
DigestReceipt{},
NoteReview{},
).Error; err != nil {
panic(err)
}

View file

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

View file

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

View file

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

View file

@ -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(&params)
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(&params)
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
}
}

View file

@ -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(&noteReviewCount), "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(&noteReviewRecord), "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(&noteReviewCount), "counting note_reviews")
assert.Equal(t, noteReviewCount, 0, "counting note_review")
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}>&middot;</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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 &#34;{data.title}&#34;</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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&hellip;
</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