mirror of
https://github.com/go-shiori/shiori.git
synced 2025-02-22 06:53:22 +08:00
Create command for add and print bookmarks
This commit is contained in:
parent
f24fc48bbc
commit
aabd1a7838
9 changed files with 486 additions and 76 deletions
49
cmd/add.go
Normal file
49
cmd/add.go
Normal 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
97
cmd/print.go
Normal 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
26
cmd/root.go
Normal 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
16
cmd/utils.go
Normal 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)
|
||||
)
|
73
database.go
73
database.go
|
@ -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
17
database/database.go
Normal 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
252
database/sqlite.go
Normal 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
12
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) {
|
||||
|
|
20
model/model.go
Normal file
20
model/model.go
Normal 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
|
||||
}
|
Loading…
Reference in a new issue