├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── notes.go ├── notes_test.go ├── pinboard.go ├── pinboard_test.go ├── posts.go ├── posts_test.go ├── posts_unmarshal_test.go ├── tags.go ├── tags_test.go ├── user.go └── user_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Wally Jones 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Package pinboard 2 | 3 | [![GoDoc](https://godoc.org/github.com/imwally/pinboard?status.svg)](https://godoc.org/github.com/imwally/pinboard) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/imwally/pinboard)](https://goreportcard.com/report/github.com/imwally/pinboard) 5 | 6 | Package pinboard implements a golang wrapper for the 7 | pinboard [api](https://pinboard.in/api/). 8 | 9 | ## Documentation 10 | 11 | Please refer to [GoDoc](https://godoc.org/github.com/imwally/pinboard) 12 | for up-to-date documentation. 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/imwally/pinboard 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /notes.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | // Note represents a Pinboard note. 11 | type Note struct { 12 | // Unique ID of the note. 13 | ID string 14 | 15 | // Title of the note. 16 | Title string 17 | 18 | // 20 character long sha1 hash of the note text. 19 | Hash []byte 20 | 21 | // Time the note was created. 22 | CreatedAt time.Time 23 | 24 | // Time the note was updated. 25 | UpdatedAt time.Time 26 | 27 | // Character length of the note. 28 | Length int 29 | 30 | // Body text of the note. 31 | // 32 | // Note: only /notes/ID returns body text. 33 | Text []byte 34 | } 35 | 36 | // note holds intermediate data for preprocessing types because JSON 37 | // is so cool. 38 | type note struct { 39 | ID string `json:"id,omitempty"` 40 | Title string `json:"title,omitempty"` 41 | Hash string `json:"hash,omitempty"` 42 | CreatedAt string `json:"created_at,omitempty"` 43 | UpdatedAt string `json:"updated_at,omitempty"` 44 | Length interface{} `json:"length,omitempty"` 45 | Text string `json:"text,omitempty"` 46 | } 47 | 48 | // notesResponse holds notes responses from the Pinboard API. 49 | type notesResponse struct { 50 | Count int 51 | Notes []note 52 | } 53 | 54 | // parseNote takes the note as JSON data returned from the Pinboard 55 | // API and translates them into Notes with proper types. 56 | func parseNote(n note) (*Note, error) { 57 | var note Note 58 | 59 | note.ID = n.ID 60 | note.Title = n.Title 61 | note.Hash = []byte(n.Hash) 62 | note.Text = []byte(n.Text) 63 | 64 | layout := "2006-01-02 15:04:05" 65 | created, err := time.Parse(layout, n.CreatedAt) 66 | if err != nil { 67 | return nil, err 68 | } 69 | note.CreatedAt = created 70 | 71 | updated, err := time.Parse(layout, n.UpdatedAt) 72 | if err != nil { 73 | return nil, err 74 | } 75 | note.UpdatedAt = updated 76 | 77 | switch v := reflect.ValueOf(n.Length); v.Kind() { 78 | case reflect.String: 79 | length, err := strconv.Atoi(n.Length.(string)) 80 | if err != nil { 81 | return nil, err 82 | } 83 | note.Length = length 84 | case reflect.Float64: 85 | note.Length = int(n.Length.(float64)) 86 | } 87 | 88 | return ¬e, nil 89 | } 90 | 91 | // NotesList returns a list of the user's notes. 92 | // 93 | // https://pinboard.in/api/#notes_list 94 | func NotesList() ([]*Note, error) { 95 | resp, err := get("notesList", nil) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | var nr notesResponse 101 | err = json.Unmarshal(resp, &nr) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | // Parse returned untyped notes into Notes. 107 | var notes []*Note 108 | for _, n := range nr.Notes { 109 | note, err := parseNote(n) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | notes = append(notes, note) 115 | } 116 | 117 | return notes, nil 118 | } 119 | 120 | // notesIDOptions represents the single required argument for 121 | // /notes/ID. 122 | type notesIDOptions struct { 123 | ID string 124 | } 125 | 126 | // NotesID returns an individual user note. The hash property is a 20 127 | // character long sha1 hash of the note text. 128 | // 129 | // https://pinboard.in/api/#notes_get 130 | func NotesID(id string) (*Note, error) { 131 | resp, err := get("notesID", ¬esIDOptions{ID: id}) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | var n note 137 | err = json.Unmarshal(resp, &n) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | note, err := parseNote(n) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | return note, nil 148 | } 149 | -------------------------------------------------------------------------------- /notes_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // Unfortunately, these tests only work with my (wally) personal 10 | // account. There's no way to add and remove a note through the API. 11 | func TestNotesList(t *testing.T) { 12 | notes, err := NotesList() 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | 17 | expected := 2 18 | got := len(notes) 19 | 20 | if got != expected { 21 | t.Errorf("error: got %v, expected %v notes", got, expected) 22 | } 23 | } 24 | 25 | func TestNotesID(t *testing.T) { 26 | note, err := NotesID("0eefe8bbf5f69c3595e4") 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | 31 | expectedID := "0eefe8bbf5f69c3595e4" 32 | gotID := note.ID 33 | 34 | if gotID != expectedID { 35 | t.Errorf("error: got %v, expected %v notes", gotID, expectedID) 36 | } 37 | 38 | expectedTitle := "Pinboard Testing" 39 | gotTitle := note.Title 40 | 41 | if gotTitle != expectedTitle { 42 | t.Errorf("error: got %v, expected %v notes", gotTitle, expectedTitle) 43 | } 44 | 45 | expectedHash := []byte("40ab7b7ab0a5448d9b49") 46 | gotHash := note.Hash 47 | 48 | if bytes.Compare(gotHash, expectedHash) != 0 { 49 | t.Errorf("error: got %v, expected %v notes", gotHash, expectedHash) 50 | } 51 | 52 | layout := "2006-01-02 15:04:05" 53 | 54 | expectedCreated, _ := time.Parse(layout, "2020-01-28 20:31:41") 55 | gotCreated := note.CreatedAt 56 | 57 | if gotCreated != expectedCreated { 58 | t.Errorf("error: got %v, expected %v notes", gotCreated, expectedCreated) 59 | } 60 | 61 | expectedUpdated, _ := time.Parse(layout, "2020-01-29 04:40:53") 62 | gotUpdated := note.UpdatedAt 63 | 64 | if gotUpdated != expectedUpdated { 65 | t.Errorf("error: got %v, expected %v notes", gotUpdated, expectedUpdated) 66 | } 67 | 68 | expectedLength := 193 69 | gotLength := note.Length 70 | 71 | if gotLength != expectedLength { 72 | t.Errorf("error: got %v, expected %v notes", gotLength, expectedLength) 73 | } 74 | 75 | expectedText := []byte("This is a note used strictly for testing purposes.\r\n\r\nIt's not a very fancy note.\r\n\r\nBut it does have line breaks.\r\n\r\n## Next Section\r\n\r\nAnd even some __crazy__ markdown.\r\n\r\nNeat.\r\n\r\nAn edit...") 76 | gotText := note.Text 77 | 78 | if bytes.Compare(gotText, expectedText) != 0 { 79 | t.Errorf("error: got %v, expected %v notes", gotText, expectedText) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pinboard.go: -------------------------------------------------------------------------------- 1 | // Package pinboard provides a wrapper for accessing the Pinboard API. 2 | // 3 | // https://pinboard.in/api/ 4 | // 5 | // All Pinboard API methods are fully supported. 6 | // 7 | // Function names mirror the API endpoints. For example: 8 | // 9 | // PostsAdd() calls the /posts/add method 10 | // TagsDelete() calls the /tags/delete method 11 | // 12 | // If a method supports optional arguments then a MethodOptions struct 13 | // allows you to specify those options to pass to said method. For 14 | // example: 15 | // 16 | // PostsAdd(&PostsAddOptions{}) 17 | // PostsGet(&PostsGetOptions{}) 18 | // 19 | // Not all endpoints require arguments, in which case just pass nil. 20 | // 21 | // PostsAll(nil) 22 | package pinboard 23 | 24 | import ( 25 | "fmt" 26 | "io/ioutil" 27 | "net/http" 28 | "net/url" 29 | "reflect" 30 | "strconv" 31 | "strings" 32 | "time" 33 | ) 34 | 35 | const ( 36 | api string = "https://api.pinboard.in/" 37 | ver string = "v1" 38 | apiurl string = api + ver 39 | ) 40 | 41 | var ( 42 | // Supported Pinboard API methods that map to endpoints. 43 | endpoints = map[string]string{ 44 | "postsUpdate": "/posts/update", 45 | "postsAdd": "/posts/add", 46 | "postsDelete": "/posts/delete", 47 | "postsGet": "/posts/get", 48 | "postsRecent": "/posts/recent", 49 | "postsDates": "/posts/dates", 50 | "postsAll": "/posts/all", 51 | "postsSuggest": "/posts/suggest", 52 | "tagsGet": "/tags/get", 53 | "tagsRename": "/tags/rename", 54 | "tagsDelete": "/tags/delete", 55 | "userSecret": "/user/secret", 56 | "userAPIToken": "/user/api_token", 57 | "notesList": "/notes/list", 58 | "notesID": "/notes/", 59 | } 60 | 61 | pinboardToken = "" 62 | ) 63 | 64 | // get checks if endpoint is a valid Pinboard API endpoint and then 65 | // constructs a valid endpoint URL including the required 'auth_token' 66 | // and 'format' values along with any optional arguments found in the 67 | // options interface. It makes a http.Get request, checks HTTP status 68 | // codes and then finally returns the response body. 69 | func get(endpoint string, options interface{}) (body []byte, err error) { 70 | ep, ok := endpoints[endpoint] 71 | if !ok { 72 | return nil, fmt.Errorf("error: %s is not a supported endpoint", endpoint) 73 | } 74 | 75 | u, err := url.Parse(apiurl + ep) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | // Set URL query parameters based on the MethodOptions only if 81 | // options is not nil. 82 | ov := reflect.ValueOf(options) 83 | if ov.Kind() == reflect.Ptr && !ov.IsNil() { 84 | // /notes/ID hack 85 | if endpoint == "notesID" { 86 | idOptions := reflect.Indirect(reflect.ValueOf(options)) 87 | id := idOptions.Field(0).String() 88 | u.Path = u.Path + id 89 | } else { 90 | v, err := values(options) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | u.RawQuery = v.Encode() 96 | } 97 | } 98 | 99 | // Add API token and format parameters before making request. 100 | q := u.Query() 101 | q.Add("auth_token", pinboardToken) 102 | q.Add("format", "json") 103 | u.RawQuery = q.Encode() 104 | 105 | // Call APImethod with fully constructed URL. 106 | res, err := http.Get(u.String()) 107 | if err != nil { 108 | return nil, err 109 | } 110 | defer res.Body.Close() 111 | 112 | // Check the HTTP response status code. This will tell us 113 | // whether the API token is not set (401) or if we somehow 114 | // managed to request an invalid endpoint (500). 115 | if res.StatusCode != http.StatusOK { 116 | return nil, fmt.Errorf("error: http %d", res.StatusCode) 117 | } 118 | 119 | body, err = ioutil.ReadAll(res.Body) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | return body, nil 125 | } 126 | 127 | // values expects a *MethodOptions struct and encodes the fields into 128 | // url.Values. 129 | func values(i interface{}) (url.Values, error) { 130 | vt := reflect.Indirect(reflect.ValueOf(i)).Type() 131 | vv := reflect.Indirect(reflect.ValueOf(i)) 132 | 133 | uv := url.Values{} 134 | 135 | for j := 0; j < vv.NumField(); j++ { 136 | fName := strings.ToLower(vt.Field(j).Name) 137 | fType := vt.Field(j).Type 138 | fValue := vv.Field(j) 139 | 140 | switch fType.Kind() { 141 | 142 | // No need to anything special with strings. 143 | case reflect.String: 144 | uv.Add(fName, fValue.String()) 145 | 146 | case reflect.Int: 147 | 148 | // Check to make sure we don't have the zero 149 | // value first. 150 | if fValue.Interface().(int) != 0 { 151 | uv.Add(fName, strconv.Itoa(fValue.Interface().(int))) 152 | } 153 | 154 | // Slices may be of type byte or type string, so 155 | // process accordingly. 156 | case reflect.Slice: 157 | if fValue.Len() > 0 { 158 | 159 | // Check what kind of slice we have. 160 | switch fValue.Index(0).Kind() { 161 | 162 | // byte slice, add as a string 163 | case reflect.Uint8: 164 | uv.Add(fName, string(fValue.Interface().([]uint8))) 165 | 166 | // string slice, create single space delimted 167 | // string 168 | case reflect.String: 169 | spaceDelimted := "" 170 | for si := 0; si < fValue.Len(); si++ { 171 | spaceDelimted += fValue.Index(si).Interface().(string) + " " 172 | } 173 | uv.Add(fName, strings.TrimRight(spaceDelimted, " ")) 174 | } 175 | } 176 | 177 | // Bool's are represented as yes/no strings. 178 | case reflect.Bool: 179 | if fValue.Bool() { 180 | uv.Add(fName, "yes") 181 | } else { 182 | uv.Add(fName, "no") 183 | } 184 | 185 | // Process various structs according to their 186 | // underlying type. 187 | case reflect.Struct: 188 | if fType.String() == "time.Time" { 189 | // Even though we hit a time.Time 190 | // field, make sure we have something 191 | // other than the zero value before 192 | // adding it to the url values, 193 | // otherwise the zero value will be 194 | // added. 195 | timeField := fValue.Interface().(time.Time) 196 | if !timeField.IsZero() { 197 | dt := timeField.Format(time.RFC3339) 198 | uv.Add(fName, dt) 199 | } 200 | } 201 | } 202 | } 203 | 204 | return uv, nil 205 | } 206 | 207 | // SetToken sets the API token required to make API calls. The token 208 | // is expected to be the full string "name:random". 209 | func SetToken(token string) { 210 | pinboardToken = token 211 | } 212 | -------------------------------------------------------------------------------- /pinboard_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var optAdd *PostsAddOptions 11 | 12 | // Can't test anything without proper authentication. 13 | func TestMain(m *testing.M) { 14 | tokenEnv, ok := os.LookupEnv("PINBOARD_TOKEN") 15 | if !ok { 16 | fmt.Println("PINBOARD_TOKEN env not set") 17 | os.Exit(1) 18 | } 19 | 20 | SetToken(tokenEnv) 21 | 22 | opt, err := testPostAddOptions() 23 | if err != nil { 24 | fmt.Println("could not create test post options") 25 | os.Exit(1) 26 | } 27 | 28 | optAdd = opt 29 | 30 | os.Exit(m.Run()) 31 | } 32 | 33 | func testPostAddOptions() (*PostsAddOptions, error) { 34 | dt, err := time.Parse(time.RFC3339, "2010-12-11T19:48:02Z") 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | // Post for testing functions. Usually this will be added and 40 | // removed within each test. Make sure Replace is true 41 | // otherwise "item already exists" errors will ensue. 42 | testPost := PostsAddOptions{ 43 | URL: "https://github.com/imwally/pinboard", 44 | Description: "Testing Pinboard Go Package", 45 | Extended: []byte("This is a test from imwally's golang pinboard package. For more information please refer to the pinned URL."), 46 | Tags: []string{"pin", "pinboard", "test", "testing", "pinboard_1_testing", "pinboard_testing"}, 47 | Dt: dt, 48 | Toread: true, 49 | Shared: true, 50 | Replace: true, 51 | } 52 | 53 | return &testPost, nil 54 | } 55 | -------------------------------------------------------------------------------- /posts.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // Aliasing this to get a custom UnmarshalJSON because Pinboard 12 | // can return `"description": false` in the JSON output. 13 | type descriptionType string 14 | 15 | // Post represents a bookmark. 16 | type Post struct { 17 | // URL of bookmark. 18 | Href *url.URL 19 | 20 | // Title of bookmark. This field is unfortunately named 21 | // 'description' for backwards compatibility with the 22 | // delicious API 23 | Description string 24 | 25 | // Description of the item. Called 'extended' for backwards 26 | // compatibility with delicious API. 27 | Extended []byte 28 | 29 | // Tags of bookmark. 30 | Tags []string 31 | 32 | // If the bookmark is private or public. 33 | Shared bool 34 | 35 | // If the bookmark is marked to read later. 36 | Toread bool 37 | 38 | // Create time for this bookmark. 39 | Time time.Time 40 | 41 | // Change detection signature of the bookmark. 42 | Meta []byte 43 | 44 | // Hash of the bookmark. 45 | Hash []byte 46 | 47 | // The number of other users who have bookmarked this same 48 | // item. 49 | Others int 50 | } 51 | 52 | // post represents intermediate post response data before type 53 | // conversion. 54 | type post struct { 55 | Href string 56 | Description descriptionType 57 | Extended string 58 | Tags string 59 | Shared string 60 | Toread string 61 | Time string 62 | Meta string 63 | Hash string 64 | Others int 65 | } 66 | 67 | // toPost converts a post to a type correct Post. 68 | func (p *post) toPost() (*Post, error) { 69 | href, err := url.Parse(p.Href) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | tags := strings.Split(p.Tags, " ") 75 | 76 | var shared, toread bool 77 | if p.Shared == "yes" { 78 | shared = true 79 | } 80 | 81 | if p.Toread == "yes" { 82 | toread = true 83 | } 84 | 85 | dt, err := time.Parse(time.RFC3339, p.Time) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | P := Post{ 91 | Href: href, 92 | Description: p.Description.String(), 93 | Extended: []byte(p.Extended), 94 | Tags: tags, 95 | Shared: shared, 96 | Toread: toread, 97 | Time: dt, 98 | Meta: []byte(p.Meta), 99 | Hash: []byte(p.Hash), 100 | Others: p.Others, 101 | } 102 | 103 | return &P, nil 104 | } 105 | 106 | // postsResponse represents a response from certain /posts/ endpoints. 107 | type postsResponse struct { 108 | UpdateTime string `json:"update_time,omitempty"` 109 | ResultCode string `json:"result_code,omitempty"` 110 | Date string `json:"date,omitempty"` 111 | User string `json:"user,omitempty"` 112 | Posts []post `json:"posts,omitempty"` 113 | } 114 | 115 | // PostsUpdate returns the most recent time a bookmark was added, 116 | // updated or deleted. 117 | // 118 | // https://pinboard.in/api/#posts_update 119 | func PostsUpdate() (time.Time, error) { 120 | resp, err := get("postsUpdate", nil) 121 | if err != nil { 122 | return time.Time{}, err 123 | } 124 | 125 | var pr postsResponse 126 | err = json.Unmarshal(resp, &pr) 127 | if err != nil { 128 | return time.Time{}, err 129 | } 130 | 131 | update, err := time.Parse(time.RFC3339, pr.UpdateTime) 132 | if err != nil { 133 | return time.Time{}, err 134 | } 135 | 136 | return update, nil 137 | } 138 | 139 | // PostsAddOptions represents the required and optional arguments for 140 | // adding a bookmark. 141 | type PostsAddOptions struct { 142 | // Required: The URL of the item. 143 | URL string 144 | 145 | // Required: Title of the item. This field is unfortunately 146 | // named 'description' for backwards compatibility with the 147 | // delicious API. 148 | Description string 149 | 150 | // Description of the item. Called 'extended' for backwards 151 | // compatibility with delicious API. 152 | Extended []byte 153 | 154 | // List of up to 100 tags. 155 | Tags []string 156 | 157 | // Creation time for this bookmark. Defaults to current 158 | // time. Datestamps more than 10 minutes ahead of server time 159 | // will be reset to current server time. 160 | Dt time.Time 161 | 162 | // Replace any existing bookmark with this URL. Default is 163 | // yes. If set to no, will throw an error if bookmark exists. 164 | Replace bool 165 | 166 | // Make bookmark public. Default is "yes" unless user has 167 | // enabled the "save all bookmarks as private" user setting, 168 | // in which case default is "no". 169 | Shared bool 170 | 171 | // Marks the bookmark as unread. Default is "no". 172 | Toread bool 173 | } 174 | 175 | // PostsAdd adds a bookmark. 176 | // 177 | // https://pinboard.in/api/#posts_add 178 | func PostsAdd(opt *PostsAddOptions) error { 179 | if opt.URL == "" { 180 | return errors.New("error: missing url") 181 | } 182 | 183 | if opt.Description == "" { 184 | return errors.New("error: missing description") 185 | } 186 | 187 | resp, err := get("postsAdd", opt) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | var pr postsResponse 193 | err = json.Unmarshal(resp, &pr) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | if pr.ResultCode != "done" { 199 | return errors.New(pr.ResultCode) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | // postsDeleteOptions represents the single required argument for 206 | // deleting a bookmark. 207 | type postsDeleteOptions struct { 208 | URL string 209 | } 210 | 211 | // PostsDelete deletes the bookmark by url. 212 | // 213 | // https://pinboard.in/api/#posts_delete 214 | func PostsDelete(url string) error { 215 | resp, err := get("postsDelete", &postsDeleteOptions{URL: url}) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | var pr postsResponse 221 | err = json.Unmarshal(resp, &pr) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | if pr.ResultCode != "done" { 227 | return errors.New(pr.ResultCode) 228 | } 229 | 230 | return nil 231 | } 232 | 233 | // PostsGetOptions represents the optional arguments for getting 234 | // bookmarks. 235 | type PostsGetOptions struct { 236 | // Filter by up to three tags. 237 | Tag []string 238 | 239 | // Return results bookmarked on this day. UTC date in this 240 | // format: 2010-12-11. 241 | Dt time.Time 242 | 243 | // Return bookmark for this URL. 244 | URL string 245 | 246 | // Include a change detection signature in a meta attribute. 247 | Meta bool 248 | } 249 | 250 | // PostsGet returns one or more posts (on a single day) matching the 251 | // arguments. If no date or URL is given, date of most recent bookmark 252 | // will be used.Returns one or more posts on a single day matching the 253 | // arguments. If no date or URL is given, date of most recent bookmark 254 | // will be used. 255 | // 256 | // https://pinboard.in/api/#posts_get 257 | func PostsGet(opt *PostsGetOptions) ([]*Post, error) { 258 | resp, err := get("postsGet", opt) 259 | if err != nil { 260 | return nil, err 261 | } 262 | 263 | var pr postsResponse 264 | err = json.Unmarshal(resp, &pr) 265 | if err != nil { 266 | return nil, err 267 | } 268 | 269 | var posts []*Post 270 | for _, p := range pr.Posts { 271 | post, err := p.toPost() 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | posts = append(posts, post) 277 | } 278 | 279 | return posts, nil 280 | } 281 | 282 | // PostsRecentOptions represents the optional arguments for returning 283 | // the user's most recent posts. 284 | type PostsRecentOptions struct { 285 | // Filter by up to three tags. 286 | Tag []string 287 | 288 | // Number of results to return. Default is 15, max is 100. 289 | Count int 290 | } 291 | 292 | // PostsRecent returns a list of the user's most recent posts, 293 | // filtered by tag. 294 | // 295 | // https://pinboard.in/api/#posts_recent 296 | func PostsRecent(opt *PostsRecentOptions) ([]*Post, error) { 297 | resp, err := get("postsRecent", opt) 298 | if err != nil { 299 | return nil, err 300 | } 301 | 302 | var pr postsResponse 303 | err = json.Unmarshal(resp, &pr) 304 | if err != nil { 305 | return nil, err 306 | } 307 | 308 | var posts []*Post 309 | for _, p := range pr.Posts { 310 | post, err := p.toPost() 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | posts = append(posts, post) 316 | } 317 | 318 | return posts, nil 319 | } 320 | 321 | // postsDatesResponse represents the response from /posts/dates. 322 | type postsDatesResponse struct { 323 | User string `json:"user"` 324 | Tag string `json:"tag"` 325 | Dates map[string]int `json:"dates"` 326 | } 327 | 328 | // PostsDatesOptions represents the single optional argument for 329 | // returning a list of dates with the number of posts at each date. 330 | type PostsDatesOptions struct { 331 | // Filter by up to three tags. 332 | Tag []string 333 | } 334 | 335 | // PostsDates returns a list of dates with the number of posts at each 336 | // date. 337 | // 338 | // https://pinboard.in/api/#posts_dates 339 | func PostsDates(opt *PostsDatesOptions) (map[string]int, error) { 340 | resp, err := get("postsDates", opt) 341 | if err != nil { 342 | return nil, err 343 | } 344 | 345 | var pr postsDatesResponse 346 | err = json.Unmarshal(resp, &pr) 347 | if err != nil { 348 | return nil, err 349 | } 350 | 351 | return pr.Dates, nil 352 | } 353 | 354 | // PostsAllOptions represents the optional arguments for returning all 355 | // bookmarks in the user's account. 356 | type PostsAllOptions struct { 357 | // Filter by up to three tags. 358 | Tag []string 359 | 360 | // Offset value (default is 0). 361 | Start int 362 | 363 | // Number of results to return. Default is all. 364 | Results int 365 | 366 | // Return only bookmarks created after this time. 367 | Fromdt time.Time 368 | 369 | // Return only bookmarks created before this time. 370 | Todt time.Time 371 | 372 | // Include a change detection signature for each bookmark. 373 | // 374 | // Note: This probably doesn't work. A meta field is always 375 | // returned. The Pinboard API says the datatype is an int but 376 | // changing the value has no impact on the results. Using a 377 | // yes/no string like all the other meta options doesn't work 378 | // either. 379 | Meta int 380 | } 381 | 382 | // PostsAll returns all bookmarks in the user's account. 383 | // 384 | // https://pinboard.in/api/#posts_all 385 | func PostsAll(opt *PostsAllOptions) ([]*Post, error) { 386 | resp, err := get("postsAll", opt) 387 | if err != nil { 388 | return nil, err 389 | } 390 | 391 | var pr []post 392 | err = json.Unmarshal(resp, &pr) 393 | if err != nil { 394 | return nil, err 395 | } 396 | 397 | var posts []*Post 398 | for _, p := range pr { 399 | post, err := p.toPost() 400 | if err != nil { 401 | return nil, err 402 | } 403 | 404 | posts = append(posts, post) 405 | } 406 | 407 | return posts, nil 408 | } 409 | 410 | // postSuggestResponse represents the response from /posts/suggest. 411 | type postsSuggestResponse struct { 412 | Popular []string `json:"popular"` 413 | Recommended []string `json:"recommended"` 414 | } 415 | 416 | // postSuggestOptions represents the single required argument, url, 417 | // for suggesting tags for a post. 418 | type postsSuggestOptions struct { 419 | URL string 420 | } 421 | 422 | // PostsSuggestPopular returns a slice of popular tags for a given 423 | // URL. Popular tags are tags used site-wide for the url. 424 | // 425 | // https://pinboard.in/api/#posts_suggest 426 | func PostsSuggestPopular(url string) ([]string, error) { 427 | resp, err := get("postsSuggest", &postsSuggestOptions{URL: url}) 428 | if err != nil { 429 | return nil, err 430 | } 431 | 432 | var pr []postsSuggestResponse 433 | err = json.Unmarshal(resp, &pr) 434 | if err != nil { 435 | return nil, err 436 | } 437 | 438 | return pr[0].Popular, nil 439 | } 440 | 441 | // PostsSuggestRecommended returns a slice of recommended tags for a 442 | // given URL. Recommended tags are drawn from the user's own tags. 443 | // 444 | // https://pinboard.in/api/#posts_suggest 445 | func PostsSuggestRecommended(url string) ([]string, error) { 446 | resp, err := get("postsSuggest", &postsSuggestOptions{URL: url}) 447 | if err != nil { 448 | return nil, err 449 | } 450 | 451 | var pr []postsSuggestResponse 452 | err = json.Unmarshal(resp, &pr) 453 | if err != nil { 454 | return nil, err 455 | } 456 | 457 | return pr[1].Recommended, nil 458 | } 459 | 460 | // UnmarshalJSON converts a `descriptionType` into a `string`. 461 | func (d *descriptionType) UnmarshalJSON(data []byte) error { 462 | // Have to do the type dance to avoid an infinite loop. 463 | type descriptionTypeAlias descriptionType 464 | var d2 descriptionTypeAlias 465 | 466 | if err := json.Unmarshal(data, &d2); err != nil { 467 | d2 = "" 468 | } 469 | *d = descriptionType(d2) 470 | 471 | return nil 472 | } 473 | 474 | // String returns the `string` value of our `descriptionType`. 475 | func (d *descriptionType) String() string { 476 | return string(*d) 477 | } 478 | -------------------------------------------------------------------------------- /posts_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // Test TestPostsAdd first as almost all subsequent tests rely on 11 | // adding a post. 12 | // 13 | // go test -v -failfast 14 | func TestPostsAdd(t *testing.T) { 15 | err := PostsAdd(optAdd) 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | 20 | err = PostsAdd(&PostsAddOptions{ 21 | Description: "This should fail", 22 | }) 23 | if err == nil { 24 | t.Error("error: expected missing url error") 25 | } 26 | 27 | err = PostsAdd(&PostsAddOptions{ 28 | URL: "https://github.com/imwally/pinboard", 29 | }) 30 | if err == nil { 31 | t.Error("error: expected must provide title error") 32 | } 33 | } 34 | 35 | func ExamplePostsAdd() { 36 | opt := &PostsAddOptions{ 37 | URL: "https://github.com/imwally/pinboard", 38 | Description: "Testing Pinboard Go Package", 39 | } 40 | 41 | err := PostsAdd(opt) 42 | if err != nil { 43 | log.Println("error adding post:", err) 44 | } 45 | } 46 | 47 | // Next, make sure PostsDelete works so we can remove test posts 48 | // created in subsequent tests. This will delete the post that was 49 | // created with TestPostsAdd. 50 | func TestPostsDelete(t *testing.T) { 51 | err := PostsDelete(optAdd.URL) 52 | if err != nil { 53 | t.Errorf("error: failed to delete test post: %s", err) 54 | } 55 | 56 | url := "https://thisisjustatest.com" 57 | err = PostsDelete(url) 58 | if err == nil { 59 | t.Error("error: expected item not found error") 60 | } 61 | } 62 | 63 | func TestPostsUpdate(t *testing.T) { 64 | timeBeforeAdd := time.Now() 65 | 66 | err := PostsAdd(optAdd) 67 | if err != nil { 68 | t.Errorf("error: failed to create test post: %s", err) 69 | } 70 | 71 | timeAfterAdd := time.Now() 72 | 73 | timeUpdate, err := PostsUpdate() 74 | if err != nil { 75 | t.Error(err) 76 | } 77 | 78 | if timeUpdate.After(timeAfterAdd) && timeUpdate.Before(timeBeforeAdd) { 79 | t.Errorf("error: expected %s to be between %s and %s", 80 | timeUpdate, 81 | timeBeforeAdd, 82 | timeAfterAdd, 83 | ) 84 | } 85 | 86 | // Clean up by removing test post 87 | err = PostsDelete(optAdd.URL) 88 | if err != nil { 89 | t.Errorf("error: clean up failed: %s", err) 90 | } 91 | } 92 | 93 | func TestPostsGet(t *testing.T) { 94 | err := PostsAdd(optAdd) 95 | if err != nil { 96 | t.Errorf("error: failed to create test post: %s", err) 97 | } 98 | 99 | // Test PostsGet by URL 100 | posts, err := PostsGet(&PostsGetOptions{ 101 | URL: optAdd.URL, 102 | }) 103 | if err != nil { 104 | t.Error(err) 105 | } 106 | 107 | if len(posts) > 1 { 108 | t.Error("error: PostsGet: expected only 1 post") 109 | } 110 | 111 | // Test PostsGet by tag 112 | dt, _ := time.Parse("2006-01-02", "2010-12-11") 113 | 114 | posts, err = PostsGet(&PostsGetOptions{ 115 | Dt: dt, 116 | Tag: []string{"pinboard", "testing"}, 117 | }) 118 | if err != nil { 119 | t.Error(err) 120 | } 121 | 122 | if len(posts) != 1 { 123 | t.Error("error: PostsGet: expected only 1 post") 124 | } 125 | 126 | // Test PostsGet by Dt 127 | posts, err = PostsGet(&PostsGetOptions{ 128 | Dt: dt, 129 | }) 130 | if err != nil { 131 | t.Error(err) 132 | } 133 | 134 | if len(posts) != 1 { 135 | t.Error("error: PostsGet: expected only 1 post") 136 | } 137 | 138 | // Did we get the meta signature? 139 | if posts[0].Meta == nil { 140 | t.Error("error: PostsGet: expected meta signature") 141 | } 142 | 143 | posts, err = PostsGet(&PostsGetOptions{ 144 | Tag: []string{"sadklfjsldkfjsdlkfj"}, 145 | }) 146 | if err != nil { 147 | t.Error(err) 148 | } 149 | 150 | if len(posts) != 0 { 151 | t.Error("error: PostsGet: expected zero posts") 152 | } 153 | 154 | // Clean up by removing test post 155 | err = PostsDelete(optAdd.URL) 156 | if err != nil { 157 | t.Errorf("error: clean up failed: %s", err) 158 | } 159 | } 160 | 161 | func ExamplePostsGet() { 162 | dt, err := time.Parse("2006-01-02", "2010-12-11") 163 | if err != nil { 164 | log.Println(err) 165 | } 166 | 167 | posts, err := PostsGet(&PostsGetOptions{Dt: dt}) 168 | if err != nil { 169 | log.Println("error getting posts:", err) 170 | } 171 | 172 | for _, post := range posts { 173 | fmt.Println(post.Description) 174 | fmt.Println(post.Href) 175 | fmt.Println(post.Time) 176 | } 177 | 178 | // Output: 179 | // Testing Pinboard Go Package 180 | // https://github.com/imwally/pinboard 181 | // 2010-12-11 19:48:02 +0000 UTC 182 | } 183 | 184 | func TestPostsRecent(t *testing.T) { 185 | // Test Count 186 | posts, err := PostsRecent(&PostsRecentOptions{ 187 | Count: 100, 188 | }) 189 | if err != nil { 190 | t.Error(err) 191 | } 192 | 193 | if len(posts) != 100 { 194 | t.Error("error: expected 100 posts") 195 | } 196 | 197 | err = PostsAdd(optAdd) 198 | if err != nil { 199 | t.Errorf("error: failed to create test post: %s", err) 200 | } 201 | 202 | posts, err = PostsRecent(&PostsRecentOptions{ 203 | Tag: []string{"pinboard_testing"}, 204 | }) 205 | if err != nil { 206 | t.Error(err) 207 | } 208 | 209 | if len(posts) != 1 { 210 | t.Error("error: expected 1 post") 211 | } 212 | 213 | // Clean up by removing test post 214 | err = PostsDelete(optAdd.URL) 215 | if err != nil { 216 | t.Errorf("error: clean up failed: %s", err) 217 | } 218 | 219 | } 220 | 221 | func TestPostsDates(t *testing.T) { 222 | err := PostsAdd(optAdd) 223 | if err != nil { 224 | t.Errorf("error: failed to create test post: %s", err) 225 | } 226 | 227 | dates, err := PostsDates(nil) 228 | if err != nil { 229 | t.Error(err) 230 | } 231 | 232 | expected := "2010-12-11" 233 | if _, ok := dates[expected]; !ok { 234 | t.Errorf("error: expected at least 1 post on %s", expected) 235 | } 236 | 237 | // Test with tags 238 | dates, err = PostsDates(&PostsDatesOptions{ 239 | Tag: []string{"pinboard_testing"}, 240 | }) 241 | if err != nil { 242 | t.Error(err) 243 | } 244 | 245 | if len(dates) != 1 { 246 | t.Error("error: expected only 1 post") 247 | } 248 | 249 | if _, ok := dates[expected]; !ok { 250 | t.Errorf("error: expected at least 1 post on %s", expected) 251 | } 252 | 253 | // Test count 254 | if dates[expected] != 1 { 255 | t.Errorf("error: expected count to be 1, got %v", dates[expected]) 256 | } 257 | 258 | // Clean up by removing test post 259 | err = PostsDelete(optAdd.URL) 260 | if err != nil { 261 | t.Errorf("error: clean up failed: %s", err) 262 | } 263 | 264 | // Make sure that date no longer appears 265 | dates, err = PostsDates(nil) 266 | if err != nil { 267 | t.Error(err) 268 | } 269 | 270 | if _, ok := dates[expected]; ok { 271 | t.Errorf("error: expected no posts on %s", expected) 272 | } 273 | } 274 | 275 | func TestPostsAll(t *testing.T) { 276 | err := PostsAdd(optAdd) 277 | if err != nil { 278 | t.Errorf("error: failed to create test post: %s", err) 279 | } 280 | 281 | posts, err := PostsAll(&PostsAllOptions{ 282 | Results: 1, 283 | Fromdt: optAdd.Dt.Add(time.Duration(-1) * time.Second), 284 | Todt: optAdd.Dt.Add(time.Second), 285 | Tag: []string{"pinboard", "testing"}, 286 | }) 287 | if err != nil { 288 | t.Error(err) 289 | } 290 | 291 | if len(posts) != 1 { 292 | t.Errorf("error: PostsAll: expected only 1 post") 293 | } 294 | 295 | posts, err = PostsAll(&PostsAllOptions{ 296 | Results: 1, 297 | Fromdt: optAdd.Dt.Add(time.Duration(-1) * time.Second), 298 | Todt: optAdd.Dt.Add(time.Second), 299 | Tag: []string{"this should fail"}, 300 | }) 301 | if err != nil { 302 | t.Error(err) 303 | } 304 | 305 | if len(posts) != 0 { 306 | t.Errorf("error: PostsAll: expected 0 posts") 307 | } 308 | 309 | posts, err = PostsAll(nil) 310 | if err != nil { 311 | t.Error(err) 312 | } 313 | 314 | if len(posts) < 200 { 315 | t.Errorf("error: PostsAll: expected more than 200 posts") 316 | } 317 | 318 | // Clean up by removing test post 319 | err = PostsDelete(optAdd.URL) 320 | if err != nil { 321 | t.Errorf("error: clean up failed: %s", err) 322 | } 323 | 324 | } 325 | 326 | // The following two tests don't require posting a bookmark to get 327 | // suggested tags. 328 | func TestPostsSuggestPopular(t *testing.T) { 329 | got, err := PostsSuggestPopular(optAdd.URL) 330 | if err != nil { 331 | t.Error(err) 332 | } 333 | 334 | if len(got) > 1 || got[0] != "code" { 335 | t.Error("error: expected single tag code") 336 | } 337 | } 338 | 339 | func TestPostsSuggestRecommended(t *testing.T) { 340 | got, err := PostsSuggestRecommended(optAdd.URL) 341 | if err != nil { 342 | t.Error(err) 343 | } 344 | 345 | if len(got) != 3 { 346 | t.Error("error: expected 3 tags") 347 | } 348 | 349 | if got[0] != "code" { 350 | t.Error("error: expected code tag") 351 | } 352 | 353 | if got[1] != "github" { 354 | t.Error("error: expected github tag") 355 | } 356 | 357 | if got[2] != "IFTTT" { 358 | t.Error("error: expected IFTTT tag") 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /posts_unmarshal_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestPostsUnmarshallingBrokenDescription(t *testing.T) { 9 | brokenJSON := `{"href":"https:\/\/twitter.com\/LeishaRiddel\/status\/961609576641540097\/photo\/1","description":false,"extended":"Favorite tweet:\n\nM\u0360o\u032c\u033a\u0362t\u0317\u0331\u0333h\u031d\u0355\u032c\u0318\u0332\u032e\u0326e\u0355\u031e\u032b\u0332\u0330r\u0348\u0359\u031f\u031f\nc\u034f\u0349\u0349\u0354\u0333\u0325\u033a\u0330h\u031d\u0353\u0349\u0326\u0339\u035di\u031d\u0348\u0348\u0356\u0332\u031c\u0332\u035fl\u0347\u0355\u0323\u032c\u032d\u0354\u035fd\u0335\u0332r\u0320\u0330\u032d\u0329\u0331\u035a\u0316e\u0317\u031c\u032b\u033cn\nT\u033ah\u0489\u032d\u032d\u0330\u0325\u0359e\u0316\u032dy\u0326\u0353\u0330\u0347\u0356\u0361 \u1e77\u0335\u0332s\u033c\u0333\u0319\u0329\u035de \u0331m\u0318\u031f\u00fd e\u0329\u033a\u0324\u032a\u0323\u0345y\u033a\u0355\u031c\u031e\u0356\u0330\u032e\u0229\u0356\u0324\u0355\u033a\u0325\u032cs to\u0489 \u031d\u034d\u0326\u0356\u033a\u0345p\u0348\u031e\u0355\u031elay\u0330\u033a\u034d\u033a\u0319\u032c\u0333 \u034f\u032c\u0359\u033c\u0347w\u0334\u0319\u033b\u032ci\u0338\u0331\u0325\u034e\u0316\u0347t\u0316\u0332\u0316\u032d\u032b\u035dh\u0318\u0320 \u0329\u0331\u031d\u032b\u032a\u0353\u0323m\u0489\u0331\u031d\u0329\u034e\u031e\u0353\u0323e M\u0335\u0339\u033c\u0329\u0319\u0318\u032ao\u0318\u0362t\u033c\u1e2b\u034e\u0318e\u0319r I\u0319\u0348\u0347\u032c\u033a\u0323\u032e\u0358t \u0322\u032b\u0330\u031e\u0323h\u0335\u0349\u034d\u032c\u0333\u034e\u0333u\u0355r\u0318\u0323\u0324\u032b\u0324\u0345t\u0330\u0315s pic.twitter.com\/CKUDNx8Maz\n\n\u2014 Leisha Riddel (@LeishaRiddel) February 8, 2018","meta":"ce6199bac28b896012f067cb64fd7226","hash":"9ad3e9d77a12d21903f9ccafda592d84","time":"2018-02-08T20:03:06Z","shared":"no","toread":"no","tags":"IFTTT Twitter FAVES"}` 10 | var p post 11 | 12 | err := json.Unmarshal([]byte(brokenJSON), &p) 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | if p.Description != "" { 17 | t.Error("Description is not empty string") 18 | } 19 | if p.Hash != "9ad3e9d77a12d21903f9ccafda592d84" { 20 | t.Error("Wrong hash") 21 | } 22 | } 23 | 24 | func TestPostsUnmarshallingOkDescription(t *testing.T) { 25 | okJSON := `{"href":"https:\/\/www.goodsfromjapan.com\/demekin-kingyo-sukui-goldfish-pack-p-1832.html","description":"Demekin Kingyo Sukui Goldfish Pack of 100 | Goods From Japan","extended":"","meta":"0becff1bafc8c4d347f19065ab44c2b4","hash":"984c059c4bdf506c05aa03ad2721a34d","time":"2018-03-31T07:26:25Z","shared":"no","toread":"yes","tags":"IFTTT Pocket"}` 26 | var p post 27 | 28 | err := json.Unmarshal([]byte(okJSON), &p) 29 | if err != nil { 30 | t.Error(err) 31 | } 32 | if p.Description != "Demekin Kingyo Sukui Goldfish Pack of 100 | Goods From Japan" { 33 | t.Error("Description is incorrect") 34 | } 35 | if p.Hash != "984c059c4bdf506c05aa03ad2721a34d" { 36 | t.Error("Wrong hash") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tags.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | // Tags maps a tag name to the number of bookmarks that use that tag. 9 | type Tags map[string]string 10 | 11 | // tagsResponse holds the response result from deleting or renaming 12 | // tags. 13 | type tagsResponse struct { 14 | Result string `json:"result"` 15 | } 16 | 17 | // TagsGet returns a full list of the user's tags along with the 18 | // number of times they were used. 19 | func TagsGet() (Tags, error) { 20 | resp, err := get("tagsGet", nil) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | var tags Tags 26 | err = json.Unmarshal(resp, &tags) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return tags, nil 32 | } 33 | 34 | // tagsDeleteOptions holds the single required argument to delete a 35 | // tag. 36 | type tagsDeleteOptions struct { 37 | Tag string 38 | } 39 | 40 | // TagsDelete deletes an existing tag. 41 | func TagsDelete(tag string) error { 42 | resp, err := get("tagsDelete", &tagsDeleteOptions{Tag: tag}) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | var tr tagsResponse 48 | err = json.Unmarshal(resp, &tr) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if tr.Result != "done" { 54 | return errors.New(tr.Result) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | // tagsRenameOptions holds the required arguments needed to rename a 61 | // tag. 62 | type tagsRenameOptions struct { 63 | Old string 64 | New string 65 | } 66 | 67 | // TagsRename renames a tag, or folds it in to an existing tag. 68 | func TagsRename(old, new string) error { 69 | resp, err := get("tagsRename", &tagsRenameOptions{ 70 | Old: old, 71 | New: new, 72 | }) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | var tr tagsResponse 78 | err = json.Unmarshal(resp, &tr) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | if tr.Result != "done" { 84 | return errors.New(tr.Result) 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /tags_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func contains(haystack []string, needle string) bool { 8 | for _, a := range haystack { 9 | if a == needle { 10 | return true 11 | } 12 | 13 | } 14 | 15 | return false 16 | } 17 | 18 | func TestTagsGet(t *testing.T) { 19 | tags, err := TagsGet() 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | 24 | // Crappy test but I'm not sure of another way right now 25 | if len(tags) < 100 { 26 | t.Error("expected more tags") 27 | } 28 | } 29 | 30 | func TestTagsRename(t *testing.T) { 31 | err := PostsAdd(optAdd) 32 | if err != nil { 33 | t.Error(err) 34 | } 35 | 36 | err = TagsRename("pinboard_1_testing", "pinboard_2_testing") 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | 41 | p, err := PostsGet(&PostsGetOptions{ 42 | URL: optAdd.URL, 43 | }) 44 | if err != nil { 45 | t.Error(err) 46 | } 47 | 48 | if !contains(p[0].Tags, "pinboard_2_testing") { 49 | t.Error("error: tag rename failed") 50 | } 51 | 52 | err = TagsRename("pinboard_2_testing", "pinboard_1_testing") 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | 57 | p, err = PostsGet(&PostsGetOptions{ 58 | URL: optAdd.URL, 59 | }) 60 | if err != nil { 61 | t.Error(err) 62 | } 63 | 64 | if !contains(p[0].Tags, "pinboard_1_testing") { 65 | t.Error("error: tag rename failed") 66 | } 67 | 68 | // Clean up by removing test post 69 | err = PostsDelete(optAdd.URL) 70 | if err != nil { 71 | t.Errorf("error: clean up failed: %s", err) 72 | } 73 | } 74 | 75 | func TestTagsDelete(t *testing.T) { 76 | err := PostsAdd(optAdd) 77 | if err != nil { 78 | t.Error(err) 79 | } 80 | 81 | err = TagsDelete("pinboard_1_testing") 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | 86 | p, err := PostsGet(&PostsGetOptions{ 87 | URL: optAdd.URL, 88 | }) 89 | if err != nil { 90 | t.Error(err) 91 | } 92 | 93 | if contains(p[0].Tags, "pinboard_1_testing") { 94 | t.Error("error: TagsDelete failed") 95 | } 96 | 97 | // Clean up by removing test post 98 | err = PostsDelete(optAdd.URL) 99 | if err != nil { 100 | t.Errorf("error: clean up failed: %s", err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // userResponse holds the response from /user/secret and 8 | // /user/api_token 9 | type userResponse struct { 10 | Result string `json:"result"` 11 | } 12 | 13 | // UserSecret returns the user's secret RSS key (for viewing private 14 | // feeds). 15 | func UserSecret() (string, error) { 16 | resp, err := get("userSecret", nil) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | var ur userResponse 22 | err = json.Unmarshal(resp, &ur) 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | return ur.Result, nil 28 | } 29 | 30 | // UserAPIToken returns the user's API token (for making API calls 31 | // without a password). 32 | func UserAPIToken() (string, error) { 33 | resp, err := get("userAPIToken", nil) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | var ur userResponse 39 | err = json.Unmarshal(resp, &ur) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | return ur.Result, nil 45 | } 46 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package pinboard 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestUserSecret(t *testing.T) { 8 | secret, err := UserSecret() 9 | if err != nil { 10 | t.Errorf("error: UserSecret: %s", err) 11 | } 12 | 13 | if secret == "" { 14 | t.Error("error: UserSecret: expected secret string") 15 | } 16 | } 17 | 18 | func TestUserAPIToken(t *testing.T) { 19 | token, err := UserAPIToken() 20 | if err != nil { 21 | t.Errorf("error: UserAPIToken: %s", err) 22 | } 23 | 24 | if token == "" { 25 | t.Error("error: UserAPIToken: expected token string") 26 | } 27 | } 28 | --------------------------------------------------------------------------------