From aabd1a78381d7c476aa967cae3ba8f75798015ee Mon Sep 17 00:00:00 2001 From: Radhi Fadlillah Date: Sun, 28 Jan 2018 13:55:43 +0700 Subject: [PATCH] Create command for add and print bookmarks --- cmd/add.go | 49 +++++++++ cmd/print.go | 97 +++++++++++++++++ cmd/root.go | 26 +++++ cmd/utils.go | 16 +++ database.go | 73 ------------- database/database.go | 17 +++ database/sqlite.go | 252 +++++++++++++++++++++++++++++++++++++++++++ main.go | 12 ++- model/model.go | 20 ++++ 9 files changed, 486 insertions(+), 76 deletions(-) create mode 100644 cmd/add.go create mode 100644 cmd/print.go create mode 100644 cmd/root.go create mode 100644 cmd/utils.go delete mode 100644 database.go create mode 100644 database/database.go create mode 100644 database/sqlite.go create mode 100644 model/model.go diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 00000000..ab5ee133 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/RadhiFadlillah/go-readability" + "github.com/spf13/cobra" + "os" + "time" +) + +var ( + addCmd = &cobra.Command{ + Use: "add url", + Short: "Bookmark URL with comma-separated tags.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Read flag and arguments + url := args[0] + tags, _ := cmd.Flags().GetStringSlice("tags") + + // Save new bookmark + err := addBookmark(url, tags...) + if err != nil { + cError.Println(err) + os.Exit(1) + } + }, + } +) + +func init() { + addCmd.Flags().StringSliceP("tags", "t", []string{}, "Comma-separated tags for this bookmark.") + rootCmd.AddCommand(addCmd) +} + +func addBookmark(url string, tags ...string) error { + article, err := readability.Parse(url, 10*time.Second) + if err != nil { + return err + } + + bookmark, err := DB.SaveBookmark(article, tags...) + if err != nil { + return err + } + + printBookmark(bookmark) + + return nil +} diff --git a/cmd/print.go b/cmd/print.go new file mode 100644 index 00000000..af549038 --- /dev/null +++ b/cmd/print.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "github.com/RadhiFadlillah/shiori/model" + "github.com/spf13/cobra" + "os" + "strings" +) + +var ( + printCmd = &cobra.Command{ + Use: "print [indices]", + Short: "Print the saved bookmarks.", + Long: "Show details of bookmark record by its DB index. " + + "If no arguments, all records with actual index from DB are shown. " + + "Accepts hyphenated ranges and space-separated indices.", + Run: func(cmd *cobra.Command, args []string) { + // Read flags + useJSON, _ := cmd.Flags().GetBool("json") + + // Read bookmarks from database + bookmarks, err := DB.GetBookmarks(args...) + if err != nil { + cError.Println(err) + os.Exit(1) + } + + if len(bookmarks) == 0 { + cError.Println("No matching index found") + os.Exit(1) + } + + // Print data + if useJSON { + bt, err := json.MarshalIndent(&bookmarks, "", " ") + if err != nil { + cError.Println(err) + os.Exit(1) + } + fmt.Println(string(bt)) + } else { + printBookmark(bookmarks...) + } + }, + } +) + +func init() { + printCmd.Flags().BoolP("json", "j", false, "Output data in JSON format") + rootCmd.AddCommand(printCmd) +} + +func printBookmark(bookmarks ...model.Bookmark) { + for _, bookmark := range bookmarks { + // Create bookmark index + strBookmarkIndex := fmt.Sprintf("%d. ", bookmark.ID) + strSpace := strings.Repeat(" ", len(strBookmarkIndex)) + + // Print bookmark title + cIndex.Print(strBookmarkIndex) + cTitle.Print(bookmark.Title) + + // Print read time + readTime := fmt.Sprintf(" (%d-%d minutes)", bookmark.MinReadTime, bookmark.MaxReadTime) + if bookmark.MinReadTime == bookmark.MaxReadTime { + readTime = fmt.Sprintf(" (%d minutes)", bookmark.MinReadTime) + } + cReadTime.Println(readTime) + + // Print bookmark URL + cSymbol.Print(strSpace + "> ") + cURL.Println(bookmark.URL) + + // Print bookmark excerpt + if bookmark.Excerpt != "" { + cSymbol.Print(strSpace + "+ ") + cExcerpt.Println(bookmark.Excerpt) + } + + // Print bookmark tags + if len(bookmark.Tags) > 0 { + cSymbol.Print(strSpace + "# ") + for i, tag := range bookmark.Tags { + if i == len(bookmark.Tags)-1 { + cTag.Println(tag.Name) + } else { + cTag.Print(tag.Name + ", ") + } + } + } + + // Append new line + fmt.Println() + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..e33d4577 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + "github.com/RadhiFadlillah/shiori/database" + "github.com/spf13/cobra" + "os" +) + +var ( + DB database.Database + + rootCmd = &cobra.Command{ + Use: "shiori", + Short: "Simple command-line bookmark manager built with Go.", + } +) + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 00000000..dbeea222 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/fatih/color" +) + +var ( + cIndex = color.New(color.FgHiCyan) + cSymbol = color.New(color.FgHiMagenta) + cTitle = color.New(color.FgHiGreen).Add(color.Bold) + cReadTime = color.New(color.FgHiMagenta) + cURL = color.New(color.FgHiYellow) + cError = color.New(color.FgHiRed) + cExcerpt = color.New(color.FgHiWhite) + cTag = color.New(color.FgHiBlue) +) diff --git a/database.go b/database.go deleted file mode 100644 index ecf71942..00000000 --- a/database.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "github.com/jmoiron/sqlx" - _ "github.com/mattn/go-sqlite3" - "log" -) - -func openDatabase() (db *sqlx.DB, err error) { - // Open database and start transaction - 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) - log.Println("Database error:", panicErr) - tx.Rollback() - - db = nil - err = panicErr - } - }() - - _, err = tx.Exec(`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))`) - checkError(err) - - _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS bookmark( - id INTEGER NOT NULL, - account_id INTEGER DEFAULT 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), - CONSTRAINT bookmark_account_id_FK FOREIGN KEY(account_id) REFERENCES account(id))`) - checkError(err) - - _, err = tx.Exec(`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))`) - checkError(err) - - _, err = tx.Exec(`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))`) - checkError(err) - - _, err = tx.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS bookmark_content USING fts4(title, content)`) - checkError(err) - - err = tx.Commit() - checkError(err) - - return db, err -} diff --git a/database/database.go b/database/database.go new file mode 100644 index 00000000..dacc54d3 --- /dev/null +++ b/database/database.go @@ -0,0 +1,17 @@ +package database + +import ( + "github.com/RadhiFadlillah/go-readability" + "github.com/RadhiFadlillah/shiori/model" +) + +type Database interface { + SaveBookmark(article readability.Article, tags ...string) (model.Bookmark, error) + GetBookmarks(indices ...string) ([]model.Bookmark, error) +} + +func checkError(err error) { + if err != nil { + panic(err) + } +} diff --git a/database/sqlite.go b/database/sqlite.go new file mode 100644 index 00000000..68e617d9 --- /dev/null +++ b/database/sqlite.go @@ -0,0 +1,252 @@ +package database + +import ( + "database/sql" + "github.com/RadhiFadlillah/go-readability" + "github.com/RadhiFadlillah/shiori/model" + "github.com/jmoiron/sqlx" + "log" + "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) + log.Println("Database error:", panicErr) + tx.Rollback() + + db = nil + err = panicErr + } + }() + + _, err = tx.Exec(`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))`) + checkError(err) + + _, err = tx.Exec(`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))`) + checkError(err) + + _, err = tx.Exec(`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))`) + checkError(err) + + _, err = tx.Exec(`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))`) + checkError(err) + + _, err = tx.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS bookmark_content USING fts4(title, content)`) + checkError(err) + + err = tx.Commit() + checkError(err) + + return &SQLiteDatabase{*db}, err +} + +func (db *SQLiteDatabase) SaveBookmark(article readability.Article, tags ...string) (bookmark model.Bookmark, err error) { + // 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, err := tx.Exec(`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) + checkError(err) + + // Get last inserted ID + bookmarkID, err := res.LastInsertId() + checkError(err) + + // Save bookmark content + _, err = tx.Exec(`INSERT INTO bookmark_content + (docid, title, content) VALUES (?, ?, ?)`, + bookmarkID, article.Meta.Title, article.Content) + checkError(err) + + // 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) + if err != nil && err != sql.ErrNoRows { + panic(err) + } + + if tagID == -1 { + res, err := stmtInsertTag.Exec(tag) + checkError(err) + + 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) { + // Prepare query + query := `SELECT id, + url, title, image_url, excerpt, author, + language, min_read_time, max_read_time, modified + FROM bookmark ` + args := []interface{}{} + + // Add where clause + for _, strIndex := range indices { + clause := "WHERE" + if strings.Contains(query, "WHERE") { + clause = "OR" + } + + if strings.Contains(strIndex, "-") { + parts := strings.Split(strIndex, "-") + if len(parts) > 2 { + continue + } + + minIndex, errMin := strconv.Atoi(parts[0]) + maxIndex, errMax := strconv.Atoi(parts[1]) + if errMin != nil || errMax != nil { + continue + } + + query += clause + ` (id BETWEEN ? AND ?) ` + args = append(args, minIndex, maxIndex) + } else { + index, err := strconv.Atoi(strIndex) + if err != nil { + continue + } + + query += clause + ` id = ? ` + args = append(args, index) + } + } + + // Fetch bookmarks + 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 +} diff --git a/main.go b/main.go index cdb13a17..03cd44b9 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,17 @@ package main -import "fmt" +import ( + "github.com/RadhiFadlillah/shiori/cmd" + db "github.com/RadhiFadlillah/shiori/database" + _ "github.com/mattn/go-sqlite3" +) func main() { - fmt.Println("Hello world") - _, err := openDatabase() + sqliteDB, err := db.OpenSQLiteDatabase() checkError(err) + + cmd.DB = sqliteDB + cmd.Execute() } func checkError(err error) { diff --git a/model/model.go b/model/model.go new file mode 100644 index 00000000..ac1eae94 --- /dev/null +++ b/model/model.go @@ -0,0 +1,20 @@ +package model + +type Tag struct { + ID int64 `db:"id"` + Name string `db:"name"` +} + +type Bookmark struct { + ID int64 `db:"id"` + URL string `db:"url"` + Title string `db:"title"` + ImageURL string `db:"image_url"` + Excerpt string `db:"excerpt"` + Author string `db:"author"` + Language string `db:"language"` + MinReadTime int `db:"min_read_time"` + MaxReadTime int `db:"max_read_time"` + Modified string `db:"modified"` + Tags []Tag +}