mirror of
https://github.com/go-shiori/shiori.git
synced 2024-09-20 06:56:10 +08:00
feat: store created and modified time separately on database for bookmarks (#896)
* sqlite migrate script * create time just when bookmark added and modified update if change happen * show added and modified time in footer instead of header * add bun.lockb that missing * add migrate for postgres * add pg support of created time * change modifed to modifed_at and create to created_at in sqlite * change modifed to modifed_at and create to created_at in postgre * add created_at to mariadb * fix migration file names * better variable name and more clear code for add modified time if created and modified is not in same day * add unittest * add unittest to sure filters work as expected * index for created_at and modified_at * build new styles.css * update swagger documents * make styles * change Created and Modified to CreatedAt and ModifiedAt * fix missing Modified * fix typo * missing Modified * fix typo * make swagger * run tests parallel Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com> * remove t.Parallel() * remove dayjs dependency and combine two function * better unittest name * fix typo * diffrnt footer style for login and content page * use class instead of id * back parallel * change duplicate url * remvoe run Parallel * make styles --------- Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>
This commit is contained in:
parent
a3d4a687aa
commit
4a5564d60b
|
@ -389,6 +389,9 @@ const docTemplate = `{
|
||||||
"description": "TODO: migrate outside the DTO",
|
"description": "TODO: migrate outside the DTO",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"excerpt": {
|
"excerpt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -410,7 +413,7 @@ const docTemplate = `{
|
||||||
"imageURL": {
|
"imageURL": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"modified": {
|
"modifiedAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"public": {
|
"public": {
|
||||||
|
|
|
@ -378,6 +378,9 @@
|
||||||
"description": "TODO: migrate outside the DTO",
|
"description": "TODO: migrate outside the DTO",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"excerpt": {
|
"excerpt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -399,7 +402,7 @@
|
||||||
"imageURL": {
|
"imageURL": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"modified": {
|
"modifiedAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"public": {
|
"public": {
|
||||||
|
|
|
@ -90,6 +90,8 @@ definitions:
|
||||||
create_ebook:
|
create_ebook:
|
||||||
description: 'TODO: migrate outside the DTO'
|
description: 'TODO: migrate outside the DTO'
|
||||||
type: boolean
|
type: boolean
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
excerpt:
|
excerpt:
|
||||||
type: string
|
type: string
|
||||||
hasArchive:
|
hasArchive:
|
||||||
|
@ -104,7 +106,7 @@ definitions:
|
||||||
type: integer
|
type: integer
|
||||||
imageURL:
|
imageURL:
|
||||||
type: string
|
type: string
|
||||||
modified:
|
modifiedAt:
|
||||||
type: string
|
type: string
|
||||||
public:
|
public:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
|
@ -62,7 +62,7 @@ func exportHandler(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
for _, book := range bookmarks {
|
for _, book := range bookmarks {
|
||||||
// Create Unix timestamp for bookmark
|
// Create Unix timestamp for bookmark
|
||||||
modifiedTime, err := time.Parse(model.DatabaseDateFormat, book.Modified)
|
modifiedTime, err := time.Parse(model.DatabaseDateFormat, book.ModifiedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
modifiedTime = time.Now()
|
modifiedTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
|
@ -136,10 +136,10 @@ func importHandler(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
// Add item to list
|
// Add item to list
|
||||||
bookmark := model.BookmarkDTO{
|
bookmark := model.BookmarkDTO{
|
||||||
URL: url,
|
URL: url,
|
||||||
Title: title,
|
Title: title,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Modified: modifiedDate.Format(model.DatabaseDateFormat),
|
ModifiedAt: modifiedDate.Format(model.DatabaseDateFormat),
|
||||||
}
|
}
|
||||||
|
|
||||||
mapURL[url] = struct{}{}
|
mapURL[url] = struct{}{}
|
||||||
|
|
|
@ -94,10 +94,10 @@ func pocketHandler(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
// Add item to list
|
// Add item to list
|
||||||
bookmark := model.BookmarkDTO{
|
bookmark := model.BookmarkDTO{
|
||||||
URL: url,
|
URL: url,
|
||||||
Title: title,
|
Title: title,
|
||||||
Modified: modified.Format(model.DatabaseDateFormat),
|
ModifiedAt: modified.Format(model.DatabaseDateFormat),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
mapURL[url] = struct{}{}
|
mapURL[url] = struct{}{}
|
||||||
|
|
|
@ -120,6 +120,7 @@ func ProcessBookmark(deps *dependencies.Dependencies, req ProcessRequest) (book
|
||||||
}
|
}
|
||||||
|
|
||||||
book.HasContent = book.Content != ""
|
book.HasContent = book.Content != ""
|
||||||
|
book.ModifiedAt = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save article image to local disk
|
// Save article image to local disk
|
||||||
|
@ -137,6 +138,7 @@ func ProcessBookmark(deps *dependencies.Dependencies, req ProcessRequest) (book
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
book.ImageURL = fp.Join("/", "bookmark", strID, "thumb")
|
book.ImageURL = fp.Join("/", "bookmark", strID, "thumb")
|
||||||
|
book.ModifiedAt = ""
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -154,6 +156,7 @@ func ProcessBookmark(deps *dependencies.Dependencies, req ProcessRequest) (book
|
||||||
return book, true, errors.Wrap(err, "failed to create ebook")
|
return book, true, errors.Wrap(err, "failed to create ebook")
|
||||||
}
|
}
|
||||||
book.HasEbook = true
|
book.HasEbook = true
|
||||||
|
book.ModifiedAt = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,6 +189,7 @@ func ProcessBookmark(deps *dependencies.Dependencies, req ProcessRequest) (book
|
||||||
}
|
}
|
||||||
|
|
||||||
book.HasArchive = true
|
book.HasArchive = true
|
||||||
|
book.ModifiedAt = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return book, false, nil
|
return book, false, nil
|
||||||
|
|
|
@ -3,6 +3,7 @@ package database
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-shiori/shiori/internal/model"
|
"github.com/go-shiori/shiori/internal/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -15,19 +16,21 @@ type testDatabaseFactory func(t *testing.T, ctx context.Context) (DB, error)
|
||||||
func testDatabase(t *testing.T, dbFactory testDatabaseFactory) {
|
func testDatabase(t *testing.T, dbFactory testDatabaseFactory) {
|
||||||
tests := map[string]databaseTestCase{
|
tests := map[string]databaseTestCase{
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
"testBookmarkAutoIncrement": testBookmarkAutoIncrement,
|
"testBookmarkAutoIncrement": testBookmarkAutoIncrement,
|
||||||
"testCreateBookmark": testCreateBookmark,
|
"testCreateBookmark": testCreateBookmark,
|
||||||
"testCreateBookmarkWithContent": testCreateBookmarkWithContent,
|
"testCreateBookmarkWithContent": testCreateBookmarkWithContent,
|
||||||
"testCreateBookmarkTwice": testCreateBookmarkTwice,
|
"testCreateBookmarkTwice": testCreateBookmarkTwice,
|
||||||
"testCreateBookmarkWithTag": testCreateBookmarkWithTag,
|
"testCreateBookmarkWithTag": testCreateBookmarkWithTag,
|
||||||
"testCreateTwoDifferentBookmarks": testCreateTwoDifferentBookmarks,
|
"testCreateTwoDifferentBookmarks": testCreateTwoDifferentBookmarks,
|
||||||
"testUpdateBookmark": testUpdateBookmark,
|
"testUpdateBookmark": testUpdateBookmark,
|
||||||
"testUpdateBookmarkWithContent": testUpdateBookmarkWithContent,
|
"testUpdateBookmarkUpdatesModifiedTime": testUpdateBookmarkUpdatesModifiedTime,
|
||||||
"testGetBookmark": testGetBookmark,
|
"testGetBoomarksWithTimeFilters": testGetBoomarksWithTimeFilters,
|
||||||
"testGetBookmarkNotExistent": testGetBookmarkNotExistent,
|
"testUpdateBookmarkWithContent": testUpdateBookmarkWithContent,
|
||||||
"testGetBookmarks": testGetBookmarks,
|
"testGetBookmark": testGetBookmark,
|
||||||
"testGetBookmarksWithSQLCharacters": testGetBookmarksWithSQLCharacters,
|
"testGetBookmarkNotExistent": testGetBookmarkNotExistent,
|
||||||
"testGetBookmarksCount": testGetBookmarksCount,
|
"testGetBookmarks": testGetBookmarks,
|
||||||
|
"testGetBookmarksWithSQLCharacters": testGetBookmarksWithSQLCharacters,
|
||||||
|
"testGetBookmarksCount": testGetBookmarksCount,
|
||||||
// Tags
|
// Tags
|
||||||
"testCreateTag": testCreateTag,
|
"testCreateTag": testCreateTag,
|
||||||
"testCreateTags": testCreateTags,
|
"testCreateTags": testCreateTags,
|
||||||
|
@ -424,3 +427,92 @@ func testGetAccounts(t *testing.T, db DB) {
|
||||||
assert.Equal(t, 0, len(accounts))
|
assert.Equal(t, 0, len(accounts))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers.
|
||||||
|
func testUpdateBookmarkUpdatesModifiedTime(t *testing.T, db DB) {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
book := model.BookmarkDTO{
|
||||||
|
URL: "https://github.com/go-shiori/shiori",
|
||||||
|
Title: "shiori",
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBook, err := db.SaveBookmarks(ctx, true, book)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
|
||||||
|
updatedBook := resultBook[0]
|
||||||
|
updatedBook.Title = "modified"
|
||||||
|
updatedBook.ModifiedAt = ""
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
resultUpdatedBooks, err := db.SaveBookmarks(ctx, false, updatedBook)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
|
||||||
|
assert.NotEqual(t, resultBook[0].ModifiedAt, resultUpdatedBooks[0].ModifiedAt)
|
||||||
|
assert.Equal(t, resultBook[0].CreatedAt, resultUpdatedBooks[0].CreatedAt)
|
||||||
|
assert.Equal(t, resultBook[0].CreatedAt, resultBook[0].ModifiedAt)
|
||||||
|
assert.NoError(t, err, "Get bookmarks must not fail")
|
||||||
|
|
||||||
|
assert.Equal(t, updatedBook.Title, resultUpdatedBooks[0].Title, "Saved bookmark must have updated Title")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers.
|
||||||
|
func testGetBoomarksWithTimeFilters(t *testing.T, db DB) {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
book1 := model.BookmarkDTO{
|
||||||
|
URL: "https://github.com/go-shiori/shiori/one",
|
||||||
|
Title: "Added First but Modified Last",
|
||||||
|
}
|
||||||
|
book2 := model.BookmarkDTO{
|
||||||
|
URL: "https://github.com/go-shiori/shiori/second",
|
||||||
|
Title: "Added Last but Modified First",
|
||||||
|
}
|
||||||
|
|
||||||
|
// create two new bookmark
|
||||||
|
resultBook1, err := db.SaveBookmarks(ctx, true, book1)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
resultBook2, err := db.SaveBookmarks(ctx, true, book2)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
|
||||||
|
// update those bookmarks
|
||||||
|
updatedBook1 := resultBook1[0]
|
||||||
|
updatedBook1.Title = "Added First but Modified Last Updated Title"
|
||||||
|
updatedBook1.ModifiedAt = ""
|
||||||
|
|
||||||
|
updatedBook2 := resultBook2[0]
|
||||||
|
updatedBook2.Title = "Last Added but modified First Updated Title"
|
||||||
|
updatedBook2.ModifiedAt = ""
|
||||||
|
|
||||||
|
// modified bookmark2 first after one second modified bookmark1
|
||||||
|
resultUpdatedBook2, err := db.SaveBookmarks(ctx, false, updatedBook2)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
resultUpdatedBook1, err := db.SaveBookmarks(ctx, false, updatedBook1)
|
||||||
|
assert.NoError(t, err, "Save bookmarks must not fail")
|
||||||
|
|
||||||
|
// get diffrent filteter combination
|
||||||
|
booksOrderByLastAdded, err := db.GetBookmarks(ctx, GetBookmarksOptions{
|
||||||
|
IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},
|
||||||
|
OrderMethod: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "Get bookmarks must not fail")
|
||||||
|
booksOrderByLastModified, err := db.GetBookmarks(ctx, GetBookmarksOptions{
|
||||||
|
IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},
|
||||||
|
OrderMethod: 2,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "Get bookmarks must not fail")
|
||||||
|
booksOrderById, err := db.GetBookmarks(ctx, GetBookmarksOptions{
|
||||||
|
IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},
|
||||||
|
OrderMethod: 0,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "Get bookmarks must not fail")
|
||||||
|
|
||||||
|
// Check Last Added
|
||||||
|
assert.Equal(t, booksOrderByLastAdded[0].Title, updatedBook2.Title)
|
||||||
|
// Check Last Modified
|
||||||
|
assert.Equal(t, booksOrderByLastModified[0].Title, updatedBook1.Title)
|
||||||
|
// Second id should be 2 if order them by id
|
||||||
|
assert.Equal(t, booksOrderById[1].ID, 2)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE bookmark RENAME COLUMN modified to created_at;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
MODIFY created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
ADD COLUMN modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
@ -0,0 +1,3 @@
|
||||||
|
UPDATE bookmark
|
||||||
|
SET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE created_at IS NOT NULL;
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX idx_created_at ON bookmark (created_at);
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX idx_modified_at ON bookmark (modified_at);
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Rename "modified" column to "created_at"
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
RENAME COLUMN modified to created_at;
|
||||||
|
|
||||||
|
-- Add the "modified_at" column to the bookmark table
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
ADD COLUMN modified_at TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- Update the "modified_at" column with the value from the "created_at" column if it is not null
|
||||||
|
UPDATE bookmark
|
||||||
|
SET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE created_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Index for "created_at" "modified_at""
|
||||||
|
CREATE INDEX idx_created_at ON bookmark(created_at);
|
||||||
|
CREATE INDEX idx_modified_at ON bookmark(modified_at);
|
12
internal/database/migrations/sqlite/0004_created_time.up.sql
Normal file
12
internal/database/migrations/sqlite/0004_created_time.up.sql
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
RENAME COLUMN modified to created_at;
|
||||||
|
|
||||||
|
ALTER TABLE bookmark
|
||||||
|
ADD COLUMN modified_at TEXT NULL;
|
||||||
|
|
||||||
|
UPDATE bookmark
|
||||||
|
SET modified_at = bookmark.created_at
|
||||||
|
WHERE created_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_created_at ON bookmark(created_at);
|
||||||
|
CREATE INDEX idx_modified_at ON bookmark(modified_at);
|
|
@ -61,6 +61,12 @@ var mysqlMigrations = []migration{
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
newFileMigration("0.7.0", "0.8.0", "mysql/0005_rename_to_created_at"),
|
||||||
|
newFileMigration("0.8.0", "0.8.1", "mysql/0006_change_created_at_settings"),
|
||||||
|
newFileMigration("0.8.1", "0.8.2", "mysql/0007_add_modified_at"),
|
||||||
|
newFileMigration("0.8.2", "0.8.3", "mysql/0008_set_modified_at_equal_created_at"),
|
||||||
|
newFileMigration("0.8.3", "0.8.4", "mysql/0009_index_for_created_at"),
|
||||||
|
newFileMigration("0.8.4", "0.8.5", "mysql/0010_index_for_modified_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// MySQLDatabase is implementation of Database interface
|
// MySQLDatabase is implementation of Database interface
|
||||||
|
@ -131,8 +137,8 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar
|
||||||
if err := db.withTx(ctx, func(tx *sqlx.Tx) error {
|
if err := db.withTx(ctx, func(tx *sqlx.Tx) error {
|
||||||
// Prepare statement
|
// Prepare statement
|
||||||
stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark
|
stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark
|
||||||
(url, title, excerpt, author, public, content, html, modified)
|
(url, title, excerpt, author, public, content, html, modified_at, created_at)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -145,7 +151,7 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar
|
||||||
public = ?,
|
public = ?,
|
||||||
content = ?,
|
content = ?,
|
||||||
html = ?,
|
html = ?,
|
||||||
modified = ?
|
modified_at = ?
|
||||||
WHERE id = ?`)
|
WHERE id = ?`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -189,17 +195,18 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set modified time
|
// Set modified time
|
||||||
if book.Modified == "" {
|
if book.ModifiedAt == "" {
|
||||||
book.Modified = modifiedTime
|
book.ModifiedAt = modifiedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save bookmark
|
// Save bookmark
|
||||||
var err error
|
var err error
|
||||||
if create {
|
if create {
|
||||||
|
book.CreatedAt = modifiedTime
|
||||||
var res sql.Result
|
var res sql.Result
|
||||||
res, err = stmtInsertBook.ExecContext(ctx,
|
res, err = stmtInsertBook.ExecContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author,
|
book.URL, book.Title, book.Excerpt, book.Author,
|
||||||
book.Public, book.Content, book.HTML, book.Modified)
|
book.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -211,7 +218,7 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar
|
||||||
} else {
|
} else {
|
||||||
_, err = stmtUpdateBook.ExecContext(ctx,
|
_, err = stmtUpdateBook.ExecContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author,
|
book.URL, book.Title, book.Excerpt, book.Author,
|
||||||
book.Public, book.Content, book.HTML, book.Modified, book.ID)
|
book.Public, book.Content, book.HTML, book.ModifiedAt, book.ID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -285,7 +292,8 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpti
|
||||||
`excerpt`,
|
`excerpt`,
|
||||||
`author`,
|
`author`,
|
||||||
`public`,
|
`public`,
|
||||||
`modified`,
|
`created_at`,
|
||||||
|
`modified_at`,
|
||||||
`content <> "" has_content`}
|
`content <> "" has_content`}
|
||||||
|
|
||||||
if opts.WithContent {
|
if opts.WithContent {
|
||||||
|
@ -371,7 +379,7 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpti
|
||||||
case ByLastAdded:
|
case ByLastAdded:
|
||||||
query += ` ORDER BY id DESC`
|
query += ` ORDER BY id DESC`
|
||||||
case ByLastModified:
|
case ByLastModified:
|
||||||
query += ` ORDER BY modified DESC`
|
query += ` ORDER BY modified_at DESC`
|
||||||
default:
|
default:
|
||||||
query += ` ORDER BY id`
|
query += ` ORDER BY id`
|
||||||
}
|
}
|
||||||
|
@ -564,7 +572,7 @@ func (db *MySQLDatabase) GetBookmark(ctx context.Context, id int, url string) (m
|
||||||
args := []interface{}{id}
|
args := []interface{}{id}
|
||||||
query := `SELECT
|
query := `SELECT
|
||||||
id, url, title, excerpt, author, public,
|
id, url, title, excerpt, author, public,
|
||||||
content, html, modified, content <> '' has_content
|
content, html, modified_at, created_at, content <> '' has_content
|
||||||
FROM bookmark WHERE id = ?`
|
FROM bookmark WHERE id = ?`
|
||||||
|
|
||||||
if url != "" {
|
if url != "" {
|
||||||
|
|
|
@ -57,6 +57,7 @@ var postgresMigrations = []migration{
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
newFileMigration("0.3.0", "0.4.0", "postgres/0002_created_time"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// PGDatabase is implementation of Database interface
|
// PGDatabase is implementation of Database interface
|
||||||
|
@ -128,8 +129,8 @@ func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks
|
||||||
if err := db.withTx(ctx, func(tx *sqlx.Tx) error {
|
if err := db.withTx(ctx, func(tx *sqlx.Tx) error {
|
||||||
// Prepare statement
|
// Prepare statement
|
||||||
stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark
|
stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark
|
||||||
(url, title, excerpt, author, public, content, html, modified)
|
(url, title, excerpt, author, public, content, html, modified_at, created_at)
|
||||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id`)
|
RETURNING id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -143,7 +144,7 @@ func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks
|
||||||
public = $5,
|
public = $5,
|
||||||
content = $6,
|
content = $6,
|
||||||
html = $7,
|
html = $7,
|
||||||
modified = $8
|
modified_at = $8
|
||||||
WHERE id = $9`)
|
WHERE id = $9`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -187,20 +188,21 @@ func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set modified time
|
// Set modified time
|
||||||
if book.Modified == "" {
|
if book.ModifiedAt == "" {
|
||||||
book.Modified = modifiedTime
|
book.ModifiedAt = modifiedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save bookmark
|
// Save bookmark
|
||||||
var err error
|
var err error
|
||||||
if create {
|
if create {
|
||||||
|
book.CreatedAt = modifiedTime
|
||||||
err = stmtInsertBook.QueryRowContext(ctx,
|
err = stmtInsertBook.QueryRowContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author,
|
book.URL, book.Title, book.Excerpt, book.Author,
|
||||||
book.Public, book.Content, book.HTML, book.Modified).Scan(&book.ID)
|
book.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt).Scan(&book.ID)
|
||||||
} else {
|
} else {
|
||||||
_, err = stmtUpdateBook.ExecContext(ctx,
|
_, err = stmtUpdateBook.ExecContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author,
|
book.URL, book.Title, book.Excerpt, book.Author,
|
||||||
book.Public, book.Content, book.HTML, book.Modified, book.ID)
|
book.Public, book.Content, book.HTML, book.ModifiedAt, book.ID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -270,7 +272,8 @@ func (db *PGDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions
|
||||||
`excerpt`,
|
`excerpt`,
|
||||||
`author`,
|
`author`,
|
||||||
`public`,
|
`public`,
|
||||||
`modified`,
|
`created_at`,
|
||||||
|
`modified_at`,
|
||||||
`content <> '' has_content`}
|
`content <> '' has_content`}
|
||||||
|
|
||||||
if opts.WithContent {
|
if opts.WithContent {
|
||||||
|
@ -359,7 +362,7 @@ func (db *PGDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions
|
||||||
case ByLastAdded:
|
case ByLastAdded:
|
||||||
query += ` ORDER BY id DESC`
|
query += ` ORDER BY id DESC`
|
||||||
case ByLastModified:
|
case ByLastModified:
|
||||||
query += ` ORDER BY modified DESC`
|
query += ` ORDER BY modified_at DESC`
|
||||||
default:
|
default:
|
||||||
query += ` ORDER BY id`
|
query += ` ORDER BY id`
|
||||||
}
|
}
|
||||||
|
@ -570,7 +573,7 @@ func (db *PGDatabase) GetBookmark(ctx context.Context, id int, url string) (mode
|
||||||
args := []interface{}{id}
|
args := []interface{}{id}
|
||||||
query := `SELECT
|
query := `SELECT
|
||||||
id, url, title, excerpt, author, public,
|
id, url, title, excerpt, author, public,
|
||||||
content, html, modified, content <> '' has_content
|
content, html, modified_at, created_at, content <> '' has_content
|
||||||
FROM bookmark WHERE id = $1`
|
FROM bookmark WHERE id = $1`
|
||||||
|
|
||||||
if url != "" {
|
if url != "" {
|
||||||
|
|
|
@ -60,6 +60,7 @@ var sqliteMigrations = []migration{
|
||||||
}),
|
}),
|
||||||
newFileMigration("0.3.0", "0.4.0", "sqlite/0002_denormalize_content"),
|
newFileMigration("0.3.0", "0.4.0", "sqlite/0002_denormalize_content"),
|
||||||
newFileMigration("0.4.0", "0.5.0", "sqlite/0003_uniq_id"),
|
newFileMigration("0.4.0", "0.5.0", "sqlite/0003_uniq_id"),
|
||||||
|
newFileMigration("0.5.0", "0.6.0", "sqlite/0004_created_time"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLiteDatabase is implementation of Database interface
|
// SQLiteDatabase is implementation of Database interface
|
||||||
|
@ -127,15 +128,15 @@ func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookma
|
||||||
// Prepare statement
|
// Prepare statement
|
||||||
|
|
||||||
stmtInsertBook, err := tx.PreparexContext(ctx, `INSERT INTO bookmark
|
stmtInsertBook, err := tx.PreparexContext(ctx, `INSERT INTO bookmark
|
||||||
(url, title, excerpt, author, public, modified, has_content)
|
(url, title, excerpt, author, public, modified_at, has_content, created_at)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id`)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stmtUpdateBook, err := tx.PreparexContext(ctx, `UPDATE bookmark SET
|
stmtUpdateBook, err := tx.PreparexContext(ctx, `UPDATE bookmark SET
|
||||||
url = ?, title = ?, excerpt = ?, author = ?,
|
url = ?, title = ?, excerpt = ?, author = ?,
|
||||||
public = ?, modified = ?, has_content = ?
|
public = ?, modified_at = ?, has_content = ?
|
||||||
WHERE id = ?`)
|
WHERE id = ?`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -193,8 +194,8 @@ func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookma
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set modified time
|
// Set modified time
|
||||||
if book.Modified == "" {
|
if book.ModifiedAt == "" {
|
||||||
book.Modified = modifiedTime
|
book.ModifiedAt = modifiedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
hasContent := book.Content != ""
|
hasContent := book.Content != ""
|
||||||
|
@ -202,11 +203,12 @@ func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookma
|
||||||
// Create or update bookmark
|
// Create or update bookmark
|
||||||
var err error
|
var err error
|
||||||
if create {
|
if create {
|
||||||
|
book.CreatedAt = modifiedTime
|
||||||
err = stmtInsertBook.QueryRowContext(ctx,
|
err = stmtInsertBook.QueryRowContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent).Scan(&book.ID)
|
book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.CreatedAt).Scan(&book.ID)
|
||||||
} else {
|
} else {
|
||||||
_, err = stmtUpdateBook.ExecContext(ctx,
|
_, err = stmtUpdateBook.ExecContext(ctx,
|
||||||
book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent, book.ID)
|
book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.ID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
|
@ -298,7 +300,8 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpt
|
||||||
b.excerpt,
|
b.excerpt,
|
||||||
b.author,
|
b.author,
|
||||||
b.public,
|
b.public,
|
||||||
b.modified,
|
b.created_at,
|
||||||
|
b.modified_at,
|
||||||
b.has_content
|
b.has_content
|
||||||
FROM bookmark b
|
FROM bookmark b
|
||||||
WHERE 1`
|
WHERE 1`
|
||||||
|
@ -389,7 +392,7 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpt
|
||||||
case ByLastAdded:
|
case ByLastAdded:
|
||||||
query += ` ORDER BY b.id DESC`
|
query += ` ORDER BY b.id DESC`
|
||||||
case ByLastModified:
|
case ByLastModified:
|
||||||
query += ` ORDER BY b.modified DESC`
|
query += ` ORDER BY b.modified_at DESC`
|
||||||
default:
|
default:
|
||||||
query += ` ORDER BY b.id`
|
query += ` ORDER BY b.id`
|
||||||
}
|
}
|
||||||
|
@ -669,8 +672,8 @@ func (db *SQLiteDatabase) DeleteBookmarks(ctx context.Context, ids ...int) error
|
||||||
func (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) {
|
func (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) {
|
||||||
args := []interface{}{id}
|
args := []interface{}{id}
|
||||||
query := `SELECT
|
query := `SELECT
|
||||||
b.id, b.url, b.title, b.excerpt, b.author, b.public, b.modified,
|
b.id, b.url, b.title, b.excerpt, b.author, b.public, b.modified_at,
|
||||||
bc.content, bc.html, b.has_content
|
bc.content, bc.html, b.has_content, b.created_at
|
||||||
FROM bookmark b
|
FROM bookmark b
|
||||||
LEFT JOIN bookmark_content bc ON bc.docid = b.id
|
LEFT JOIN bookmark_content bc ON bc.docid = b.id
|
||||||
WHERE b.id = ?`
|
WHERE b.id = ?`
|
||||||
|
|
|
@ -14,7 +14,8 @@ type BookmarkDTO struct {
|
||||||
Excerpt string `db:"excerpt" json:"excerpt"`
|
Excerpt string `db:"excerpt" json:"excerpt"`
|
||||||
Author string `db:"author" json:"author"`
|
Author string `db:"author" json:"author"`
|
||||||
Public int `db:"public" json:"public"`
|
Public int `db:"public" json:"public"`
|
||||||
Modified string `db:"modified" json:"modified"`
|
CreatedAt string `db:"created_at" json:"createdAt"`
|
||||||
|
ModifiedAt string `db:"modified_at" json:"modifiedAt"`
|
||||||
Content string `db:"content" json:"-"`
|
Content string `db:"content" json:"-"`
|
||||||
HTML string `db:"html" json:"html,omitempty"`
|
HTML string `db:"html" json:"html,omitempty"`
|
||||||
ImageURL string `db:"image_url" json:"imageURL"`
|
ImageURL string `db:"image_url" json:"imageURL"`
|
||||||
|
|
File diff suppressed because one or more lines are too long
1
internal/view/assets/js/dayjs.min.js
vendored
1
internal/view/assets/js/dayjs.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -5,10 +5,57 @@ select {
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
.login-footer {
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-footer {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 840px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: var(--contentBg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--colorLink);
|
||||||
|
&:nth-child(1) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[v-cloak] {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--color);
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--main);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -384,18 +431,6 @@ a {
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
#metadata {
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--colorLink);
|
|
||||||
|
|
||||||
&[v-cloak] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#title {
|
#title {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
grid-column-start: 1;
|
grid-column-start: 1;
|
||||||
|
@ -406,22 +441,6 @@ a {
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#links {
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
|
|
||||||
a {
|
|
||||||
padding: 0 4px;
|
|
||||||
color: var(--color);
|
|
||||||
text-decoration: underline;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
color: var(--main);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
|
|
|
@ -16,16 +16,14 @@
|
||||||
|
|
||||||
<link href="assets/css/style.css" rel="stylesheet">
|
<link href="assets/css/style.css" rel="stylesheet">
|
||||||
|
|
||||||
<script src="assets/js/dayjs.min.js"></script>
|
|
||||||
<script src="assets/js/vue.min.js"></script>
|
<script src="assets/js/vue.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="content-scene">
|
<div id="content-scene">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<p id="metadata" v-cloak>Added {{localtime()}}</p>
|
|
||||||
<p id="title" dir="auto">$$.Book.Title$$</p>
|
<p id="title" dir="auto">$$.Book.Title$$</p>
|
||||||
<div id="links">
|
<div class="links">
|
||||||
<a href="$$.Book.URL$$" target="_blank" rel="noopener noreferrer">View Original</a>
|
<a href="$$.Book.URL$$" target="_blank" rel="noopener noreferrer">View Original</a>
|
||||||
$$if .Book.HasArchive$$
|
$$if .Book.HasArchive$$
|
||||||
<a href="bookmark/$$.Book.ID$$/archive">View Archive</a>
|
<a href="bookmark/$$.Book.ID$$/archive">View Archive</a>
|
||||||
|
@ -38,6 +36,9 @@
|
||||||
<div id="content" dir="auto" v-pre>
|
<div id="content" dir="auto" v-pre>
|
||||||
$$.HTML$$
|
$$.HTML$$
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="content-footer">
|
||||||
|
<p class="metadata">{{ createdModifiedTime() }} </p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
@ -48,15 +49,21 @@
|
||||||
el: '#content-scene',
|
el: '#content-scene',
|
||||||
mixins: [basePage],
|
mixins: [basePage],
|
||||||
data: {
|
data: {
|
||||||
modified: "$$.Book.Modified$$"
|
created: "$$.Book.CreatedAt$$"
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
localtime() {
|
createdModifiedTime() {
|
||||||
var strTime = this.modified.replace(" ", "T");
|
const strCreatedTime = "$$.Book.CreatedAt$$".replace(" ", "T") + ("$$.Book.CreatedAt$$".endsWith("Z") ? "" : "Z");
|
||||||
if (!strTime.endsWith("Z")) {
|
const strModifiedTime = "$$.Book.ModifiedAt$$".replace(" ", "T") + ("$$.Book.ModifiedAt$$".endsWith("Z") ? "" : "Z");
|
||||||
strTime += "Z";
|
|
||||||
}
|
const createdDate = new Date(strCreatedTime);
|
||||||
return dayjs(strTime).format("D MMMM YYYY, HH:mm:ss");
|
const modifiedDate = new Date(strModifiedTime);
|
||||||
|
|
||||||
|
if (createdDate.toDateString() === modifiedDate.toDateString()) {
|
||||||
|
return `Added ${createdDate.getDate()} ${createdDate.toLocaleString('default', { month: 'long' })} ${createdDate.getFullYear()}`;
|
||||||
|
} else {
|
||||||
|
return `Added ${createdDate.getDate()} ${createdDate.toLocaleString('default', { month: 'long' })} ${createdDate.getFullYear()} | Last Modified ${modifiedDate.getDate()} ${modifiedDate.toLocaleString('default', {month: 'long'})} ${modifiedDate.getFullYear()}`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
loadSetting() {
|
loadSetting() {
|
||||||
var opts = JSON.parse(localStorage.getItem("shiori-account")) || {},
|
var opts = JSON.parse(localStorage.getItem("shiori-account")) || {},
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer class="login-footer">
|
||||||
<p>$$.Version$$</p>
|
<p>$$.Version$$</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -360,6 +360,9 @@ func (h *Handler) ApiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set bookmark modified
|
||||||
|
book.ModifiedAt = ""
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
res, err := h.DB.SaveBookmarks(ctx, false, book)
|
res, err := h.DB.SaveBookmarks(ctx, false, book)
|
||||||
checkError(err)
|
checkError(err)
|
||||||
|
|
Loading…
Reference in a new issue