Implement edit bookmark in web interface

This commit is contained in:
Radhi Fadlillah 2019-05-30 10:07:20 +07:00
parent 7ab6c533a3
commit 3b8dead04d
10 changed files with 242 additions and 50 deletions

View file

@ -45,11 +45,16 @@ func printHandler(cmd *cobra.Command, args []string) {
}
// Read bookmarks from database
orderMethod := database.DefaultOrder
if orderLatest {
orderMethod = database.ByLastModified
}
searchOptions := database.GetBookmarksOptions{
IDs: ids,
Tags: tags,
Keyword: keyword,
OrderLatest: orderLatest,
OrderMethod: orderMethod,
}
bookmarks, err := DB.GetBookmarks(searchOptions)

View file

@ -6,13 +6,25 @@ import (
"github.com/go-shiori/shiori/internal/model"
)
// OrderMethod is the order method for getting bookmarks
type OrderMethod int
const (
// DefaultOrder is oldest to newest.
DefaultOrder OrderMethod = iota
// ByLastAdded is from newest addition to the oldest.
ByLastAdded
// ByLastModified is from latest modified to the oldest.
ByLastModified
)
// GetBookmarksOptions is options for fetching bookmarks from database.
type GetBookmarksOptions struct {
IDs []int
Tags []string
Keyword string
WithContent bool
OrderLatest bool
OrderMethod OrderMethod
Limit int
Offset int
}

View file

