diff options
28 files changed, 550 insertions, 49 deletions
diff --git a/integration/pinboard/pinboard.go b/integration/pinboard/pinboard.go new file mode 100644 index 0000000..4179483 --- /dev/null +++ b/integration/pinboard/pinboard.go @@ -0,0 +1,45 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package pinboard + +import ( + "fmt" + "net/url" + + "github.com/miniflux/miniflux2/reader/http" +) + +// Client represents a Pinboard token. +type Client struct { + authToken string +} + +// AddBookmark sends a link to Pinboard. +func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error { + toRead := "no" + if markAsUnread { + toRead = "yes" + } + + values := url.Values{} + values.Add("auth_token", c.authToken) + values.Add("url", link) + values.Add("description", title) + values.Add("tags", tags) + values.Add("toread", toRead) + + client := http.NewClient("https://api.pinboard.in/v1/posts/add?" + values.Encode()) + response, err := client.Get() + if response.HasServerFailure() { + return fmt.Errorf("unable to send bookmark to pinboard, status=%d", response.StatusCode) + } + + return err +} + +// NewClient returns a new Pinboard client. +func NewClient(authToken string) *Client { + return &Client{authToken: authToken} +} diff --git a/locale/translations.go b/locale/translations.go index 9553018..49c241c 100644 --- a/locale/translations.go +++ b/locale/translations.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.287568844 -0800 PST m=+0.033078160 +// 2017-12-02 19:31:37.042505019 -0800 PST m=+0.027446145 package locale @@ -153,12 +153,19 @@ var translations = map[string]string{ "Invalid theme.": "Le thème est invalide.", "Entry Sorting": "Ordre des éléments", "Older entries first": "Ancien éléments en premier", - "Recent entries first": "Éléments récents en premier" + "Recent entries first": "Éléments récents en premier", + "Saving...": "Enregistrement...", + "Done!": "Terminé !", + "Save this article": "Sauvegarder cet article", + "Mark bookmark as unread": "Marquer le lien comme non lu", + "Pinboard Tags": "Libellés de Pinboard", + "Pinboard API Token": "Jeton de sécurité de l'API de Pinboard", + "Enable Pinboard": "Activer Pinboard" } `, } var translationsChecksums = map[string]string{ "en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897", - "fr_FR": "5c054c06fa687f05fd4f6041b002207fe1fe304d6c0c0d094b8caa61a5071ba5", + "fr_FR": "fce31dfc3b8d45ee1c5d0c7aca4449553a8228a2428491e5cf5cf9e507dddb31", } diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index 08f0729..c349a99 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -137,5 +137,12 @@ "Invalid theme.": "Le thème est invalide.", "Entry Sorting": "Ordre des éléments", "Older entries first": "Ancien éléments en premier", - "Recent entries first": "Éléments récents en premier" + "Recent entries first": "Éléments récents en premier", + "Saving...": "Enregistrement...", + "Done!": "Terminé !", + "Save this article": "Sauvegarder cet article", + "Mark bookmark as unread": "Marquer le lien comme non lu", + "Pinboard Tags": "Libellés de Pinboard", + "Pinboard API Token": "Jeton de sécurité de l'API de Pinboard", + "Enable Pinboard": "Activer Pinboard" } diff --git a/model/integration.go b/model/integration.go new file mode 100644 index 0000000..91a4595 --- /dev/null +++ b/model/integration.go @@ -0,0 +1,14 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package model + +// Integration represents user integration settings. +type Integration struct { + UserID int64 + PinboardEnabled bool + PinboardToken string + PinboardTags string + PinboardMarkAsUnread bool +} diff --git a/reader/http/client.go b/reader/http/client.go index de0558c..3b4488f 100644 --- a/reader/http/client.go +++ b/reader/http/client.go @@ -23,22 +23,17 @@ type Client struct { url string etagHeader string lastModifiedHeader string + username string + password string Insecure bool } // Get execute a GET HTTP request. -func (h *Client) Get() (*Response, error) { - defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient:Get] url=%s", h.url)) - u, _ := url.Parse(h.url) +func (c *Client) Get() (*Response, error) { + defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient:Get] url=%s", c.url)) - req := &http.Request{ - URL: u, - Method: http.MethodGet, - Header: h.buildHeaders(), - } - - client := h.buildClient() - resp, err := client.Do(req) + client := c.buildClient() + resp, err := client.Do(c.buildRequest()) if err != nil { return nil, err } @@ -53,7 +48,7 @@ func (h *Client) Get() (*Response, error) { } log.Println("[HttpClient:Get]", - "OriginalURL:", h.url, + "OriginalURL:", c.url, "StatusCode:", response.StatusCode, "ETag:", response.ETag, "LastModified:", response.LastModified, @@ -63,9 +58,24 @@ func (h *Client) Get() (*Response, error) { return response, err } -func (h *Client) buildClient() http.Client { +func (c *Client) buildRequest() *http.Request { + link, _ := url.Parse(c.url) + request := &http.Request{ + URL: link, + Method: http.MethodGet, + Header: c.buildHeaders(), + } + + if c.username != "" && c.password != "" { + request.SetBasicAuth(c.username, c.password) + } + + return request +} + +func (c *Client) buildClient() http.Client { client := http.Client{Timeout: time.Duration(requestTimeout * time.Second)} - if h.Insecure { + if c.Insecure { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } @@ -74,16 +84,16 @@ func (h *Client) buildClient() http.Client { return client } -func (h *Client) buildHeaders() http.Header { +func (c *Client) buildHeaders() http.Header { headers := make(http.Header) headers.Add("User-Agent", userAgent) - if h.etagHeader != "" { - headers.Add("If-None-Match", h.etagHeader) + if c.etagHeader != "" { + headers.Add("If-None-Match", c.etagHeader) } - if h.lastModifiedHeader != "" { - headers.Add("If-Modified-Since", h.lastModifiedHeader) + if c.lastModifiedHeader != "" { + headers.Add("If-Modified-Since", c.lastModifiedHeader) } return headers @@ -94,6 +104,11 @@ func NewClient(url string) *Client { return &Client{url: url, Insecure: false} } +// NewClientWithCredentials returns a new HTTP client that require authentication. +func NewClientWithCredentials(url, username, password string) *Client { + return &Client{url: url, Insecure: false, username: username, password: password} +} + // NewClientWithCacheHeaders returns a new HTTP client that send cache headers. func NewClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *Client { return &Client{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false} diff --git a/server/routes.go b/server/routes.go index 903f24c..5cb1e99 100644 --- a/server/routes.go +++ b/server/routes.go @@ -91,6 +91,7 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET") router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST") + router.Handle("/entry/save/{entryID}", uiHandler.Use(uiController.SaveEntry)).Name("saveEntry").Methods("POST") router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET") router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET") @@ -117,6 +118,7 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han router.Handle("/bookmarklet", uiHandler.Use(uiController.Bookmarklet)).Name("bookmarklet").Methods("GET") router.Handle("/integrations", uiHandler.Use(uiController.ShowIntegrations)).Name("integrations").Methods("GET") + router.Handle("/integration", uiHandler.Use(uiController.UpdateIntegration)).Name("updateIntegration").Methods("POST") router.Handle("/sessions", uiHandler.Use(uiController.ShowSessions)).Name("sessions").Methods("GET") router.Handle("/sessions/{sessionID}/remove", uiHandler.Use(uiController.RemoveSession)).Name("removeSession").Methods("POST") diff --git a/server/static/bin.go b/server/static/bin.go index 21cd59f..ab92ae3 100644 --- a/server/static/bin.go +++ b/server/static/bin.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.261744369 -0800 PST m=+0.007253685 +// 2017-12-02 19:31:37.021832102 -0800 PST m=+0.006773228 package static diff --git a/server/static/css.go b/server/static/css.go index d30f275..243ba82 100644 --- a/server/static/css.go +++ b/server/static/css.go @@ -1,14 +1,14 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.265273764 -0800 PST m=+0.010783080 +// 2017-12-02 19:31:37.024320147 -0800 PST m=+0.009261273 package static var Stylesheets = map[string]string{ "black": `body{background:#222;color:#efefef}h1,h2,h3{color:#aaa}a{color:#aaa}a:focus,a:hover{color:#ddd}.header li{border-color:#333}.header a{color:#ddd;font-weight:400}.header .active a{font-weight:400;color:#9b9494}.header a:focus,.header a:hover{color:rgba(82,168,236,.85)}.page-header h1{border-color:#333}.logo a:hover span{color:#555}table,th,td{border:1px solid #555}th{background:#333;color:#aaa;font-weight:400}tr:hover{background-color:#333;color:#aaa}input[type=url],input[type=password],input[type=text]{border:1px solid #555;background:#333;color:#ccc}input[type=url]:focus,input[type=password]:focus,input[type=text]:focus{color:#efefef;border-color:rgba(82,168,236,.8);box-shadow:0 0 8px rgba(82,168,236,.6)}.button-primary{border-color:#444;background:#333;color:#efefef}.button-primary:hover,.button-primary:focus{border-color:#888;background:#555}.alert,.alert-success,.alert-error,.alert-info,.alert-normal{color:#efefef;background-color:#333;border-color:#444}.panel{background:#333;border-color:#555}.unread-counter{color:#bbb}.category{color:#efefef;background-color:#333;border-color:#444}.category a{color:#999}.category a:hover,.category a:focus{color:#aaa}.pagination a{color:#aaa}.pagination-bottom{border-color:#333}.item{border-color:#666;padding:4px}.item.current-item{border-width:2px;border-color:rgba(82,168,236,.8);box-shadow:0 0 8px rgba(82,168,236,.6)}.item-title a{font-weight:400}.item-status-read .item-title a{color:#666}.item-status-read .item-title a:focus,.item-status-read .item-title a:hover{color:rgba(82,168,236,.6)}.item-meta a:hover,.item-meta a:focus{color:#aaa}.item-meta li:after{color:#ddd}.entry header{border-color:#333}.entry header h1 a{color:#bbb}.entry-content,.entry-content p,ul{color:#999}.entry-content pre,.entry-content code{color:#fff;background:#555;border-color:#888}.entry-enclosure{border-color:#333}`, - "common": `*{margin:0;padding:0;box-sizing:border-box}body{font-family:helvetica neue,Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}main{padding-left:5px;padding-right:5px}a{color:#36c}a:focus{outline:0;color:red;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}.header{margin-top:10px;margin-bottom:20px}.header nav ul{display:none}.header li{cursor:pointer;padding-left:10px;line-height:2.1em;font-size:1.2em;border-bottom:1px dotted #ddd}.header li:hover a{color:#888}.header a{font-size:.9em;color:#444;text-decoration:none;border:0}.header .active a{font-weight:600}.header a:hover,.header a:focus{color:#888}.page-header{margin-bottom:25px}.page-header h1{font-weight:500;border-bottom:1px dotted #ddd}.page-header ul{margin-left:25px}.page-header li{list-style-type:circle;line-height:1.8em}.logo{cursor:pointer;text-align:center}.logo a{color:#000;letter-spacing:1px}.logo a:hover{color:#396}.logo a span{color:#396}.logo a:hover span{color:#000}@media(min-width:600px){body{margin:auto;max-width:750px}.logo{text-align:left;float:left;margin-right:15px}.header nav ul{display:block}.header li{display:inline;padding:0;padding-right:15px;line-height:normal;border:0;font-size:1em}.page-header ul{margin-left:0}.page-header li{display:inline;padding-right:15px}}table{width:100%;border-collapse:collapse}table,th,td{border:1px solid #ddd}th,td{padding:5px;text-align:left}td{vertical-align:top}th{background:#fcfcfc}tr:hover{background-color:#f9f9f9}.column-40{width:40%}.column-25{width:25%}.column-20{width:20%}label{cursor:pointer;display:block}.radio-group{line-height:1.9em}div.radio-group label{display:inline-block}select{margin-bottom:15px}input[type=url],input[type=password],input[type=text]{border:1px solid #ccc;padding:3px;line-height:20px;width:250px;font-size:99%;margin-bottom:10px;margin-top:5px;-webkit-appearance:none}input[type=url]:focus,input[type=password]:focus,input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}::-moz-placeholder,::-ms-input-placeholder,::-webkit-input-placeholder{color:#ddd;padding-top:2px}.form-help{font-size:.9em;color:brown;margin-bottom:15px}a.button{text-decoration:none}.button{display:inline-block;-webkit-appearance:none;-moz-appearance:none;font-size:1.1em;cursor:pointer;padding:3px 10px;border:1px solid;border-radius:unset}.button-primary{border-color:#3079ed;background:#4d90fe;color:#fff}.button-primary:hover,.button-primary:focus{border-color:#2f5bb7;background:#357ae8}.button-danger{border-color:#b0281a;background:#d14836;color:#fff}.button-danger:hover,.button-danger:focus{color:#fff;background:#c53727}.button:disabled{color:#ccc;background:#f7f7f7;border-color:#ccc}.buttons{margin-top:10px;margin-bottom:20px}.alert{padding:8px 35px 8px 14px;margin-bottom:20px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px;overflow:auto}.alert h3{margin-top:0;margin-bottom:15px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-error a{color:#b94a48}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.panel{color:#333;background-color:#f0f0f0;border:1px solid #ddd;border-radius:5px;padding:10px;margin-bottom:15px}.panel h3{font-weight:500;margin-top:0;margin-bottom:20px}.panel ul{margin-left:30px}.login-form{margin:50px auto 0;max-width:280px}.unread-counter{font-size:.8em;font-weight:300;color:#666}.category{font-size:.75em;background-color:#fffcd7;border:1px solid #d5d458;border-radius:5px;margin-left:.25em;padding:1px .4em;white-space:nowrap}.category a{color:#555;text-decoration:none}.category a:hover,.category a:focus{color:#000}.pagination{font-size:1.1em;display:flex;align-items:center;padding-top:8px}.pagination-bottom{border-top:1px dotted #ddd;margin-bottom:15px;margin-top:50px}.pagination>div{flex:1}.pagination-next{text-align:right}.pagination-prev:before{content:"« "}.pagination-next:after{content:" »"}.pagination a{color:#333}.pagination a:hover,.pagination a:focus{text-decoration:none}.item{border:1px dotted #ddd;margin-bottom:20px;padding:5px;overflow:hidden}.item.current-item{border:3px solid #bce;padding:3px}.item-title a{text-decoration:none;font-weight:600}.item-status-read .item-title a{color:#777}.item-meta{color:#777;font-size:.8em}.item-meta a{color:#777;text-decoration:none}.item-meta a:hover,.item-meta a:focus{color:#333}.item-meta ul{margin-top:5px}.item-meta li{display:inline}.item-meta li:after{content:"|";color:#aaa}.item-meta li:last-child:after{content:""}.hide-read-items .item-status-read{display:none}.entry header{padding-bottom:5px;border-bottom:1px dotted #ddd}.entry header h1{font-size:2em;line-height:1.25em;margin:30px 0}.entry header h1 a{text-decoration:none;color:#333}.entry header h1 a:hover,.entry header h1 a:focus{color:#666}.entry-meta{font-size:.95em;margin:0 0 20px;color:#666;overflow-wrap:break-word}.entry-website img{vertical-align:top}.entry-website a{color:#666;vertical-align:top;text-decoration:none}.entry-website a:hover,.entry-website a:focus{text-decoration:underline}.entry-date{font-size:.65em;font-style:italic;color:#555}.entry-content{padding-top:15px;font-size:1.2em;font-weight:300;font-family:Georgia,times new roman,Times,serif;color:#555;line-height:1.4em;overflow-wrap:break-word}.entry-content h1,h2,h3,h4,h5,h6{margin-top:15px;margin-bottom:10px}.entry-content iframe,.entry-content video,.entry-content img{max-width:100%}.entry-content figure img{border:1px solid #000}.entry-content figcaption{font-size:.75em;text-transform:uppercase;color:#777}.entry-content p{margin-top:10px;margin-bottom:15px}.entry-content a{overflow-wrap:break-word}.entry-content a:visited{color:purple}.entry-content dt{font-weight:500;margin-top:15px;color:#555}.entry-content dd{margin-left:15px;margin-top:5px;padding-left:20px;border-left:3px solid #ddd;color:#777;font-weight:300;line-height:1.4em}.entry-content blockquote{border-left:4px solid #ddd;padding-left:25px;margin-left:20px;margin-top:20px;margin-bottom:20px;color:#888;line-height:1.4em;font-family:Georgia,serif}.entry-content blockquote+p{color:#555;font-style:italic;font-weight:200}.entry-content q{color:purple;font-family:Georgia,serif;font-style:italic}.entry-content q:before{content:"“"}.entry-content q:after{content:"”"}.entry-content pre{padding:5px;background:#f0f0f0;border:1px solid #ddd;overflow:scroll;overflow-wrap:initial}.entry-content table{table-layout:fixed;max-width:100%}.entry-content ul,.entry-content ol{margin-left:30px}.entry-content ul{list-style-type:square}.entry-enclosures h3{font-weight:500}.entry-enclosure{border:1px dotted #ddd;padding:5px;margin-top:10px;max-width:100%}.entry-enclosure-download{font-size:.85em}.enclosure-video video,.enclosure-image img{max-width:100%}.confirm{font-weight:500;color:#ed2d04}.confirm a{color:#ed2d04}.loading{font-style:italic}.bookmarklet{border:1px dashed #ccc;border-radius:5px;padding:15px;margin:15px;text-align:center}.bookmarklet a{font-weight:600;text-decoration:none;font-size:1.2em}`, + "common": `*{margin:0;padding:0;box-sizing:border-box}body{font-family:helvetica neue,Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}main{padding-left:5px;padding-right:5px}a{color:#36c}a:focus{outline:0;color:red;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}.header{margin-top:10px;margin-bottom:20px}.header nav ul{display:none}.header li{cursor:pointer;padding-left:10px;line-height:2.1em;font-size:1.2em;border-bottom:1px dotted #ddd}.header li:hover a{color:#888}.header a{font-size:.9em;color:#444;text-decoration:none;border:0}.header .active a{font-weight:600}.header a:hover,.header a:focus{color:#888}.page-header{margin-bottom:25px}.page-header h1{font-weight:500;border-bottom:1px dotted #ddd}.page-header ul{margin-left:25px}.page-header li{list-style-type:circle;line-height:1.8em}.logo{cursor:pointer;text-align:center}.logo a{color:#000;letter-spacing:1px}.logo a:hover{color:#396}.logo a span{color:#396}.logo a:hover span{color:#000}@media(min-width:600px){body{margin:auto;max-width:750px}.logo{text-align:left;float:left;margin-right:15px}.header nav ul{display:block}.header li{display:inline;padding:0;padding-right:15px;line-height:normal;border:0;font-size:1em}.page-header ul{margin-left:0}.page-header li{display:inline;padding-right:15px}}table{width:100%;border-collapse:collapse}table,th,td{border:1px solid #ddd}th,td{padding:5px;text-align:left}td{vertical-align:top}th{background:#fcfcfc}tr:hover{background-color:#f9f9f9}.column-40{width:40%}.column-25{width:25%}.column-20{width:20%}label{cursor:pointer;display:block}.radio-group{line-height:1.9em}div.radio-group label{display:inline-block}select{margin-bottom:15px}input[type=url],input[type=password],input[type=text]{border:1px solid #ccc;padding:3px;line-height:20px;width:250px;font-size:99%;margin-bottom:10px;margin-top:5px;-webkit-appearance:none}input[type=url]:focus,input[type=password]:focus,input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input[type=checkbox]{margin-bottom:10px}::-moz-placeholder,::-ms-input-placeholder,::-webkit-input-placeholder{color:#ddd;padding-top:2px}.form-help{font-size:.9em;color:brown;margin-bottom:15px}a.button{text-decoration:none}.button{display:inline-block;-webkit-appearance:none;-moz-appearance:none;font-size:1.1em;cursor:pointer;padding:3px 10px;border:1px solid;border-radius:unset}.button-primary{border-color:#3079ed;background:#4d90fe;color:#fff}.button-primary:hover,.button-primary:focus{border-color:#2f5bb7;background:#357ae8}.button-danger{border-color:#b0281a;background:#d14836;color:#fff}.button-danger:hover,.button-danger:focus{color:#fff;background:#c53727}.button:disabled{color:#ccc;background:#f7f7f7;border-color:#ccc}.buttons{margin-top:10px;margin-bottom:20px}.alert{padding:8px 35px 8px 14px;margin-bottom:20px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px;overflow:auto}.alert h3{margin-top:0;margin-bottom:15px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-error a{color:#b94a48}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.panel{color:#333;background-color:#fcfcfc;border:1px solid #ddd;border-radius:5px;padding:10px;margin-bottom:15px}.panel h3{font-weight:500;margin-top:0;margin-bottom:20px}.panel ul{margin-left:30px}.login-form{margin:50px auto 0;max-width:280px}.unread-counter{font-size:.8em;font-weight:300;color:#666}.category{font-size:.75em;background-color:#fffcd7;border:1px solid #d5d458;border-radius:5px;margin-left:.25em;padding:1px .4em;white-space:nowrap}.category a{color:#555;text-decoration:none}.category a:hover,.category a:focus{color:#000}.pagination{font-size:1.1em;display:flex;align-items:center;padding-top:8px}.pagination-bottom{border-top:1px dotted #ddd;margin-bottom:15px;margin-top:50px}.pagination>div{flex:1}.pagination-next{text-align:right}.pagination-prev:before{content:"« "}.pagination-next:after{content:" »"}.pagination a{color:#333}.pagination a:hover,.pagination a:focus{text-decoration:none}.item{border:1px dotted #ddd;margin-bottom:20px;padding:5px;overflow:hidden}.item.current-item{border:3px solid #bce;padding:3px}.item-title a{text-decoration:none;font-weight:600}.item-status-read .item-title a{color:#777}.item-meta{color:#777;font-size:.8em}.item-meta a{color:#777;text-decoration:none}.item-meta a:hover,.item-meta a:focus{color:#333}.item-meta ul{margin-top:5px}.item-meta li{display:inline}.item-meta li:after{content:"|";color:#aaa}.item-meta li:last-child:after{content:""}.hide-read-items .item-status-read{display:none}.entry header{padding-bottom:5px;border-bottom:1px dotted #ddd}.entry header h1{font-size:2em;line-height:1.25em;margin:30px 0}.entry header h1 a{text-decoration:none;color:#333}.entry header h1 a:hover,.entry header h1 a:focus{color:#666}.entry-actions{margin-bottom:20px}.entry-meta{font-size:.95em;margin:0 0 20px;color:#666;overflow-wrap:break-word}.entry-website img{vertical-align:top}.entry-website a{color:#666;vertical-align:top;text-decoration:none}.entry-website a:hover,.entry-website a:focus{text-decoration:underline}.entry-date{font-size:.65em;font-style:italic;color:#555}.entry-content{padding-top:15px;font-size:1.2em;font-weight:300;font-family:Georgia,times new roman,Times,serif;color:#555;line-height:1.4em;overflow-wrap:break-word}.entry-content h1,h2,h3,h4,h5,h6{margin-top:15px;margin-bottom:10px}.entry-content iframe,.entry-content video,.entry-content img{max-width:100%}.entry-content figure img{border:1px solid #000}.entry-content figcaption{font-size:.75em;text-transform:uppercase;color:#777}.entry-content p{margin-top:10px;margin-bottom:15px}.entry-content a{overflow-wrap:break-word}.entry-content a:visited{color:purple}.entry-content dt{font-weight:500;margin-top:15px;color:#555}.entry-content dd{margin-left:15px;margin-top:5px;padding-left:20px;border-left:3px solid #ddd;color:#777;font-weight:300;line-height:1.4em}.entry-content blockquote{border-left:4px solid #ddd;padding-left:25px;margin-left:20px;margin-top:20px;margin-bottom:20px;color:#888;line-height:1.4em;font-family:Georgia,serif}.entry-content blockquote+p{color:#555;font-style:italic;font-weight:200}.entry-content q{color:purple;font-family:Georgia,serif;font-style:italic}.entry-content q:before{content:"“"}.entry-content q:after{content:"”"}.entry-content pre{padding:5px;background:#f0f0f0;border:1px solid #ddd;overflow:scroll;overflow-wrap:initial}.entry-content table{table-layout:fixed;max-width:100%}.entry-content ul,.entry-content ol{margin-left:30px}.entry-content ul{list-style-type:square}.entry-enclosures h3{font-weight:500}.entry-enclosure{border:1px dotted #ddd;padding:5px;margin-top:10px;max-width:100%}.entry-enclosure-download{font-size:.85em}.enclosure-video video,.enclosure-image img{max-width:100%}.confirm{font-weight:500;color:#ed2d04}.confirm a{color:#ed2d04}.loading{font-style:italic}.bookmarklet{border:1px dashed #ccc;border-radius:5px;padding:15px;margin:15px;text-align:center}.bookmarklet a{font-weight:600;text-decoration:none;font-size:1.2em}`, } var StylesheetsChecksums = map[string]string{ "black": "38e7fee92187a036ce37f3c15fde2deff59a55c5ab693c7b8578af79d6a117d2", - "common": "921e622bf833cb8a843766b4bf71263e4cf8cc4a0c8678692b363a2d3b44f465", + "common": "af622cd4416dd93c3192a00c0123a37bd1880ef19d0e9f519e55c86533b10043", } diff --git a/server/static/css/common.css b/server/static/css/common.css index 411ab8f..b1b523d 100644 --- a/server/static/css/common.css +++ b/server/static/css/common.css @@ -222,6 +222,10 @@ input[type="text"]:focus { box-shadow: 0 0 8px rgba(82, 168, 236, 0.6); } +input[type="checkbox"] { + margin-bottom: 10px; +} + ::-moz-placeholder, ::-ms-input-placeholder, ::-webkit-input-placeholder { @@ -327,7 +331,7 @@ a.button { /* Panel */ .panel { color: #333; - background-color: #f0f0f0; + background-color: #fcfcfc; border: 1px solid #ddd; border-radius: 5px; padding: 10px; @@ -497,6 +501,10 @@ a.button { color: #666; } +.entry-actions { + margin-bottom: 20px; +} + .entry-meta { font-size: 0.95em; margin: 0 0 20px; diff --git a/server/static/js.go b/server/static/js.go index a0c535a..a5a795f 100644 --- a/server/static/js.go +++ b/server/static/js.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.268772139 -0800 PST m=+0.014281455 +// 2017-12-02 19:31:37.026479459 -0800 PST m=+0.011420585 package static @@ -42,13 +42,16 @@ getCsrfToken(){let element=document.querySelector("meta[name=X-CSRF-Token]");if( return "";} execute(){fetch(new Request(this.url,this.options)).then((response)=>{if(this.callback){this.callback(response);}});}} class EntryHandler{static updateEntriesStatus(entryIDs,status){let url=document.body.dataset.entriesStatusUrl;let request=new RequestBuilder(url);request.withBody({entry_ids:entryIDs,status:status});request.execute();} -static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);break;}}}} +static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);break;}}} +static saveEntry(element){if(element.dataset.completed){return;} +element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.saveUrl);request.withCallback(()=>{element.innerHTML=element.dataset.labelDone;element.dataset.completed=true;});request.execute();}} class ConfirmHandler{remove(url){let request=new RequestBuilder(url);request.withCallback(()=>window.location.reload());request.execute();} handle(event){let questionElement=document.createElement("span");let linkElement=event.target;let containerElement=linkElement.parentNode;linkElement.style.display="none";let yesElement=document.createElement("a");yesElement.href="#";yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));yesElement.onclick=(event)=>{event.preventDefault();let loadingElement=document.createElement("span");loadingElement.className="loading";loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));questionElement.remove();containerElement.appendChild(loadingElement);this.remove(linkElement.dataset.url);};let noElement=document.createElement("a");noElement.href="#";noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));noElement.onclick=(event)=>{event.preventDefault();linkElement.style.display="inline";questionElement.remove();};questionElement.className="confirm";questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion+" "));questionElement.appendChild(yesElement);questionElement.appendChild(document.createTextNode(", "));questionElement.appendChild(noElement);containerElement.appendChild(questionElement);}} class MenuHandler{clickMenuListItem(event){let element=event.target;if(element.tagName==="A"){window.location.href=element.getAttribute("href");}else{window.location.href=element.querySelector("a").getAttribute("href");}} toggleMainMenu(){let menu=document.querySelector(".header nav ul");if(DomHelper.isVisible(menu)){menu.style.display="none";}else{menu.style.display="block";}}} class NavHandler{markPageAsRead(){let items=DomHelper.getVisibleElements(".items .item");let entryIDs=[];items.forEach((element)=>{element.classList.add("item-status-read");entryIDs.push(parseInt(element.dataset.id,10));});if(entryIDs.length>0){EntryHandler.updateEntriesStatus(entryIDs,"read");} this.goToPage("next");} +saveEntry(){if(this.isListView()){let currentItem=document.querySelector(".current-item");if(currentItem!==null){let saveLink=currentItem.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}}else{let saveLink=document.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}} toggleEntryStatus(){let currentItem=document.querySelector(".current-item");if(currentItem!==null){EntryHandler.toggleEntryStatus(currentItem);this.goToNextListItem();}} openOriginalLink(){let entryLink=document.querySelector(".entry h1 a");if(entryLink!==null){DomHelper.openNewTab(entryLink.getAttribute("href"));return;} let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));}} @@ -65,9 +68,9 @@ if(document.querySelector(".current-item")===null){items[0].classList.add("curre for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i+1<items.length){items[i+1].classList.add("current-item");DomHelper.scrollPageTo(items[i+1]);} break;}}} isListView(){return document.querySelector(".items")!==null;}} -document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();let navHandler=new NavHandler();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>navHandler.goToPage("unread"));keyboardHandler.on("g h",()=>navHandler.goToPage("history"));keyboardHandler.on("g f",()=>navHandler.goToPage("feeds"));keyboardHandler.on("g c",()=>navHandler.goToPage("categories"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`, +document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();let navHandler=new NavHandler();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>navHandler.goToPage("unread"));keyboardHandler.on("g h",()=>navHandler.goToPage("history"));keyboardHandler.on("g f",()=>navHandler.goToPage("feeds"));keyboardHandler.on("g c",()=>navHandler.goToPage("categories"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.on("s",()=>navHandler.saveEntry());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-save-entry]",(event)=>{event.preventDefault();EntryHandler.saveEntry(event.target);});mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`, } var JavascriptChecksums = map[string]string{ - "app": "e1a955eaf7c0672da771ddbb87090bd7831197c46c5508349aa87a955f7814f6", + "app": "ce9791d6752f8b09f6163907e5ba01092f76595d6f99d611858216d6533f4655", } diff --git a/server/static/js/app.js b/server/static/js/app.js index 40611ec..0fd65a5 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -298,6 +298,21 @@ class EntryHandler { } } } + + static saveEntry(element) { + if (element.dataset.completed) { + return; + } + + element.innerHTML = element.dataset.labelLoading; + + let request = new RequestBuilder(element.dataset.saveUrl); + request.withCallback(() => { + element.innerHTML = element.dataset.labelDone; + element.dataset.completed = true; + }); + request.execute(); + } } class ConfirmHandler { @@ -386,6 +401,23 @@ class NavHandler { this.goToPage("next"); } + saveEntry() { + if (this.isListView()) { + let currentItem = document.querySelector(".current-item"); + if (currentItem !== null) { + let saveLink = currentItem.querySelector("a[data-save-entry]"); + if (saveLink) { + EntryHandler.saveEntry(saveLink); + } + } + } else { + let saveLink = document.querySelector("a[data-save-entry]"); + if (saveLink) { + EntryHandler.saveEntry(saveLink); + } + } + } + toggleEntryStatus() { let currentItem = document.querySelector(".current-item"); if (currentItem !== null) { @@ -520,9 +552,14 @@ document.addEventListener("DOMContentLoaded", function() { keyboardHandler.on("v", () => navHandler.openOriginalLink()); keyboardHandler.on("m", () => navHandler.toggleEntryStatus()); keyboardHandler.on("A", () => navHandler.markPageAsRead()); + keyboardHandler.on("s", () => navHandler.saveEntry()); keyboardHandler.listen(); let mouseHandler = new MouseHandler(); + mouseHandler.onClick("a[data-save-entry]", (event) => { + event.preventDefault(); + EntryHandler.saveEntry(event.target); + }); mouseHandler.onClick("a[data-on-click=markPageAsRead]", () => navHandler.markPageAsRead()); mouseHandler.onClick("a[data-confirm]", (event) => { (new ConfirmHandler()).handle(event); diff --git a/server/template/common.go b/server/template/common.go index 461f8e5..e7fa6fc 100644 --- a/server/template/common.go +++ b/server/template/common.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.286110197 -0800 PST m=+0.031619513 +// 2017-12-02 19:31:37.041375649 -0800 PST m=+0.026316775 package template diff --git a/server/template/html/category_entries.html b/server/template/html/category_entries.html index 77d2725..d86b103 100644 --- a/server/template/html/category_entries.html +++ b/server/template/html/category_entries.html @@ -36,6 +36,15 @@ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> diff --git a/server/template/html/create_user.html b/server/template/html/create_user.html index 225dfaa..8faab49 100644 --- a/server/template/html/create_user.html +++ b/server/template/html/create_user.html @@ -35,7 +35,7 @@ <label for="form-confirmation">{{ t "Confirmation" }}</label> <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required> - <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label> + <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "Administrator" }}</label> <div class="buttons"> <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a> diff --git a/server/template/html/edit_user.html b/server/template/html/edit_user.html index dbbd852..6611943 100644 --- a/server/template/html/edit_user.html +++ b/server/template/html/edit_user.html @@ -38,7 +38,7 @@ <label for="form-confirmation">{{ t "Confirmation" }}</label> <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}"> - <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label> + <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "Administrator" }}</label> <div class="buttons"> <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a> diff --git a/server/template/html/entry.html b/server/template/html/entry.html index 7c190d3..06e0184 100644 --- a/server/template/html/entry.html +++ b/server/template/html/entry.html @@ -6,6 +6,15 @@ <h1> <a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a> </h1> + <div class="entry-actions"> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </div> <div class="entry-meta"> <span class="entry-website"> {{ if ne .entry.Feed.Icon.IconID 0 }} diff --git a/server/template/html/feed_entries.html b/server/template/html/feed_entries.html index 384b4f6..4f23e4e 100644 --- a/server/template/html/feed_entries.html +++ b/server/template/html/feed_entries.html @@ -47,6 +47,15 @@ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> diff --git a/server/template/html/history.html b/server/template/html/history.html index d53d512..00613c7 100644 --- a/server/template/html/history.html +++ b/server/template/html/history.html @@ -36,6 +36,15 @@ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> diff --git a/server/template/html/integrations.html b/server/template/html/integrations.html index 75ffbc9..8e3f872 100644 --- a/server/template/html/integrations.html +++ b/server/template/html/integrations.html @@ -21,6 +21,33 @@ </ul> </section> +<form method="post" autocomplete="off" action="{{ route "updateIntegration" }}"> + <input type="hidden" name="csrf" value="{{ .csrf }}"> + + {{ if .errorMessage }} + <div class="alert alert-error">{{ t .errorMessage }}</div> + {{ end }} + + <h3>Pinboard</h3> + <label> + <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }} + </label> + + <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label> + <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}"> + + <label for="form-pinboard-tags">{{ t "Pinboard Tags" }}</label> + <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}"> + + <label> + <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "Mark bookmark as unread" }} + </label> + + <div class="buttons"> + <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> + </div> +</form> + <div class="panel"> <h3>{{ t "Bookmarklet" }}</h3> <p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p> diff --git a/server/template/html/unread.html b/server/template/html/unread.html index 2fea1e1..a007197 100644 --- a/server/template/html/unread.html +++ b/server/template/html/unread.html @@ -36,6 +36,15 @@ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> diff --git a/server/template/views.go b/server/template/views.go index bc78075..2f36c00 100644 --- a/server/template/views.go +++ b/server/template/views.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.271430439 -0800 PST m=+0.016939755 +// 2017-12-02 19:31:37.027317932 -0800 PST m=+0.012259058 package template @@ -186,6 +186,15 @@ var templateViewsMap = map[string]string{ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> @@ -300,7 +309,7 @@ var templateViewsMap = map[string]string{ <label for="form-confirmation">{{ t "Confirmation" }}</label> <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required> - <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label> + <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "Administrator" }}</label> <div class="buttons"> <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a> @@ -440,7 +449,7 @@ var templateViewsMap = map[string]string{ <label for="form-confirmation">{{ t "Confirmation" }}</label> <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}"> - <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label> + <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "Administrator" }}</label> <div class="buttons"> <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a> @@ -456,6 +465,15 @@ var templateViewsMap = map[string]string{ <h1> <a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a> </h1> + <div class="entry-actions"> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </div> <div class="entry-meta"> <span class="entry-website"> {{ if ne .entry.Feed.Icon.IconID 0 }} @@ -573,6 +591,15 @@ var templateViewsMap = map[string]string{ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> @@ -698,6 +725,15 @@ var templateViewsMap = map[string]string{ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> @@ -768,6 +804,33 @@ var templateViewsMap = map[string]string{ </ul> </section> +<form method="post" autocomplete="off" action="{{ route "updateIntegration" }}"> + <input type="hidden" name="csrf" value="{{ .csrf }}"> + + {{ if .errorMessage }} + <div class="alert alert-error">{{ t .errorMessage }}</div> + {{ end }} + + <h3>Pinboard</h3> + <label> + <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }} + </label> + + <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label> + <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}"> + + <label for="form-pinboard-tags">{{ t "Pinboard Tags" }}</label> + <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}"> + + <label> + <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "Mark bookmark as unread" }} + </label> + + <div class="buttons"> + <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> + </div> +</form> + <div class="panel"> <h3>{{ t "Bookmarklet" }}</h3> <p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p> @@ -983,6 +1046,15 @@ var templateViewsMap = map[string]string{ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time> </li> <li> + <a href="#" + title="{{ t "Save this article" }}" + data-save-entry="true" + data-save-url="{{ route "saveEntry" "entryID" .ID }}" + data-label-loading="{{ t "Saving..." }}" + data-label-done="{{ t "Done!" }}" + >{{ t "Save" }}</a> + </li> + <li> <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> </li> </ul> @@ -1061,22 +1133,22 @@ var templateViewsMapChecksums = map[string]string{ "about": "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad", "add_subscription": "098ea9e492e18242bd414b22c4d8638006d113f728e5ae78c9186663f60ae3f1", "categories": "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11", - "category_entries": "23187062ffa6ba8a37ab8a3db02b08ca3d6013c1b57a2aa96b8f9f0b1f237389", + "category_entries": "951cdacf38fcaed5cdd63a00dc800e26039236b94b556a68e4409012b0095ece", "choose_subscription": "d37682743d8bbd84738a964e238103db2651f95fa340c6e285ffe2e12548d673", "create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275", - "create_user": "815dd31faaa6e9ba81a2a6664e5707aaf4153c392accd2b1f77cf1937035a881", + "create_user": "45e226df757126d5fe7c464e295e9a34f07952cfdb71e31e49839850d35af139", "edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5", "edit_feed": "c5bc4c22bf7e8348d880395250545595d21fb8c8e723fc5d7cca68e25d250884", - "edit_user": "c835d78f7cf36c11533db9cef253457a9003987d704070d59446cb2b0e84dcb9", - "entry": "12e863c777368185091008adc851ddc0327317849f1566f7803be2b5bd358fd7", - "feed_entries": "0d9818a09b92ee9a80287e34cd3a08598f71a7c03a2658893db1b8b731dafbc9", + "edit_user": "82d9749d76ddbd2352816d813c4b1f6d92f2222de678b4afe5821090246735c7", + "entry": "fc800833ef8a9875f8ec44e40d5e53aa7dae91b5f78f7f05dac3f627129e1984", + "feed_entries": "547c19eb36b20e350ce70ed045173b064cdcd6b114afb241c9f2dda9d88fcc27", "feeds": "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c", - "history": "bfad256437cfcc898dea9e7e9a2c331ea707f1729cdb559ea563a2cb8de6cc7d", + "history": "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56", "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f", - "integrations": "c485d6d9ed996635e55e73320610e6bcb01a41c1153e8e739ae2294b0b14b243", + "integrations": "e3cb653bf3d45fada18b64c53860fcae18a9a3f18162d42c56b290cd1aaa4e18", "login": "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f", "sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf", "settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9", - "unread": "3d8deab9119dc11f0d74a461e1ac89dc29931ba4645a043bb5b3eccba3cba5b8", + "unread": "745d9a1c70c7327aa0ae37328c2736ba6a5f6493db44ef7f12d4da241491b71f", "users": "44677e28bb5347799ed0020c90ec785aadec4b1454446d92411cfdaf6e32110b", } diff --git a/server/ui/controller/integrations.go b/server/ui/controller/integrations.go index 887f619..12a7964 100644 --- a/server/ui/controller/integrations.go +++ b/server/ui/controller/integrations.go @@ -4,10 +4,25 @@ package controller -import "github.com/miniflux/miniflux2/server/core" +import ( + "errors" + "log" + + "github.com/miniflux/miniflux2/integration/pinboard" + "github.com/miniflux/miniflux2/model" + "github.com/miniflux/miniflux2/server/core" + "github.com/miniflux/miniflux2/server/ui/form" +) // ShowIntegrations renders the page with all external integrations. func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request, response *core.Response) { + user := ctx.LoggedUser() + integration, err := c.store.Integration(user.ID) + if err != nil { + response.HTML().ServerError(err) + return + } + args, err := c.getCommonTemplateArgs(ctx) if err != nil { response.HTML().ServerError(err) @@ -16,5 +31,75 @@ func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request, response.HTML().Render("integrations", args.Merge(tplParams{ "menu": "settings", + "form": form.IntegrationForm{ + PinboardEnabled: integration.PinboardEnabled, + PinboardToken: integration.PinboardToken, + PinboardTags: integration.PinboardTags, + PinboardMarkAsUnread: integration.PinboardMarkAsUnread, + }, })) } + +// UpdateIntegration updates integration settings. +func (c *Controller) UpdateIntegration(ctx *core.Context, request *core.Request, response *core.Response) { + user := ctx.LoggedUser() + integration, err := c.store.Integration(user.ID) + if err != nil { + response.HTML().ServerError(err) + return + } + + integrationForm := form.NewIntegrationForm(request.Request()) + integrationForm.Merge(integration) + + err = c.store.UpdateIntegration(integration) + if err != nil { + response.HTML().ServerError(err) + return + } + + response.Redirect(ctx.Route("integrations")) +} + +// SaveEntry send the link to external services. +func (c *Controller) SaveEntry(ctx *core.Context, request *core.Request, response *core.Response) { + entryID, err := request.IntegerParam("entryID") + if err != nil { + response.HTML().BadRequest(err) + return + } + + user := ctx.LoggedUser() + builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone) + builder.WithEntryID(entryID) + builder.WithoutStatus(model.EntryStatusRemoved) + + entry, err := builder.GetEntry() + if err != nil { + response.JSON().ServerError(err) + return + } + + if entry == nil { + response.JSON().NotFound(errors.New("Entry not found")) + return + } + + integration, err := c.store.Integration(user.ID) + if err != nil { + response.JSON().ServerError(err) + return + } + + go func() { + if integration.PinboardEnabled { + client := pinboard.NewClient(integration.PinboardToken) + err := client.AddBookmark(entry.URL, entry.Title, integration.PinboardTags, integration.PinboardMarkAsUnread) + if err != nil { + log.Println("[Pinboard]", err) + } + } + }() + + response.JSON().Created(map[string]string{"message": "saved"}) +} diff --git a/server/ui/form/integration.go b/server/ui/form/integration.go new file mode 100644 index 0000000..566adae --- /dev/null +++ b/server/ui/form/integration.go @@ -0,0 +1,37 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package form + +import ( + "net/http" + + "github.com/miniflux/miniflux2/model" +) + +// IntegrationForm represents user integration settings form. +type IntegrationForm struct { + PinboardEnabled bool + PinboardToken string + PinboardTags string + PinboardMarkAsUnread bool +} + +// Merge copy form values to the model. +func (i IntegrationForm) Merge(integration *model.Integration) { + integration.PinboardEnabled = i.PinboardEnabled + integration.PinboardToken = i.PinboardToken + integration.PinboardTags = i.PinboardTags + integration.PinboardMarkAsUnread = i.PinboardMarkAsUnread +} + +// NewIntegrationForm returns a new AuthForm. +func NewIntegrationForm(r *http.Request) *IntegrationForm { + return &IntegrationForm{ + PinboardEnabled: r.FormValue("pinboard_enabled") == "1", + PinboardToken: r.FormValue("pinboard_token"), + PinboardTags: r.FormValue("pinboard_tags"), + PinboardMarkAsUnread: r.FormValue("pinboard_mark_as_unread") == "1", + } +} diff --git a/sql/schema_version_5.sql b/sql/schema_version_5.sql new file mode 100644 index 0000000..e4c597f --- /dev/null +++ b/sql/schema_version_5.sql @@ -0,0 +1,8 @@ +create table integrations ( + user_id int not null, + pinboard_enabled bool default 'f', + pinboard_token text default '', + pinboard_tags text default 'miniflux', + pinboard_mark_as_unread bool default 'f', + primary key(user_id) +) @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-02 16:12:47.256865279 -0800 PST m=+0.002374595 +// 2017-12-02 19:31:37.017577269 -0800 PST m=+0.002518395 package sql @@ -121,6 +121,15 @@ create index users_extra_idx on users using gin(extra); "schema_version_4": `create type entry_sorting_direction as enum('asc', 'desc'); alter table users add column entry_direction entry_sorting_direction default 'asc'; `, + "schema_version_5": `create table integrations ( + user_id int not null, + pinboard_enabled bool default 'f', + pinboard_token text default '', + pinboard_tags text default 'miniflux', + pinboard_mark_as_unread bool default 'f', + primary key(user_id) +) +`, } var SqlMapChecksums = map[string]string{ @@ -128,4 +137,5 @@ var SqlMapChecksums = map[string]string{ "schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4", "schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12", "schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9", + "schema_version_5": "69c0aff4d72f86a3f3c3cac33674a943d4f293dd88a8552706144814a85b5629", } diff --git a/storage/integration.go b/storage/integration.go new file mode 100644 index 0000000..d21b618 --- /dev/null +++ b/storage/integration.go @@ -0,0 +1,78 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package storage + +import ( + "database/sql" + "fmt" + + "github.com/miniflux/miniflux2/model" +) + +// Integration returns user integration settings. +func (s *Storage) Integration(userID int64) (*model.Integration, error) { + query := `SELECT + user_id, + pinboard_enabled, + pinboard_token, + pinboard_tags, + pinboard_mark_as_unread + FROM integrations + WHERE user_id=$1 + ` + var integration model.Integration + err := s.db.QueryRow(query, userID).Scan( + &integration.UserID, + &integration.PinboardEnabled, + &integration.PinboardToken, + &integration.PinboardTags, + &integration.PinboardMarkAsUnread, + ) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + return nil, fmt.Errorf("unable to fetch integration row: %v", err) + } + + return &integration, nil +} + +// UpdateIntegration saves user integration settings. +func (s *Storage) UpdateIntegration(integration *model.Integration) error { + query := ` + UPDATE integrations SET + pinboard_enabled=$1, + pinboard_token=$2, + pinboard_tags=$3, + pinboard_mark_as_unread=$4 + WHERE user_id=$5 + ` + _, err := s.db.Exec( + query, + integration.PinboardEnabled, + integration.PinboardToken, + integration.PinboardTags, + integration.PinboardMarkAsUnread, + integration.UserID, + ) + + if err != nil { + return fmt.Errorf("unable to update integration row: %v", err) + } + + return nil +} + +// CreateIntegration creates initial user integration settings. +func (s *Storage) CreateIntegration(userID int64) error { + query := `INSERT INTO integrations (user_id) VALUES ($1)` + _, err := s.db.Exec(query, userID) + if err != nil { + return fmt.Errorf("unable to create integration row: %v", err) + } + + return nil +} diff --git a/storage/migration.go b/storage/migration.go index 9cfde8b..76ad801 100644 --- a/storage/migration.go +++ b/storage/migration.go @@ -12,7 +12,7 @@ import ( "github.com/miniflux/miniflux2/sql" ) -const schemaVersion = 4 +const schemaVersion = 5 // Migrate run database migrations. func (s *Storage) Migrate() { diff --git a/storage/user.go b/storage/user.go index 7d296a9..689ed74 100644 --- a/storage/user.go +++ b/storage/user.go @@ -86,6 +86,7 @@ func (s *Storage) CreateUser(user *model.User) (err error) { } s.CreateCategory(&model.Category{Title: "All", UserID: user.ID}) + s.CreateIntegration(user.ID) return nil } |