Create command for add and print bookmarks

This commit is contained in:
Radhi Fadlillah 2018-01-28 13:55:43 +07:00
parent f24fc48bbc
commit aabd1a7838
9 changed files with 486 additions and 76 deletions

49
cmd/add.go Normal file
View file

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

97
cmd/print.go Normal file
View file

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

26
cmd/root.go Normal file
View file

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

16
cmd/utils.go Normal file
View file

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

View file

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

17
database/database.go Normal file
View file

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

252
database/sqlite.go Normal file
View file

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

12
main.go
View file

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

20
model/model.go Normal file
View file

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