mirror of
https://github.com/go-shiori/shiori.git
synced 2025-09-07 13:35:22 +08:00
Implement edit bookmark in web interface
This commit is contained in:
parent
7ab6c533a3
commit
3b8dead04d
10 changed files with 242 additions and 50 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}
|
|
@ -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);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue