mirror of
https://github.com/go-shiori/shiori.git
synced 2025-02-21 22:43:22 +08:00
Implement add bookmark in web interface
This commit is contained in:
parent
d35a396ad2
commit
093b398b2f
11 changed files with 451 additions and 34 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -202,7 +202,7 @@ body {
|
|||
}
|
||||
|
||||
&.active {
|
||||
color: var(--contentBg);
|
||||
color: var(--colorSidebar);
|
||||
background-color: var(--main);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue