├── __init__.py ├── requirements.txt ├── LICENSE ├── .gitignore ├── README.md ├── pinote_edit.pyui ├── pinote_ui.py └── pinote.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | bs4 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright <2017> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # End of https://www.gitignore.io/api/python -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Pinote Better 2 |
3 | 4 | Pinote Better enables programmatic access to Pinboard Notes. This is very much a workaround, as currently there isn't an official API for Notes. (Hence the requirement of username/password instead of API token.) 5 | 6 | 7 | ### Usage 8 | ```python 9 | >>> from pinote import Pinote 10 | >>> pn = Pinote('your_username', 'your_password') 11 | ``` 12 | 13 | Once you've done this, you can now use the `pn` object to access Notes. Here are some examples: 14 | 15 | ##### Get All Notes 16 | ```python 17 | >>> pn.get_all_notes() 18 | { 19 | u'count': 1, 20 | u'notes': [{ 21 | u'hash': u'47cd8b82d59beby67', 22 | u'title': u'my cool note', 23 | u'created_at': u'2017-03-03 21:00:41', 24 | u'updated_at': u'2017-03-03 21:00:41', 25 | u'length': u'293', 26 | u'id': u'1ebd124daf1y82nd0' 27 | }] 28 | } 29 | ``` 30 | 31 | ##### Get a Full Note 32 | ```python 33 | >>> pn.get_note_details('1ebd124daf1y82nd0') 34 | { 35 | u'hash': u'47cd8b82d59beby67', 36 | u'title': u'my cool note', 37 | u'text': u'*note body* more note body', 38 | u'created_at': u'2017-03-03 21:00:41', 39 | u'updated_at': u'2017-03-03 21:00:41', 40 | u'length': 5, 41 | u'id': u'1ebd124daf1y82nd0' 42 | } 43 | ``` 44 | 45 | ##### Get a Note Html 46 | ```python 47 | >>> pn.get_note_html('1ebd124daf1y82nd0') 48 |
49 |

my cool note

50 |

note body more note body

51 |
52 | ``` 53 | 54 | ##### Add a Note 55 | ```python 56 | >>> pn.add_note('my cool note', '*note body* more note body', 'space separated tags', True, False) 57 | ``` 58 | 59 | ##### Edit a Note 60 | ```python 61 | >>> pn.edit_note('my title', 'my note body', '1ebd124daf1y82nd0', False) 62 | ``` 63 | 64 | ##### Delete a Note 65 | ```python 66 | >>> pn.delete_note('1ebd124daf1y82nd0') 67 | ``` 68 | 69 |
70 | 71 | #### TODO: 72 | 73 | - [x] Add exception handling 74 | - [ ] Add a Pythonista GUI (i.e. iOS app) 75 | 76 | 77 | ### License 78 | MIT License. See [License]() for details. 79 | -------------------------------------------------------------------------------- /pinote_edit.pyui: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "selected" : false, 4 | "frame" : "{{0, 0}, {320, 480}}", 5 | "class" : "View", 6 | "nodes" : [ 7 | { 8 | "selected" : false, 9 | "frame" : "{{15, 60}, {289, 390}}", 10 | "class" : "TextView", 11 | "nodes" : [ 12 | 13 | ], 14 | "attributes" : { 15 | "uuid" : "B0D26FC3-4C78-4A46-BFB4-15A05B9E5DF8", 16 | "flex" : "WH", 17 | "corner_radius" : 8, 18 | "background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)", 19 | "frame" : "{{60, 140}, {200, 200}}", 20 | "editable" : true, 21 | "border_width" : 1, 22 | "alignment" : "left", 23 | "autocorrection_type" : "default", 24 | "text" : "", 25 | "alpha" : 1, 26 | "font_name" : "", 27 | "spellchecking_type" : "default", 28 | "class" : "TextView", 29 | "name" : "note", 30 | "font_size" : 17 31 | } 32 | }, 33 | { 34 | "selected" : true, 35 | "frame" : "{{15, 6}, {289, 32}}", 36 | "class" : "TextField", 37 | "nodes" : [ 38 | 39 | ], 40 | "attributes" : { 41 | "uuid" : "A9A7FB9B-0B48-41E4-90EB-489EE27596AA", 42 | "font_size" : 17, 43 | "corner_radius" : 8, 44 | "background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)", 45 | "frame" : "{{60, 224}, {200, 32}}", 46 | "border_width" : 1, 47 | "alignment" : "left", 48 | "autocorrection_type" : "default", 49 | "placeholder" : "Title", 50 | "font_name" : "", 51 | "spellchecking_type" : "default", 52 | "class" : "TextField", 53 | "name" : "title", 54 | "flex" : "W" 55 | } 56 | } 57 | ], 58 | "attributes" : { 59 | "enabled" : true, 60 | "background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)", 61 | "tint_color" : "RGBA(0.000000,0.478000,1.000000,1.000000)", 62 | "border_color" : "RGBA(0.000000,0.000000,0.000000,1.000000)", 63 | "flex" : "" 64 | } 65 | } 66 | ] -------------------------------------------------------------------------------- /pinote_ui.py: -------------------------------------------------------------------------------- 1 | from pinotebetter.pinote import * 2 | import markdown 3 | import clipboard 4 | import console 5 | import ui 6 | import dialogs 7 | pb = Pinote('user','pass') 8 | 9 | 10 | class ListingsView(object): 11 | def __init__(self, notes): 12 | self.notes = notes.get('notes') 13 | self.count = notes.get('count') 14 | self.selected_item = None 15 | import ui 16 | self.view = ui.TableView() 17 | self.view.name = '{} Notes'.format(notes.get('count')) 18 | ds = ui.ListDataSource(notes.get('notes')) 19 | self.view.right_button_items=[ui.ButtonItem(title='Add Note',action=self.add_note)] 20 | self.view.left_button_items=[ui.ButtonItem(image=ui.Image.named('iob:ios7_refresh_empty_32'),action=self.refresh)] 21 | ds.delete_enabled = False 22 | ds.action = self.row_selected 23 | self.view.data_source = ds 24 | self.view.delegate = ds 25 | self.view.frame = (0, 0, 500, 500) 26 | self.view.present('sheet') 27 | 28 | @ui.in_background 29 | def add_note(self, btn): 30 | title = dialogs.input_alert('Title') 31 | text = dialogs.text_dialog(title=title,autocorrection=True,done_button_title='Save') 32 | pb.add_note(title, text, 'qtey uenr') 33 | 34 | @ui.in_background 35 | def refresh(self, btn): 36 | self.view.close() 37 | self.__init__(pb.get_all_notes()) 38 | 39 | @ui.in_background 40 | def row_selected(self, ds): 41 | note = self.notes[ds.selected_row] 42 | #print note 43 | console.show_activity('Getting note...') 44 | note_detail = pb.get_note_details(note.get('id')) 45 | console.hide_activity() 46 | note_view = NoteView(note_detail) 47 | 48 | class NoteView(object): 49 | def __init__(self, note): 50 | self.note = note 51 | import ui 52 | self.view = ui.WebView() 53 | 54 | self.view.load_html(self.get_html()) 55 | edit_btn = ui.ButtonItem(title='Edit',action=self.edit_action) 56 | delete_btn = ui.ButtonItem(title='Delete',action=self.delete_action,tint_color='#ff0000') 57 | self.view.right_button_items = [edit_btn, delete_btn] 58 | self.view.present() 59 | self.view.wait_modal() 60 | 61 | 62 | def edit_action(self, btn): 63 | edit_view = ui.load_view('pinote_edit') 64 | edit_view['title'].text = self.note.get('title') 65 | edit_view['note'].text = self.note.get('text') 66 | edit_view.present() 67 | edit_view.wait_modal() 68 | 69 | self.note['text'] = edit_view['note'].text 70 | self.note['title'] = edit_view['title'].text 71 | pb.edit_note(self.note['title'], self.note['text'],self.note['id'] ) 72 | 73 | def delete_action(self, btn): 74 | result = dialogs.alert('Are you sure you want to delete?', button1='Delete') 75 | if result: 76 | pb.delete_note(self.note['id']) 77 | self.view.close() 78 | 79 | def get_html(self): 80 | html_fmt=''' 81 | 82 |

{title}

83 |

{body}

84 | 85 | ''' 86 | md = markdown.Markdown(extensions=['markdown.extensions.nl2br']) 87 | title = self.note.get('title') 88 | body = md.convert(self.note.get('text')) 89 | return html_fmt.format(title=title, body=body) 90 | 91 | 92 | notes = pb.get_all_notes() 93 | notes.get('notes').sort(key=lambda note: note.get('updated_at'), reverse=True) 94 | v = ListingsView(notes) 95 | #v.view.present('sheet') 96 | #details = pb.get_note_details(note.get('id')) 97 | #note_view = NoteView(note=details) 98 | #note_view.present('sheet') 99 | #print stuff 100 | #dialogs.text_dialog(title=stuff.get('title'),text=stuff.get('text'),autocorrection=True) 101 | html_fmt=''' 102 | 103 |

{title}

104 |

{body}

105 | 106 | ''' 107 | #print stuff 108 | #md = markdown.Markdown(extensions=['markdown.extensions.nl2br']) 109 | #htnl = md.convert(stuff.get('text')) 110 | #htnl = foo.get_note_html(note.get('id')) 111 | 112 | 113 | #TODO: 114 | # tags, 115 | # preview markdown 116 | # save button 117 | # delete ui 118 | # fix pinote parser - html -------------------------------------------------------------------------------- /pinote.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | import atexit 3 | import requests 4 | from bs4 import BeautifulSoup 5 | 6 | HEADERS = {'User-Agent': 'Pinote-Better.py'} 7 | 8 | 9 | class PinoteError(Exception): 10 | 11 | @staticmethod 12 | def error_handler(msg=''): 13 | def error_handler_decorator(func): 14 | @wraps(func) 15 | def func_wrapper(*args, **kwargs): 16 | try: 17 | return func(*args, **kwargs) 18 | except Exception as e: 19 | raise PinoteError("Error while executing '{}' -- {}\n{}" 20 | .format(func.func_name, repr(e), msg)) 21 | return func_wrapper 22 | return error_handler_decorator 23 | 24 | 25 | class Pinote(object): 26 | _cached_session = None 27 | _delete_token = None 28 | 29 | def __init__(self, username, password): 30 | self.username = username 31 | self.password = password 32 | self.basic_auth = requests.auth.HTTPBasicAuth(username, password) 33 | self.post_auth = {'username': username, 'password': password} 34 | 35 | @property 36 | def __session(self): 37 | if not self._cached_session: 38 | self.__login() 39 | return self._cached_session 40 | 41 | def __login(self): 42 | self._cached_session = requests.Session() 43 | r = self._cached_session.post( 44 | 'https://pinboard.in/auth/', data=self.post_auth, 45 | headers=HEADERS, allow_redirects=False) 46 | r.raise_for_status() 47 | if 'error' in r.headers.get('location'): 48 | self._cached_session = None 49 | raise Exception('Invalid login') 50 | 51 | @atexit.register 52 | def __del__(self): 53 | try: 54 | self._cached_session.get('https://pinboard.in/logout/', allow_redirects=False) 55 | except Exception: 56 | pass 57 | 58 | @PinoteError.error_handler() 59 | def add_note(self, title, note, tags, use_markdown=False, public=False): 60 | visibility = 'public' if public else 'private' 61 | data = { 62 | 'title': title, 63 | 'note': note, 64 | 'tags': tags, 65 | 'use_markdown': '1' if use_markdown else '0', 66 | 'submit': 'save ' + visibility, 67 | 'action': 'save_' + visibility 68 | } 69 | 70 | r = self.__session.post('https://pinboard.in/note/add/', 71 | data=data, headers=HEADERS, 72 | allow_redirects=False) 73 | r.raise_for_status() 74 | 75 | @PinoteError.error_handler() 76 | def get_all_notes(self): 77 | r = requests.get('https://api.pinboard.in/v1/notes/list?format=json', 78 | auth=self.basic_auth) 79 | r.raise_for_status() 80 | return r.json() 81 | 82 | @PinoteError.error_handler() 83 | def get_note_details(self, note_id): 84 | r = requests.get('https://api.pinboard.in/v1/notes/{}?format=json'.format(note_id), 85 | auth=self.basic_auth) 86 | r.raise_for_status() 87 | return r.json() 88 | 89 | @PinoteError.error_handler() 90 | def get_note_html(self, note_id): 91 | r = self.__session.get( 92 | 'https://notes.pinboard.in/u:{}/notes/{}'.format(self.username, note_id)) 93 | r.raise_for_status() 94 | html = r.text 95 | soup = BeautifulSoup(html, "lxml") 96 | note_html = soup.find('blockquote', {'class': 'note'}) 97 | return note_html 98 | 99 | @PinoteError.error_handler() 100 | def edit_note(self, title, note, note_id, use_markdown=False): 101 | data = { 102 | 'slug': note_id, 103 | 'action': 'update', 104 | 'title': title, 105 | 'note': note, 106 | 'use_markdown': 'on' if use_markdown else 'off' 107 | } 108 | r = self.__session.post('https://notes.pinboard.in/u:{}/notes/{}/edit/' 109 | .format(self.username, note_id), data=data, headers=HEADERS) 110 | r.raise_for_status() 111 | 112 | @PinoteError.error_handler() 113 | def delete_note(self, note_id): 114 | if not self._delete_token: 115 | r = self.__session.get( 116 | 'https://notes.pinboard.in', headers=HEADERS) 117 | r.raise_for_status() 118 | html = r.text 119 | soup = BeautifulSoup(html, "lxml") 120 | self._delete_token = soup.find( 121 | 'input', {'name': 'token'}).get('value') 122 | data = { 123 | 'token': self._delete_token, 124 | 'action': 'delete_note', 125 | 'id': note_id 126 | } 127 | r = self.__session.post('https://notes.pinboard.in/', 128 | data=data, headers=HEADERS) 129 | r.raise_for_status() 130 | --------------------------------------------------------------------------------