@ -246,8 +246,13 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma
}
// Add order clause
if opts.OrderLatest {
switch opts.OrderMethod {
case ByLastAdded:
query += ` ORDER BY b.id DESC`
case ByLastModified:
query += ` ORDER BY b.modified DESC`
default:
query += ` ORDER BY b.id`
}
if opts.Limit > 0 && opts.Offset >= 0 {
@ -398,14 +403,21 @@ func (db *SQLiteDatabase) DeleteBookmarks(ids ...int) (err error) {
// GetBookmark fetchs bookmark based on its ID or URL.
// Returns the bookmark and boolean whether it's exist or not.
func (db *SQLiteDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) {
book := model.Bookmark{}
db.Get(&book, `SELECT
args := []interface{}{id}
query := `SELECT
b.id, b.url, b.title, b.excerpt, b.author, b.modified,
bc.content, bc.html, bc.content <> "" has_content
FROM bookmark b
LEFT JOIN bookmark_content bc ON bc.docid = b.id
WHERE b.id = ? OR b.url = ?`,
id, url)
WHERE b.id = ?`
if url != "" {
query += ` OR b.url = ?`
args = append(args, url)
}
book := model.Bookmark{}
db.Get(&book, query, args...)
return book, book.ID != 0
}

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;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}
: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::after{content:"";display:block;min-height:1px;grid-column-end:-1;grid-column-start:1}.custom-dialog-overlay .custom-dialog .custom-dialog-body .custom-dialog-content{grid-column-end:-1;grid-column-start:1;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{height:6em;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}

View file

@ -56,6 +56,12 @@ export default {
return {
backgroundImage: `url("${this.imageURL}")`
}
},
eventItem() {
return {
id: this.id,
index: this.index,
}
}
},
methods: {
@ -63,13 +69,10 @@ export default {
this.$emit("tagClicked", name);
},
editBookmark() {
this.$emit("edit", this.id, this.index);
this.$emit("edit", this.eventItem);
},
deleteBookmark() {
this.$emit("delete", {
id: this.id,
index: this.index
});
this.$emit("delete", this.eventItem);
},
updateBookmark() {
this.$emit("update", this.id, this.index);

View file

@ -23,7 +23,8 @@ var template = `
:showId="displayOptions.showId"
:listMode="displayOptions.listMode"
@tagClicked="filterTag"
@delete="showDialogDelete">
@delete="showDialogDelete"
@edit="showDialogEdit">
</bookmark-item>
<div class="pagination-box" v-if="maxPage > 0">
<p>Page</p>
@ -110,7 +111,7 @@ export default {
}
// Clear tag A from keyword
keyword = keyword.replace(rxTagA, '');
keyword = keyword.replace(rxTagA, "");
// Fetch tag B
while (rxResult = rxTagB.exec(keyword)) {
@ -118,7 +119,7 @@ export default {
}
// Clear tag B from keyword, then trim keyword
keyword = keyword.replace(rxTagB, '').trim().replace(/\s+/g, ' ');
keyword = keyword.replace(rxTagB, "").trim().replace(/\s+/g, " ");
// Prepare URL for API
var url = new URL("/api/bookmarks", document.URL);
@ -213,26 +214,26 @@ export default {
},
showDialogAdd() {
this.showDialog({
title: 'New Bookmark',
content: 'Create a new bookmark',
title: "New Bookmark",
content: "Create a new bookmark",
fields: [{
name: 'url',
label: 'Url, start with http://...',
name: "url",
label: "Url, start with http://...",
}, {
name: 'title',
label: 'Custom title (optional)'
name: "title",
label: "Custom title (optional)"
}, {
name: 'excerpt',
label: 'Custom excerpt (optional)',
type: 'area'
name: "excerpt",
label: "Custom excerpt (optional)",
type: "area"
}, {
name: 'tags',
label: 'Comma separated tags (optional)',
separator: ',',
name: "tags",
label: "Comma separated tags (optional)",
separator: ",",
dictionary: this.tags.map(tag => tag.name)
}],
mainText: 'OK',
secondText: 'Cancel',
mainText: "OK",
secondText: "Cancel",
mainClick: (data) => {
// Make sure URL is not empty
if (data.url.trim() === "") {
@ -243,12 +244,12 @@ export default {
// Prepare tags
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/\s+/g, " ")
.split(/\s*,\s*/g)
.filter(tag => tag !== '')
.filter(tag => tag.trim() !== "")
.map(tag => {
return {
name: tag
name: tag.trim()
};
});
@ -317,8 +318,8 @@ export default {
this.showDialog({
title: title,
content: content,
mainText: 'Yes',
secondText: 'No',
mainText: "Yes",
secondText: "No",
mainClick: () => {
this.dialog.loading = true;
fetch("/api/bookmarks", {
@ -350,6 +351,89 @@ export default {
}
});
},
showDialogEdit(item) {
// Check the item
if (typeof item !== "object") return;
var id = (typeof item.id === "number") ? item.id : 0,
index = (typeof item.index === "number") ? item.index : -1;
if (id < 1 || index < 0) return;
// Get the existing bookmark value
var book = JSON.parse(JSON.stringify(this.bookmarks[index])),
strTags = book.tags.map(tag => tag.name).join(", ");
this.showDialog({
title: "Edit Bookmark",
content: "Edit the bookmark's data",
showLabel: true,
fields: [{
name: "title",
label: "Title",
value: book.title,
}, {
name: "excerpt",
label: "Excerpt",
type: "area",
value: book.excerpt,
}, {
name: "tags",
label: "Tags",
value: strTags,
separator: ",",
dictionary: this.tags.map(tag => tag.name)
}],
mainText: "OK",
secondText: "Cancel",
mainClick: (data) => {
// Validate input
if (data.title.trim() === "") return;
// Prepare tags
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, " ")
.split(/\s*,\s*/g)
.filter(tag => tag.trim() !== "")
.map(tag => {
return {
name: tag.trim()
};
});
// Set new data
book.title = data.title.trim();
book.excerpt = data.excerpt.trim();
book.tags = tags;
// Send data
this.dialog.loading = true;
fetch("/api/bookmarks", {
method: "put",
body: JSON.stringify(book),
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(index, 1, json);
})
.catch(err => {
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
});
}
});
},
},
mounted() {
var stateWatcher = (e) => {

View file

@ -53,8 +53,17 @@
align-items: baseline;
grid-gap: 16px;
&::after {
content: "";
display: block;
min-height: 1px;
grid-column-end: -1;
grid-column-start: 1;
}
.custom-dialog-content {
grid-column: 1 / span 2;
grid-column-end: -1;
grid-column-start: 1;
color: var(--color);
align-self: baseline;
}
@ -70,6 +79,7 @@
}
>textarea {
height: 6em;
min-height: 37px;
resize: vertical;
}

File diff suppressed because one or more lines are too long

View file

@ -136,7 +136,7 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
Keyword: keyword,
Limit: 30,
Offset: (page - 1) * 30,
OrderLatest: true,
OrderMethod: database.ByLastAdded,
}
// Calculate max page
@ -303,3 +303,69 @@ func (h *handler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps h
fmt.Fprint(w, 1)
}
// apiUpdateBookmark is handler for PUT /api/bookmarks
func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := model.Bookmark{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Validate input
if request.Title == "" {
panic(fmt.Errorf("Title must not empty"))
}
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: []int{request.ID},
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(filter)
checkError(err)
if len(bookmarks) == 0 {
panic(fmt.Errorf("no bookmark with matching index"))
}
// Set new bookmark data
book := bookmarks[0]
book.Title = request.Title
book.Excerpt = request.Excerpt
// Set new tags
for i := range book.Tags {
book.Tags[i].Deleted = true
}
for _, newTag := range request.Tags {
for i, oldTag := range book.Tags {
if newTag.Name == oldTag.Name {
newTag.ID = oldTag.ID
book.Tags[i].Deleted = false
break
}
}
if newTag.ID == 0 {
book.Tags = append(book.Tags, newTag)
}
}
// Update database
res, err := h.DB.SaveBookmarks(book)
checkError(err)
// Add thumbnail image to the saved bookmarks again
newBook := res[0]
newBook.ImageURL = request.ImageURL
// Return new saved result
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&newBook)
checkError(err)
}

View file

@ -41,8 +41,8 @@ func ServeApp(DB database.DB, dataDir string, port int) error {
router.GET("/api/tags", hdl.apiGetTags)
router.POST("/api/bookmarks", hdl.apiInsertBookmark)
router.DELETE("/api/bookmarks", hdl.apiDeleteBookmark)
router.PUT("/api/bookmarks", hdl.apiUpdateBookmark)
// router.PUT("/api/cache", hdl.apiUpdateCache)
// router.PUT("/api/bookmarks", hdl.apiUpdateBookmark)
// router.PUT("/api/bookmarks/tags", hdl.apiUpdateBookmarkTags)
// Route for panic