package database import ( "database/sql" "fmt" "github.com/RadhiFadlillah/go-readability" "github.com/RadhiFadlillah/shiori/model" "github.com/jmoiron/sqlx" "sort" "strconv" "strings" "time" ) type SQLiteDatabase struct { sqlx.DB } func OpenSQLiteDatabase() (*SQLiteDatabase, error) { // Open database and start transaction var err error db := sqlx.MustConnect("sqlite3", "shiori.db") tx := db.MustBegin() // Make sure to rollback if panic ever happened defer func() { if r := recover(); r != nil { panicErr, _ := r.(error) fmt.Println("Database error:", panicErr) tx.Rollback() db = nil err = panicErr } }() 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, image_url TEXT NOT NULL DEFAULT "", excerpt TEXT NOT NULL DEFAULT "", author TEXT NOT NULL DEFAULT "", language TEXT NOT NULL DEFAULT "", min_read_time INTEGER NOT NULL DEFAULT 0, max_read_time INTEGER NOT NULL DEFAULT 0, 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)`) err = tx.Commit() checkError(err) return &SQLiteDatabase{*db}, err } func (db *SQLiteDatabase) SaveBookmark(article readability.Article, tags ...string) (bookmark model.Bookmark, err error) { // Check URL and title if article.URL == "" { return model.Bookmark{}, fmt.Errorf("URL must not empty") } if article.Meta.Title == "" { return model.Bookmark{}, fmt.Errorf("Title must not empty") } // Prepare transaction tx, err := db.Beginx() if err != nil { return model.Bookmark{}, err } // Make sure to rollback if panic ever happened defer func() { if r := recover(); r != nil { panicErr, _ := r.(error) tx.Rollback() bookmark = model.Bookmark{} err = panicErr } }() // Save article to database res := tx.MustExec(`INSERT INTO bookmark ( url, title, image_url, excerpt, author, language, min_read_time, max_read_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, article.URL, article.Meta.Title, article.Meta.Image, article.Meta.Excerpt, article.Meta.Author, article.Meta.Language, article.Meta.MinReadTime, article.Meta.MaxReadTime) // Get last inserted ID bookmarkID, err := res.LastInsertId() checkError(err) // Save bookmark content tx.MustExec(`INSERT INTO bookmark_content (docid, title, content) VALUES (?, ?, ?)`, bookmarkID, article.Meta.Title, article.Content) // 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) bookmarkTags := []model.Tag{} for _, tag := range tags { tag = strings.ToLower(tag) tag = strings.TrimSpace(tag) tagID := int64(-1) err = stmtGetTag.Get(&tagID, tag) checkError(err) if tagID == -1 { res := stmtInsertTag.MustExec(tag) tagID, err = res.LastInsertId() checkError(err) } stmtInsertBookmarkTag.Exec(tagID, bookmarkID) bookmarkTags = append(bookmarkTags, model.Tag{ ID: tagID, Name: tag, }) } // Commit transaction err = tx.Commit() checkError(err) // Return result bookmark = model.Bookmark{ ID: bookmarkID, URL: article.URL, Title: article.Meta.Title, ImageURL: article.Meta.Image, Excerpt: article.Meta.Excerpt, Author: article.Meta.Author, Language: article.Meta.Language, MinReadTime: article.Meta.MinReadTime, MaxReadTime: article.Meta.MaxReadTime, Modified: time.Now().Format("2006-01-02 15:04:05"), Tags: bookmarkTags, } return bookmark, err } func (db *SQLiteDatabase) GetBookmarks(indices ...string) ([]model.Bookmark, error) { // Convert list of index to int listIndex := []int{} errInvalidIndex := fmt.Errorf("Index is not valid") for _, strIndex := range indices { if strings.Contains(strIndex, "-") { parts := strings.Split(strIndex, "-") if len(parts) != 2 { return nil, errInvalidIndex } minIndex, errMin := strconv.Atoi(parts[0]) maxIndex, errMax := strconv.Atoi(parts[1]) if errMin != nil || errMax != nil || minIndex < 1 || minIndex > maxIndex { return nil, errInvalidIndex } for i := minIndex; i <= maxIndex; i++ { listIndex = append(listIndex, i) } } else { index, err := strconv.Atoi(strIndex) if err != nil || index < 1 { return nil, errInvalidIndex } listIndex = append(listIndex, index) } } // Prepare where clause args := []interface{}{} whereClause := " WHERE 1" if len(listIndex) > 0 { whereClause = " WHERE id IN (" for _, idx := range listIndex { args = append(args, idx) whereClause += "?," } whereClause = whereClause[:len(whereClause)-1] whereClause += ")" } // Fetch bookmarks query := `SELECT id, url, title, image_url, excerpt, author, language, min_read_time, max_read_time, modified FROM bookmark` + whereClause bookmarks := []model.Bookmark{} err := db.Select(&bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, 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, err } defer stmtGetTags.Close() for i := range bookmarks { tags := []model.Tag{} err = stmtGetTags.Select(&tags, bookmarks[i].ID) if err != nil && err != sql.ErrNoRows { return nil, err } bookmarks[i].Tags = tags } return bookmarks, nil } func (db *SQLiteDatabase) DeleteBookmarks(indices ...string) (oldIndices, newIndices []int, err error) { // Convert list of index to int listIndex := []int{} errInvalidIndex := fmt.Errorf("Index is not valid") for _, strIndex := range indices { if strings.Contains(strIndex, "-") { parts := strings.Split(strIndex, "-") if len(parts) != 2 { return nil, nil, errInvalidIndex } minIndex, errMin := strconv.Atoi(parts[0]) maxIndex, errMax := strconv.Atoi(parts[1]) if errMin != nil || errMax != nil || minIndex < 1 || minIndex > maxIndex { return nil, nil, errInvalidIndex } for i := minIndex; i <= maxIndex; i++ { listIndex = append(listIndex, i) } } else { index, err := strconv.Atoi(strIndex) if err != nil || index < 1 { return nil, nil, errInvalidIndex } listIndex = append(listIndex, index) } } // Sort the index sort.Ints(listIndex) // Create args and where clause args := []interface{}{} whereClause := " WHERE 1" if len(listIndex) > 0 { whereClause = " WHERE id IN (" for _, idx := range listIndex { args = append(args, idx) whereClause += "?," } whereClause = whereClause[:len(whereClause)-1] whereClause += ")" } // Begin transaction tx, err := db.Beginx() if err != nil { return nil, nil, errInvalidIndex } // Make sure to rollback if panic ever happened defer func() { if r := recover(); r != nil { panicErr, _ := r.(error) tx.Rollback() oldIndices = nil newIndices = nil err = panicErr } }() // Delete bookmarks whereTagClause := strings.Replace(whereClause, "id", "bookmark_id", 1) whereContentClause := strings.Replace(whereClause, "id", "docid", 1) tx.MustExec("DELETE FROM bookmark "+whereClause, args...) tx.MustExec("DELETE FROM bookmark_tag "+whereTagClause, args...) tx.MustExec("DELETE FROM bookmark_content "+whereContentClause, args...) // Prepare statement for updating index stmtGetMaxID, err := tx.Preparex(`SELECT IFNULL(MAX(id), 0) FROM bookmark`) checkError(err) stmtUpdateBookmark, err := tx.Preparex(`UPDATE bookmark SET id = ? WHERE id = ?`) checkError(err) stmtUpdateBookmarkTag, err := tx.Preparex(`UPDATE bookmark_tag SET bookmark_id = ? WHERE bookmark_id = ?`) checkError(err) stmtUpdateBookmarkContent, err := tx.Preparex(`UPDATE bookmark_content SET docid = ? WHERE docid = ?`) checkError(err) // Get list of removed indices maxIndex := 0 err = stmtGetMaxID.Get(&maxIndex) checkError(err) removedIndices := []int{} err = tx.Select(&removedIndices, `WITH cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT ?) SELECT x FROM cnt WHERE x NOT IN (SELECT id FROM bookmark)`, maxIndex) checkError(err) // Fill removed indices newIndices = []int{} oldIndices = []int{} for _, removedIndex := range removedIndices { oldIndex := 0 err = stmtGetMaxID.Get(&oldIndex) checkError(err) if oldIndex <= removedIndex { break } stmtUpdateBookmark.MustExec(removedIndex, oldIndex) stmtUpdateBookmarkTag.MustExec(removedIndex, oldIndex) stmtUpdateBookmarkContent.MustExec(removedIndex, oldIndex) newIndices = append(newIndices, removedIndex) oldIndices = append(oldIndices, oldIndex) } // Commit transaction err = tx.Commit() checkError(err) return oldIndices, newIndices, err } func (db *SQLiteDatabase) SearchBookmarks(keyword string, tags ...string) ([]model.Bookmark, error) { // Create initial variable keyword = strings.TrimSpace(keyword) whereClause := "WHERE 1" args := []interface{}{} // Create where clause for keyword if keyword != "" { whereClause += ` AND id IN ( SELECT docid id FROM bookmark_content WHERE bookmark_content MATCH ?)` args = append(args, keyword) } // Create where clause for tags if len(tags) > 0 { whereTagClause := ` AND id IN ( SELECT DISTINCT bookmark_id FROM bookmark_tag WHERE tag_id IN (SELECT id FROM tag WHERE name IN (` for _, tag := range tags { args = append(args, tag) whereTagClause += "?," } whereTagClause = whereTagClause[:len(whereTagClause)-1] whereTagClause += ")))" whereClause += whereTagClause } // Search bookmarks query := `SELECT id, url, title, image_url, excerpt, author, language, min_read_time, max_read_time, modified FROM bookmark ` + whereClause bookmarks := []model.Bookmark{} err := db.Select(&bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, 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, err } defer stmtGetTags.Close() for i := range bookmarks { tags := []model.Tag{} err = stmtGetTags.Select(&tags, bookmarks[i].ID) if err != nil && err != sql.ErrNoRows { return nil, err } bookmarks[i].Tags = tags } return bookmarks, nil }