2019-05-21 11:31:40 +08:00
|
|
|
package database
|
|
|
|
|
|
|
|
import (
|
2019-05-22 00:24:11 +08:00
|
|
|
"database/sql"
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/go-shiori/shiori/internal/model"
|
2019-05-21 11:31:40 +08:00
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
)
|
|
|
|
|
|
|
|
// SQLiteDatabase is implementation of Database interface
|
|
|
|
// for connecting to SQLite3 database.
|
|
|
|
type SQLiteDatabase struct {
|
|
|
|
sqlx.DB
|
|
|
|
}
|
2019-05-22 00:24:11 +08:00
|
|
|
|
|
|
|
// OpenSQLiteDatabase creates and open connection to new SQLite3 database.
|
|
|
|
func OpenSQLiteDatabase(databasePath string) (*SQLiteDatabase, error) {
|
|
|
|
// Open database and start transaction
|
|
|
|
var err error
|
|
|
|
db := sqlx.MustConnect("sqlite3", databasePath)
|
|
|
|
|
|
|
|
tx, err := db.Beginx()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make sure to rollback if panic ever happened
|
|
|
|
defer func() {
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
panicErr, _ := r.(error)
|
|
|
|
tx.Rollback()
|
|
|
|
|
|
|
|
db = nil
|
|
|
|
err = panicErr
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Create tables
|
|
|
|
tx.MustExec(`CREATE TABLE IF NOT EXISTS account(
|
|
|
|
id INTEGER NOT NULL,
|
|
|
|
username TEXT NOT NULL,
|
|
|
|
password TEXT NOT NULL,
|
|
|
|
CONSTRAINT account_PK PRIMARY KEY(id),
|
|
|
|
CONSTRAINT account_username_UNIQUE UNIQUE(username))`)
|
|
|
|
|
|
|
|
tx.MustExec(`CREATE TABLE IF NOT EXISTS bookmark(
|
|
|
|
id INTEGER NOT NULL,
|
|
|
|
url TEXT NOT NULL,
|
|
|
|
title TEXT NOT NULL,
|
|
|
|
excerpt TEXT NOT NULL DEFAULT "",
|
|
|
|
author TEXT NOT NULL DEFAULT "",
|
|
|
|
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT bookmark_PK PRIMARY KEY(id),
|
|
|
|
CONSTRAINT bookmark_url_UNIQUE UNIQUE(url))`)
|
|
|
|
|
|
|
|
tx.MustExec(`CREATE TABLE IF NOT EXISTS tag(
|
|
|
|
id INTEGER NOT NULL,
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
CONSTRAINT tag_PK PRIMARY KEY(id),
|
|
|
|
CONSTRAINT tag_name_UNIQUE UNIQUE(name))`)
|
|
|
|
|
|
|
|
tx.MustExec(`CREATE TABLE IF NOT EXISTS bookmark_tag(
|
|
|
|
bookmark_id INTEGER NOT NULL,
|
|
|
|
tag_id INTEGER NOT NULL,
|
|
|
|
CONSTRAINT bookmark_tag_PK PRIMARY KEY(bookmark_id, tag_id),
|
|
|
|
CONSTRAINT bookmark_id_FK FOREIGN KEY(bookmark_id) REFERENCES bookmark(id),
|
|
|
|
CONSTRAINT tag_id_FK FOREIGN KEY(tag_id) REFERENCES tag(id))`)
|
|
|
|
|
|
|
|
tx.MustExec(`CREATE VIRTUAL TABLE IF NOT EXISTS bookmark_content USING fts4(title, content, html)`)
|
|
|
|
|
|
|
|
err = tx.Commit()
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
return &SQLiteDatabase{*db}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// InsertBookmark saves new bookmark to database.
|
|
|
|
// Returns new ID and error message if any happened.
|
|
|
|
func (db *SQLiteDatabase) InsertBookmark(bookmark model.Bookmark) (bookmarkID int, err error) {
|
|
|
|
// Check URL and title
|
|
|
|
if bookmark.URL == "" {
|
|
|
|
return -1, fmt.Errorf("URL must not be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
if bookmark.Title == "" {
|
|
|
|
return -1, fmt.Errorf("title must not be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create ID (if needed) and modified time
|
|
|
|
if bookmark.ID != 0 {
|
|
|
|
bookmarkID = bookmark.ID
|
|
|
|
} else {
|
|
|
|
bookmarkID, err = db.CreateNewID("bookmark")
|
|
|
|
if err != nil {
|
|
|
|
return -1, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if bookmark.Modified == "" {
|
|
|
|
bookmark.Modified = time.Now().UTC().Format("2006-01-02 15:04:05")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Begin transaction
|
|
|
|
tx, err := db.Beginx()
|
|
|
|
if err != nil {
|
|
|
|
return -1, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make sure to rollback if panic ever happened
|
|
|
|
defer func() {
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
panicErr, _ := r.(error)
|
|
|
|
tx.Rollback()
|
|
|
|
|
|
|
|
bookmarkID = -1
|
|
|
|
err = panicErr
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Save article to database
|
|
|
|
tx.MustExec(`INSERT INTO bookmark (
|
|
|
|
id, url, title, excerpt, author, modified)
|
|
|
|
VALUES(?, ?, ?, ?, ?, ?)`,
|
|
|
|
bookmarkID,
|
|
|
|
bookmark.URL,
|
|
|
|
bookmark.Title,
|
|
|
|
bookmark.Excerpt,
|
|
|
|
bookmark.Author,
|
|
|
|
bookmark.Modified)
|
|
|
|
|
|
|
|
// Save bookmark content
|
|
|
|
tx.MustExec(`INSERT INTO bookmark_content
|
|
|
|
(docid, title, content, html) VALUES (?, ?, ?, ?)`,
|
|
|
|
bookmarkID,
|
|
|
|
bookmark.Title,
|
|
|
|
bookmark.Content,
|
|
|
|
bookmark.HTML)
|
|
|
|
|
|
|
|
// Save tags
|
|
|
|
stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = ?`)
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`)
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
stmtInsertBookmarkTag, err := tx.Preparex(`INSERT OR IGNORE INTO bookmark_tag
|
|
|
|
(tag_id, bookmark_id) VALUES (?, ?)`)
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
for _, tag := range bookmark.Tags {
|
|
|
|
tagName := strings.ToLower(tag.Name)
|
|
|
|
tagName = strings.TrimSpace(tagName)
|
|
|
|
|
|
|
|
tagID := -1
|
|
|
|
err = stmtGetTag.Get(&tagID, tagName)
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
if tagID == -1 {
|
|
|
|
res := stmtInsertTag.MustExec(tagName)
|
|
|
|
tagID64, err := res.LastInsertId()
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
tagID = int(tagID64)
|
|
|
|
}
|
|
|
|
|
|
|
|
stmtInsertBookmarkTag.Exec(tagID, bookmarkID)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Commit transaction
|
|
|
|
err = tx.Commit()
|
|
|
|
checkError(err)
|
|
|
|
|
|
|
|
return bookmarkID, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetBookmarks fetch list of bookmarks based on submitted ids.
|
|
|
|
func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, error) {
|
|
|
|
// Create initial query
|
|
|
|
columns := []string{
|
|
|
|
`b.id`,
|
|
|
|
`b.url`,
|
|
|
|
`b.title`,
|
|
|
|
`b.excerpt`,
|
|
|
|
`b.author`,
|
|
|
|
`b.modified`,
|
|
|
|
`bc.content <> "" has_content`}
|
|
|
|
|
|
|
|
if opts.WithContent {
|
|
|
|
columns = append(columns, `bc.content`, `bc.html`)
|
|
|
|
}
|
|
|
|
|
|
|
|
query := `SELECT ` + strings.Join(columns, ",") + `
|
|
|
|
FROM bookmark b
|
|
|
|
LEFT JOIN bookmark_content bc ON bc.docid = b.id
|
|
|
|
WHERE 1`
|
|
|
|
|
|
|
|
// Add where clause
|
|
|
|
args := []interface{}{}
|
|
|
|
|
|
|
|
if len(opts.IDs) > 0 {
|
|
|
|
query += ` AND b.id IN (?)`
|
|
|
|
args = append(args, opts.IDs)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.Keyword != "" {
|
|
|
|
query += ` AND (b.url LIKE ? OR b.id IN (
|
|
|
|
SELECT docid id
|
|
|
|
FROM bookmark_content
|
|
|
|
WHERE title MATCH ? OR content MATCH ?))`
|
|
|
|
|
|
|
|
args = append(args,
|
|
|
|
"%"+opts.Keyword+"%",
|
|
|
|
opts.Keyword,
|
|
|
|
opts.Keyword)
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(opts.Tags) > 0 {
|
|
|
|
query += ` AND b.id IN (
|
|
|
|
SELECT bookmark_id FROM bookmark_tag
|
|
|
|
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (?)))`
|
|
|
|
|
|
|
|
args = append(args, opts.Tags)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add order clause
|
|
|
|
if opts.OrderLatest {
|
|
|
|
query += ` ORDER BY b.modified DESC`
|
|
|
|
}
|
|
|
|
|
|
|
|
// Expand query, because some of the args might be an array
|
|
|
|
query, args, err := sqlx.In(query, args...)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to expand query: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetch bookmarks
|
|
|
|
bookmarks := []model.Bookmark{}
|
|
|
|
err = db.Select(&bookmarks, query, args...)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
|
|
return nil, fmt.Errorf("failed to fetch data: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetch tags for each bookmarks
|
|
|
|
stmtGetTags, err := db.Preparex(`SELECT t.id, t.name
|
|
|
|
FROM bookmark_tag bt
|
|
|
|
LEFT JOIN tag t ON bt.tag_id = t.id
|
|
|
|
WHERE bt.bookmark_id = ?
|
|
|
|
ORDER BY t.name`)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to prepare tag query: %v", err)
|
|
|
|
}
|
|
|
|
defer stmtGetTags.Close()
|
|
|
|
|
|
|
|
for i, book := range bookmarks {
|
|
|
|
book.Tags = []model.Tag{}
|
|
|
|
err = stmtGetTags.Select(&book.Tags, book.ID)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
|
|
return nil, fmt.Errorf("failed to fetch tags: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
bookmarks[i] = book
|
|
|
|
}
|
|
|
|
|
|
|
|
return bookmarks, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateNewID creates new ID for specified table
|
|
|
|
func (db *SQLiteDatabase) CreateNewID(table string) (int, error) {
|
|
|
|
var tableID int
|
|
|
|
query := fmt.Sprintf(`SELECT IFNULL(MAX(id) + 1, 1) FROM %s`, table)
|
|
|
|
|
|
|
|
err := db.Get(&tableID, query)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
|
|
return -1, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return tableID, nil
|
|
|
|
}
|