Implement add bookmark in web interface

This commit is contained in:
Radhi Fadlillah 2019-05-28 17:05:11 +07:00
parent d35a396ad2
commit 093b398b2f
11 changed files with 451 additions and 34 deletions

View file

@ -40,6 +40,9 @@ type DB interface {
// GetAccount fetch account with matching username.
GetAccount(username string) (model.Account, bool)
// GetTags fetch list of tags and its frequency from database.
GetTags() ([]model.Tag, error)
// CreateNewID creates new id for specified table.
CreateNewID(table string) (int, error)
}

View file

@ -444,6 +444,22 @@ func (db *SQLiteDatabase) GetAccount(username string) (model.Account, bool) {
return account, account.ID != 0
}
// GetTags fetch list of tags and their frequency.
func (db *SQLiteDatabase) GetTags() ([]model.Tag, error) {
tags := []model.Tag{}
query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) n_bookmarks
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
GROUP BY bt.tag_id ORDER BY t.name`
err := db.Select(&tags, query)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to fetch tags: %v", err)
}
return tags, nil
}
// CreateNewID creates new ID for specified table
func (db *SQLiteDatabase) CreateNewID(table string) (int, error) {
var tableID int

View file

@ -1 +1 @@
:root{--dialogHeaderBg:#292929;--colorDialogHeader:#FFF}.custom-dialog-overlay{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;-webkit-box-align:center;align-items:center;-webkit-box-pack:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10001;background-color:rgba(0,0,0,0.6);padding:32px}.custom-dialog-overlay .custom-dialog{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;width:100%;max-width:400px;min-height:0;max-height:100%;overflow:auto;background-color:var(--contentBg);font-size:16px}.custom-dialog-overlay .custom-dialog .custom-dialog-header{padding:16px;color:var(--colorDialogHeader);background-color:var(--dialogHeaderBg);font-weight:600;font-size:1em;text-transform:uppercase}.custom-dialog-overlay .custom-dialog .custom-dialog-body{padding:16px;display:grid;max-height:100%;min-height:80px;min-width:0;overflow:auto;font-size:1em;grid-template-columns:max-content 1fr;-webkit-box-align:baseline;align-items:baseline;grid-gap:16px}.custom-dialog-overlay .custom-dialog .custom-dialog-body .custom-dialog-content{grid-column:1 / span 2;color:var(--color);align-self:baseline}.custom-dialog-overlay .custom-dialog .custom-dialog-body>input{color:var(--color);padding:8px;font-size:1em;border:1px solid var(--border);min-width:0}.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding:16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;-webkit-box-pack:end;justify-content:flex-end;border-top:1px solid var(--border)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a{padding:0 8px;font-size:.9em;font-weight:600;color:var(--color);text-transform:uppercase}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:hover{color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:focus{outline:none;color:var(--main);border-bottom:1px dashed var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>i.fa-spinner.fa-spin{width:19px;line-height:19px;text-align:center}
:root{--dialogHeaderBg:#292929;--colorDialogHeader:#FFF}.custom-dialog-overlay{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;-webkit-box-align:center;align-items:center;-webkit-box-pack:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10001;background-color:rgba(0,0,0,0.6);padding:32px}.custom-dialog-overlay .custom-dialog{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;width:100%;max-width:400px;min-height:0;max-height:100%;overflow:auto;background-color:var(--contentBg);font-size:16px}.custom-dialog-overlay .custom-dialog .custom-dialog-header{padding:16px;color:var(--colorDialogHeader);background-color:var(--dialogHeaderBg);font-weight:600;font-size:1em;text-transform:uppercase;border-bottom:1px solid var(--border)}.custom-dialog-overlay .custom-dialog .custom-dialog-body{padding:16px;display:grid;max-height:100%;min-height:80px;min-width:0;overflow:auto;font-size:1em;grid-template-columns:max-content 1fr;-webkit-box-align:baseline;align-items:baseline;grid-gap:16px}.custom-dialog-overlay .custom-dialog .custom-dialog-body .custom-dialog-content{grid-column:1 / span 2;color:var(--color);align-self:baseline}.custom-dialog-overlay .custom-dialog .custom-dialog-body>input,.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{color:var(--color);padding:8px;font-size:1em;border:1px solid var(--border);background-color:var(--contentBg);min-width:0}.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{min-height:37px;resize:vertical}.custom-dialog-overlay .custom-dialog .custom-dialog-body>.suggestion{position:absolute;display:block;padding:8px;background-color:var(--contentBg);border:1px solid var(--border);color:var(--color);font-size:.9em}.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding:16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;-webkit-box-pack:end;justify-content:flex-end;border-top:1px solid var(--border)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a{padding:0 8px;font-size:.9em;font-weight:600;color:var(--color);text-transform:uppercase}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:hover{color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:focus{outline:none;color:var(--main);border-bottom:1px dashed var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>i.fa-spinner.fa-spin{width:19px;line-height:19px;text-align:center}

File diff suppressed because one or more lines are too long

View file

@ -3,18 +3,34 @@ var template = `
<div class="custom-dialog">
<p class="custom-dialog-header">{{title}}</p>
<div class="custom-dialog-body">
<p class="custom-dialog-content">{{content}}</p>
<template v-for="(field,index) in formFields">
<label v-if="showLabel">{{field.label}} :</label>
<input :style="{gridColumnEnd: showLabel ? null : 'span 2'}"
:type="fieldType(field)"
:placeholder="field.label"
:tabindex="index+1"
ref="input"
v-model="field.value"
@focus="$event.target.select()"
@keyup.enter="handleMainClick">
</template>
<slot>
<p class="custom-dialog-content">{{content}}</p>
<template v-for="(field,index) in formFields">
<label v-if="showLabel">{{field.label}} :</label>
<textarea v-if="field.type === 'area'"
:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
:placeholder="field.label"
:tabindex="index+1"
ref="input"
v-model="field.value"
@focus="$event.target.select()"
@keyup="handleInput(index)">
</textarea>
<input v-else
:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
:type="fieldType(field)"
:placeholder="field.label"
:tabindex="index+1"
ref="input"
v-model="field.value"
@focus="$event.target.select()"
@keyup="handleInput(index)"
@keyup.enter="handleInputEnter(index)">
<span :ref="'suggestion-'+index"
v-if="field.suggestion"
class="suggestion">{{field.suggestion}}</span>
</template>
</slot>
</div>
<div class="custom-dialog-footer">
<i v-if="loading" class="fas fa-fw fa-spinner fa-spin"></i>
@ -90,6 +106,9 @@ export default {
label: field,
value: '',
type: 'text',
dictionary: [],
separator: ' ',
suggestion: undefined
}
if (typeof field === 'object') return {
@ -97,6 +116,9 @@ export default {
label: field.label || '',
value: field.value || '',
type: field.type || 'text',
dictionary: field.dictionary instanceof Array ? field.dictionary : [],
separator: field.separator || ' ',
suggestion: undefined
}
});
}
@ -129,6 +151,57 @@ export default {
handleSecondClick() {
this.secondClick();
},
handleInput(index) {
// Create initial variable
var field = this.formFields[index],
dictionary = field.dictionary;
// Make sure dictionary is not empty
if (dictionary.length === 0) return;
// Fetch suggestion from dictionary
var words = field.value.split(field.separator),
lastWord = words[words.length - 1].toLowerCase(),
suggestion;
if (lastWord !== '') {
suggestion = dictionary.find(word => {
return word.toLowerCase().startsWith(lastWord)
});
}
this.formFields[index].suggestion = suggestion;
// Make sure suggestion exist
if (suggestion == null) return;
// Display suggestion
this.$nextTick(() => {
var input = this.$refs.input[index],
span = this.$refs['suggestion-' + index][0],
inputRect = input.getBoundingClientRect();
span.style.top = (inputRect.bottom - 1) + 'px';
span.style.left = inputRect.left + 'px';
});
},
handleInputEnter(index) {
var suggestion = this.formFields[index].suggestion;
if (suggestion == null) {
this.handleMainClick();
return;
}
var separator = this.formFields[index].separator,
words = this.formFields[index].value.split(separator);
words.pop();
words.push(suggestion);
this.formFields[index].value = words.join(separator) + separator;
this.formFields[index].suggestion = undefined;
},
focus() {
this.$nextTick(() => {
if (!this.visible) return;

View file

@ -5,7 +5,7 @@ var template = `
<a title="Refresh storage" @click="reloadData">
<i class="fas fa-fw fa-sync-alt" :class="loading && 'fa-spin'"></i>
</a>
<a title="Add new bookmark">
<a title="Add new bookmark" @click="showDialogAdd">
<i class="fas fa-fw fa-plus-circle"></i>
</a>
<a title="Batch edit">
@ -72,7 +72,8 @@ export default {
search: "",
page: 0,
maxPage: 0,
bookmarks: []
bookmarks: [],
tags: [],
}
},
computed: {
@ -85,13 +86,14 @@ export default {
if (this.loading) return;
this.page = 1;
this.search = "";
this.loadBookmarks();
this.loadData(true, true);
},
loadBookmarks(saveState) {
loadData(saveState, fetchTags) {
if (this.loading) return;
// By default, we eill save the state
// Set default args
saveState = (typeof saveState === "boolean") ? saveState : true;
fetchTags = (typeof fetchTags === "boolean") ? fetchTags : false;
// Parse search query
var rxTagA = /['"]#([^'"]+)['"]/g, // "#tag with space"
@ -125,8 +127,9 @@ export default {
});
// Fetch data from API
this.loading = true;
var skipFetchTags = Error("skip fetching tags");
this.loading = true;
fetch(url)
.then(response => {
if (!response.ok) throw response;
@ -137,7 +140,6 @@ export default {
this.page = json.page;
this.maxPage = json.maxPage;
this.bookmarks = json.bookmarks;
this.loading = false;
// Save state and change URL if needed
if (saveState) {
@ -158,17 +160,36 @@ export default {
window.history.pushState(history, "page-home", url);
}
// Fetch tags if requested
if (fetchTags) {
return fetch("/api/tags");
} else {
this.loading = false;
throw skipFetchTags;
}
})
.then(response => {
if (!response.ok) throw response;
return response.json();
})
.then(json => {
this.tags = json;
this.loading = false;
})
.catch(err => {
this.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
if (err !== skipFetchTags) {
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
}
});
},
searchBookmarks() {
this.page = 1;
this.loadBookmarks();
this.loadData();
},
changePage(page) {
page = parseInt(page, 10) || 0;
@ -177,7 +198,7 @@ export default {
else this.page = page;
this.$refs.bookmarksGrid.scrollTop = 0;
this.loadBookmarks();
this.loadData();
},
filterTag(tagName) {
var rxSpace = /\s+/g,
@ -185,9 +206,84 @@ export default {
if (!this.search.includes(newTag)) {
this.search += ` ${newTag}`;
this.loadBookmarks();
this.loadData();
}
}
},
showDialogAdd() {
this.showDialog({
title: 'New Bookmark',
content: 'Create a new bookmark',
fields: [{
name: 'url',
label: 'Url, start with http://...',
}, {
name: 'title',
label: 'Custom title (optional)'
}, {
name: 'excerpt',
label: 'Custom excerpt (optional)',
type: 'area'
}, {
name: 'tags',
label: 'Comma separated tags (optional)',
separator: ',',
dictionary: this.tags.map(tag => tag.name)
}],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Make sure URL is not empty
if (data.url.trim() === "") {
this.showErrorDialog("URL must not empty");
return;
}
// Prepare tags
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.split(/\s*,\s*/g)
.filter(tag => tag !== '')
.map(tag => {
return {
name: tag
};
});
// Send data
var data = {
url: data.url.trim(),
title: data.title.trim(),
excerpt: data.excerpt.trim(),
tags: tags
};
this.dialog.loading = true;
fetch("/api/bookmarks", {
method: "post",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
})
.then(response => {
if (!response.ok) throw response;
return response.json();
})
.then(json => {
this.dialog.loading = false;
this.dialog.visible = false;
this.bookmarks.splice(0, 0, json);
})
.catch(err => {
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
});
}
});
},
},
mounted() {
var stateWatcher = (e) => {
@ -200,7 +296,7 @@ export default {
this.page = page;
this.search = search;
this.loadBookmarks(false);
this.loadData(false);
}
window.addEventListener('popstate', stateWatcher);
@ -208,6 +304,6 @@ export default {
window.removeEventListener('popstate', stateWatcher);
})
this.loadBookmarks(false);
this.loadData(false, true);
}
}

View file

@ -38,6 +38,7 @@
font-weight: 600;
font-size: 1em;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.custom-dialog-body {
@ -58,13 +59,30 @@
align-self: baseline;
}
>input {
>input,
>textarea {
color: var(--color);
padding: 8px;
font-size: 1em;
border: 1px solid var(--border);
background-color: var(--contentBg);
min-width: 0;
}
>textarea {
min-height: 37px;
resize: vertical;
}
>.suggestion {
position: absolute;
display: block;
padding: 8px;
background-color: var(--contentBg);
border: 1px solid var(--border);
color: var(--color);
font-size: 0.9em;
}
}
.custom-dialog-footer {

View file

@ -202,7 +202,7 @@ body {
}
&.active {
color: var(--contentBg);
color: var(--colorSidebar);
background-color: var(--main);
}
}

View file

@ -5,12 +5,14 @@ import (
"fmt"
"math"
"net/http"
nurl "net/url"
"path"
fp "path/filepath"
"strconv"
"strings"
"time"
"github.com/go-shiori/go-readability"
"github.com/go-shiori/shiori/internal/database"
"github.com/go-shiori/shiori/internal/model"
"github.com/gofrs/uuid"
@ -167,3 +169,111 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
err = json.NewEncoder(w).Encode(&resp)
checkError(err)
}
// apiGetTags is handler for GET /api/tags
func (h *handler) apiGetTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Fetch all tags
tags, err := h.DB.GetTags()
checkError(err)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&tags)
checkError(err)
}
// apiInsertBookmark is handler for POST /api/bookmark
func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
book := model.Bookmark{}
err = json.NewDecoder(r.Body).Decode(&book)
checkError(err)
// Clean up URL by removing its fragment and UTM parameters
tmp, err := nurl.Parse(book.URL)
if err != nil || tmp.Scheme == "" || tmp.Hostname() == "" {
panic(fmt.Errorf("URL is not valid"))
}
tmp.Fragment = ""
clearUTMParams(tmp)
book.URL = tmp.String()
// Create bookmark ID
book.ID, err = h.DB.CreateNewID("bookmark")
if err != nil {
panic(fmt.Errorf("failed to create ID: %v", err))
}
// Fetch data from internet
var imageURLs []string
func() {
resp, err := httpClient.Get(book.URL)
if err != nil {
return
}
defer resp.Body.Close()
article, err := readability.FromReader(resp.Body, book.URL)
if err != nil {
return
}
book.Author = article.Byline
book.Content = article.TextContent
book.HTML = article.Content
// If title and excerpt doesnt have submitted value, use from article
if book.Title == "" {
book.Title = article.Title
}
if book.Excerpt == "" {
book.Excerpt = article.Excerpt
}
// Get image URL
if article.Image != "" {
imageURLs = append(imageURLs, article.Image)
}
if article.Favicon != "" {
imageURLs = append(imageURLs, article.Favicon)
}
}()
// Make sure title is not empty
if book.Title == "" {
book.Title = book.URL
}
// Save bookmark to database
results, err := h.DB.SaveBookmarks(book)
if err != nil || len(results) == 0 {
panic(fmt.Errorf("failed to save bookmark: %v", err))
}
book = results[0]
// Save article image to local disk
imgPath := fp.Join(h.DataDir, "thumb", fmt.Sprintf("%d", book.ID))
for _, imageURL := range imageURLs {
err = downloadBookImage(imageURL, imgPath, time.Minute)
if err == nil {
strID := strconv.Itoa(book.ID)
book.ImageURL = path.Join("/", "thumb", strID)
break
}
}
// Return the new bookmark
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&book)
checkError(err)
}

View file

@ -11,6 +11,8 @@ import (
"github.com/sirupsen/logrus"
)
var httpClient = &http.Client{Timeout: time.Minute}
// ServeApp serves wb interface in specified port
func ServeApp(DB database.DB, dataDir string, port int) error {
// Create handler
@ -36,8 +38,8 @@ func ServeApp(DB database.DB, dataDir string, port int) error {
router.POST("/api/login", hdl.apiLogin)
router.POST("/api/logout", hdl.apiLogout)
router.GET("/api/bookmarks", hdl.apiGetBookmarks)
// router.GET("/api/tags", hdl.apiGetTags)
// router.POST("/api/bookmarks", hdl.apiInsertBookmark)
router.GET("/api/tags", hdl.apiGetTags)
router.POST("/api/bookmarks", hdl.apiInsertBookmark)
// router.PUT("/api/cache", hdl.apiUpdateCache)
// router.PUT("/api/bookmarks", hdl.apiUpdateBookmark)
// router.PUT("/api/bookmarks/tags", hdl.apiUpdateBookmarkTags)

View file

@ -2,11 +2,21 @@ package webserver
import (
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"io"
"math"
"mime"
"net/http"
nurl "net/url"
"os"
fp "path/filepath"
"strings"
"time"
"github.com/disintegration/imaging"
)
func serveFile(w http.ResponseWriter, filePath string, cache bool) error {
@ -63,6 +73,95 @@ func fileExists(filePath string) bool {
return !os.IsNotExist(err) && !info.IsDir()
}
func clearUTMParams(url *nurl.URL) {
queries := url.Query()
for key := range queries {
if strings.HasPrefix(key, "utm_") {
queries.Del(key)
}
}
url.RawQuery = queries.Encode()
}
func downloadBookImage(url, dstPath string, timeout time.Duration) error {
// Fetch data from URL
client := &http.Client{Timeout: timeout}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Make sure it's JPG or PNG image
cp := resp.Header.Get("Content-Type")
if !strings.Contains(cp, "image/jpeg") && !strings.Contains(cp, "image/png") {
return fmt.Errorf("%s is not a supported image", url)
}
// At this point, the download has finished successfully.
// Prepare destination file.
err = os.MkdirAll(fp.Dir(dstPath), os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create image dir: %v", err)
}
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create image file: %v", err)
}
defer dstFile.Close()
// Parse image and process it.
// If image is smaller than 600x400 or its ratio is less than 4:3, resize.
// Else, save it as it is.
img, _, err := image.Decode(resp.Body)
if err != nil {
return fmt.Errorf("failed to parse image %s: %v", url, err)
}
imgRect := img.Bounds()
imgWidth := imgRect.Dx()
imgHeight := imgRect.Dy()
imgRatio := float64(imgWidth) / float64(imgHeight)
if imgWidth >= 600 && imgHeight >= 400 && imgRatio > 1.3 {
err = jpeg.Encode(dstFile, img, nil)
} else {
// Create background
bg := image.NewNRGBA(imgRect)
draw.Draw(bg, imgRect, image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(bg, imgRect, img, image.Point{}, draw.Over)
bg = imaging.Fill(bg, 600, 400, imaging.Center, imaging.Lanczos)
bg = imaging.Blur(bg, 150)
bg = imaging.AdjustBrightness(bg, 30)
// Create foreground
fg := imaging.Fit(img, 600, 400, imaging.Lanczos)
// Merge foreground and background
bgRect := bg.Bounds()
fgRect := fg.Bounds()
fgPosition := image.Point{
X: bgRect.Min.X - int(math.Round(float64(bgRect.Dx()-fgRect.Dx())/2)),
Y: bgRect.Min.Y - int(math.Round(float64(bgRect.Dy()-fgRect.Dy())/2)),
}
draw.Draw(bg, bgRect, fg, fgPosition, draw.Over)
// Save to file
err = jpeg.Encode(dstFile, bg, nil)
}
if err != nil {
return fmt.Errorf("failed to save image %s: %v", url, err)
}
return nil
}
func checkError(err error) {
if err != nil {
panic(err)