├── .gitignore ├── LICENSE ├── README.md ├── alfred ├── document.png ├── icon.png ├── info.plist └── scripts └── scripts ├── service ├── __init__.py ├── alfred.py └── cli.py ├── spillo.py └── spillo ├── __init__.py ├── account.py ├── bookmark.py ├── database.py ├── emitter.py └── query.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | .idea 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Damien DeVille 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spillo for Alfred 2 | 3 | An Alfred workflow that lets you search your bookmarks in [Spillo](http://bananafishsoftware.com/products/spillo). 4 | 5 | You can download the latest version of the workflow from [here](https://github.com/ddeville/spillo-alfred/releases/latest) or from [Packal](http://www.packal.org/workflow/spillo). 6 | 7 | ## Usage 8 | 9 | You can invoke the workflow by using the `spl` command in Alfred. 10 | 11 | You can search in two different ways: 12 | 13 | ### Global 14 | 15 | Just type some text after the `spl` command and Spillo will search against the title, URL, description and tags of your bookmarks. Simple. 16 | 17 | ``` 18 | spl objective-c atomics 19 | ``` 20 | 21 | ### Specific 22 | 23 | If you want more control, you can specify various parameters in your search. The following parameters are supported: 24 | 25 | - `-n`/`--name`: The name of the bookmark (text) 26 | - `-u`/`--url`: The URL of the bookmark (text) 27 | - `-d`/`--desc`: The description of the bookmark (text) 28 | - `-t`/`--tags`: The tags of the bookmark (text, separated by space) 29 | - `-un`/`--unread`: Whether the bookmark is unread (`1` or `0`, `true` or `false`, `yes` or `no`) 30 | - `-pu`/`--public`: Whether the bookmark is public (`1` or `0`, `true` or `false`, `yes` or `no`) 31 | 32 | You can use multiple parameters at the same time. 33 | 34 | ``` 35 | spl -n nullability -t objc swift -un 1 36 | ``` 37 | 38 | #### Unread 39 | 40 | You can also see a list of your unread bookmarks by using the `splunread` command in Alfred. 41 | 42 | ## Hotkeys 43 | 44 | The workflow has some hotkeys setup that you can use to start a search of view your unread bookmarks. These hotkeys are left blank by default but you can easily set them up by editing them in the workflow. 45 | 46 | ## Actions 47 | 48 | When you have found the bookmark you are looking for, you can open it in your default browser. Just hit Return or click on the bookmark in the Alfred search results. 49 | 50 | Alternatively, you can open the bookmark in the background by holding the option key. 51 | 52 | You can also open the bookmark in Spillo by holding the command key. 53 | 54 | ## Notes 55 | 56 | Note that Spillo needs to be installed and authenticated on your machine for the workflow to work. 57 | -------------------------------------------------------------------------------- /alfred/document.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddeville/spillo-alfred/0f88ad7f90cc8e2773882b12dd2854a603cfab17/alfred/document.png -------------------------------------------------------------------------------- /alfred/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddeville/spillo-alfred/0f88ad7f90cc8e2773882b12dd2854a603cfab17/alfred/icon.png -------------------------------------------------------------------------------- /alfred/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.ddeville.spillo.alfred 7 | category 8 | Productivity 9 | connections 10 | 11 | 6C68D0BB-A89A-4FDD-BB99-B5A5B460646E 12 | 13 | 14 | destinationuid 15 | 0EB96C1E-0FCD-4072-BA49-18885F489BE2 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | 21 | 22 | destinationuid 23 | DA1739EB-62FF-4F27-BA0E-1BFBDC136AC2 24 | modifiers 25 | 1048576 26 | modifiersubtext 27 | 28 | 29 | 30 | destinationuid 31 | 47697E2F-6FD5-4B01-8C78-E2E1E5596CAE 32 | modifiers 33 | 524288 34 | modifiersubtext 35 | 36 | 37 | 38 | BB06C0C1-DFC1-4B57-9C0F-E56ED87BC903 39 | 40 | 41 | destinationuid 42 | 0EB96C1E-0FCD-4072-BA49-18885F489BE2 43 | modifiers 44 | 0 45 | modifiersubtext 46 | 47 | 48 | 49 | destinationuid 50 | DA1739EB-62FF-4F27-BA0E-1BFBDC136AC2 51 | modifiers 52 | 1048576 53 | modifiersubtext 54 | 55 | 56 | 57 | destinationuid 58 | 47697E2F-6FD5-4B01-8C78-E2E1E5596CAE 59 | modifiers 60 | 524288 61 | modifiersubtext 62 | 63 | 64 | 65 | 66 | createdby 67 | Bananafish Software 68 | description 69 | Search your bookmarks in Spillo 70 | disabled 71 | 72 | name 73 | Search Bookmarks in Spillo 74 | objects 75 | 76 | 77 | config 78 | 79 | plusspaces 80 | 81 | url 82 | {query} 83 | utf8 84 | 85 | 86 | type 87 | alfred.workflow.action.openurl 88 | uid 89 | 0EB96C1E-0FCD-4072-BA49-18885F489BE2 90 | version 91 | 0 92 | 93 | 94 | config 95 | 96 | argumenttype 97 | 0 98 | escaping 99 | 68 100 | keyword 101 | spl 102 | queuedelaycustom 103 | 3 104 | queuedelayimmediatelyinitially 105 | 106 | queuedelaymode 107 | 0 108 | queuemode 109 | 1 110 | runningsubtext 111 | Please wait, currently searching in Spillo… 112 | script 113 | import os, subprocess, sys 114 | 115 | script = os.path.join(os.path.dirname(os.path.realpath('__file__')), 'scripts/spillo.py') 116 | output = subprocess.check_output([script, '--service', 'alfred', '--query', "{query}"]) 117 | 118 | sys.stdout.write(output) 119 | subtext 120 | Enter a query to search bookmarks in Spillo 121 | title 122 | Search in Spillo 123 | type 124 | 3 125 | withspace 126 | 127 | 128 | type 129 | alfred.workflow.input.scriptfilter 130 | uid 131 | 6C68D0BB-A89A-4FDD-BB99-B5A5B460646E 132 | version 133 | 0 134 | 135 | 136 | config 137 | 138 | plusspaces 139 | 140 | url 141 | spillo:///view_post?url={query} 142 | utf8 143 | 144 | 145 | type 146 | alfred.workflow.action.openurl 147 | uid 148 | DA1739EB-62FF-4F27-BA0E-1BFBDC136AC2 149 | version 150 | 0 151 | 152 | 153 | config 154 | 155 | argumenttype 156 | 2 157 | escaping 158 | 102 159 | keyword 160 | splunread 161 | queuedelaycustom 162 | 3 163 | queuedelayimmediatelyinitially 164 | 165 | queuedelaymode 166 | 0 167 | queuemode 168 | 1 169 | runningsubtext 170 | Please wait, currently searching in Spillo… 171 | script 172 | import os, subprocess, sys 173 | 174 | script = os.path.join(os.path.dirname(os.path.realpath('__file__')), 'scripts/spillo.py') 175 | output = subprocess.check_output([script, '--service', 'alfred', '--query', "-un 1"]) 176 | 177 | sys.stdout.write(output) 178 | subtext 179 | View a list of unread bookmarks in Spillo 180 | title 181 | View Unread in Spillo 182 | type 183 | 3 184 | withspace 185 | 186 | 187 | type 188 | alfred.workflow.input.scriptfilter 189 | uid 190 | BB06C0C1-DFC1-4B57-9C0F-E56ED87BC903 191 | version 192 | 0 193 | 194 | 195 | config 196 | 197 | concurrently 198 | 199 | escaping 200 | 68 201 | script 202 | import subprocess 203 | from Foundation import NSURL 204 | from AppKit import NSWorkspace 205 | 206 | default_browser_path = NSWorkspace.sharedWorkspace().URLForApplicationToOpenURL_(NSURL.URLWithString_('http://google.com')).path() 207 | 208 | subprocess.check_output(['open', '-a', default_browser_path, '--background', '{query}']) 209 | 210 | type 211 | 3 212 | 213 | type 214 | alfred.workflow.action.script 215 | uid 216 | 47697E2F-6FD5-4B01-8C78-E2E1E5596CAE 217 | version 218 | 0 219 | 220 | 221 | readme 222 | 223 | uidata 224 | 225 | 0EB96C1E-0FCD-4072-BA49-18885F489BE2 226 | 227 | ypos 228 | 10 229 | 230 | 47697E2F-6FD5-4B01-8C78-E2E1E5596CAE 231 | 232 | ypos 233 | 280 234 | 235 | 6C68D0BB-A89A-4FDD-BB99-B5A5B460646E 236 | 237 | ypos 238 | 80 239 | 240 | BB06C0C1-DFC1-4B57-9C0F-E56ED87BC903 241 | 242 | ypos 243 | 190 244 | 245 | DA1739EB-62FF-4F27-BA0E-1BFBDC136AC2 246 | 247 | ypos 248 | 140 249 | 250 | 251 | webaddress 252 | http://bananafishsoftware.com/products/spillo 253 | 254 | 255 | -------------------------------------------------------------------------------- /alfred/scripts: -------------------------------------------------------------------------------- 1 | ../scripts/ -------------------------------------------------------------------------------- /scripts/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddeville/spillo-alfred/0f88ad7f90cc8e2773882b12dd2854a603cfab17/scripts/service/__init__.py -------------------------------------------------------------------------------- /scripts/service/alfred.py: -------------------------------------------------------------------------------- 1 | from spillo.emitter import Emitter 2 | 3 | from xml.etree.ElementTree import ( 4 | Element, 5 | SubElement, 6 | tostring, 7 | ) 8 | 9 | 10 | class AlfredEmitter(Emitter): 11 | def generate_empty(self): 12 | return self.generate_output([]) 13 | 14 | def generate_output(self, bookmarks): 15 | items_element = Element('items') 16 | 17 | if not bookmarks: 18 | item_element = SubElement(items_element, 'item', {'valid': 'NO'}) 19 | 20 | title_element = SubElement(item_element, 'title') 21 | title_element.text = 'No Results' 22 | 23 | subtitle_element = SubElement(item_element, 'subtitle') 24 | subtitle_element.text = 'Could not find any bookmark matching your search query' 25 | else: 26 | for bookmark in bookmarks: 27 | item_element = SubElement(items_element, 'item', {'arg': bookmark.url, 'uid': bookmark.identifier}) 28 | 29 | title_element = SubElement(item_element, 'title') 30 | title_element.text = bookmark.title 31 | 32 | subtitle_element = SubElement(item_element, 'subtitle') 33 | subtitle_element.text = bookmark.url 34 | 35 | subtitle_alt_element = SubElement(item_element, 'subtitle', {'mod': 'cmd'}) 36 | subtitle_alt_element.text = 'Open bookmark in Spillo' 37 | 38 | subtitle_alt_element = SubElement(item_element, 'subtitle', {'mod': 'alt'}) 39 | subtitle_alt_element.text = 'Open URL in the background' 40 | 41 | icon_element = SubElement(item_element, 'icon') 42 | icon_element.text = 'document.png' 43 | 44 | copy_element = SubElement(item_element, 'text', {'type': 'copy'}) 45 | copy_element.text = bookmark.url 46 | 47 | largetype_element = SubElement(item_element, 'text', {'type': 'largetype'}) 48 | largetype_element.text = bookmark.title 49 | 50 | return tostring(items_element) 51 | 52 | def generate_error(self, error): 53 | items_element = Element('items') 54 | 55 | item_element = SubElement(items_element, 'item', {'valid': 'NO'}) 56 | 57 | title_element = SubElement(item_element, 'title') 58 | title_element.text = 'There was an error while querying Spillo' 59 | 60 | subtitle_element = SubElement(item_element, 'subtitle') 61 | subtitle_element.text = error 62 | 63 | return tostring(items_element) 64 | -------------------------------------------------------------------------------- /scripts/service/cli.py: -------------------------------------------------------------------------------- 1 | from spillo.emitter import Emitter 2 | 3 | 4 | class CLIEmitter(Emitter): 5 | def generate_output(self, bookmarks): 6 | if not bookmarks: 7 | return "" 8 | else: 9 | output = "" 10 | for bookmark in bookmarks: 11 | output += bookmark.title 12 | output += ' - ' 13 | output += bookmark.url 14 | output += '\n' 15 | return output 16 | -------------------------------------------------------------------------------- /scripts/spillo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import getopt 4 | import sys 5 | 6 | from service.alfred import AlfredEmitter 7 | from service.cli import CLIEmitter 8 | 9 | from spillo.account import retrieve_account_identifiers 10 | from spillo.database import Database, DatabaseException 11 | from spillo.query import Query, QueryException 12 | 13 | 14 | def main(argv): 15 | def _emit(output_str): 16 | sys.stdout.write(output_str + '\n') 17 | 18 | def _emit_message_and_exit(message, exit_code=1): 19 | _emit(message) 20 | sys.exit(exit_code) 21 | 22 | # attempt to parse the command line arguments 23 | try: 24 | s, q = _parse_arguments(argv) 25 | except RuntimeError as e: 26 | _emit_message_and_exit(str(e)) 27 | return 28 | 29 | # get the right emitter based on the service 30 | if s == 'cli': 31 | emitter = CLIEmitter() 32 | elif s == 'alfred': 33 | emitter = AlfredEmitter() 34 | else: 35 | _emit_message_and_exit('unknown service ' + s) 36 | return 37 | 38 | # parse the query and emit an empty response if there is none 39 | try: 40 | query = Query.parse_query(q) 41 | except QueryException: 42 | _emit_message_and_exit(emitter.generate_empty(), 0) 43 | return 44 | 45 | # retrieve the first account identifier 46 | account_identifiers = retrieve_account_identifiers() 47 | if account_identifiers is None or len(account_identifiers) == 0: 48 | _emit_message_and_exit('No account set up in Spillo') 49 | 50 | # create a database, query it and generate some output via the emitter 51 | try: 52 | database = Database(account_identifiers[0]) 53 | output = emitter.generate_output(database.query(query)) 54 | except IOError: 55 | output = emitter.generate_error('Cannot find Spillo database, make sure that Spillo is installed') 56 | except DatabaseException: 57 | output = emitter.generate_error('There was an unknown error while querying the database') 58 | 59 | _emit(output) 60 | 61 | 62 | def _parse_arguments(argv): 63 | """Parse the command line arguments and returns the query""" 64 | usage = 'spillo.py -s -q ' 65 | 66 | try: 67 | opts, args = getopt.getopt(argv, 's:q:', ['service=', 'query=']) 68 | except getopt.GetoptError: 69 | raise RuntimeError(usage) 70 | 71 | query = None 72 | service = None 73 | 74 | for opt, arg in opts: 75 | if opt in ('-s', '--service'): 76 | service = arg 77 | elif opt in ('-q', '--query'): 78 | query = arg 79 | 80 | if service is None or query is None: 81 | raise RuntimeError(usage) 82 | 83 | return service, query 84 | 85 | if __name__ == "__main__": 86 | main(sys.argv[1:]) 87 | -------------------------------------------------------------------------------- /scripts/spillo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddeville/spillo-alfred/0f88ad7f90cc8e2773882b12dd2854a603cfab17/scripts/spillo/__init__.py -------------------------------------------------------------------------------- /scripts/spillo/account.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from Foundation import ( 4 | NSData, 5 | NSLibraryDirectory, 6 | NSPropertyListSerialization, 7 | NSSearchPathForDirectoriesInDomains, 8 | NSUserDomainMask, 9 | ) 10 | 11 | def retrieve_account_identifiers(): 12 | path = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, True).firstObject() 13 | path = os.path.join(path, 14 | 'Group Containers', 15 | 'Q8B696Y8U4.com.ddeville.spillo', 16 | 'Library', 17 | 'Preferences', 18 | 'Q8B696Y8U4.com.ddeville.spillo.plist' 19 | ) 20 | 21 | data = NSData.dataWithContentsOfFile_(path) 22 | if data is None: 23 | return None 24 | 25 | defaults = NSPropertyListSerialization.propertyListWithData_options_format_error_(data, 0, None, None)[0] 26 | if defaults is None: 27 | return None 28 | 29 | accounts = defaults.get("accounts") 30 | if accounts is None: 31 | return None 32 | 33 | return accounts.valueForKey_("identifier") 34 | -------------------------------------------------------------------------------- /scripts/spillo/bookmark.py: -------------------------------------------------------------------------------- 1 | class Bookmark(object): 2 | _url = None 3 | _title = None 4 | _identifier = None 5 | _date = None 6 | 7 | def __init__(self, url, title, identifier, date): 8 | self._url = url 9 | self._title = title 10 | self._identifier = identifier 11 | self._date = date 12 | 13 | @property 14 | def url(self): 15 | return self._url 16 | 17 | @url.setter 18 | def url(self, url): 19 | self._url = url 20 | 21 | @property 22 | def title(self): 23 | return self._title 24 | 25 | @title.setter 26 | def title(self, title): 27 | self._title = title 28 | 29 | @property 30 | def identifier(self): 31 | return self._identifier 32 | 33 | @identifier.setter 34 | def identifier(self, identifier): 35 | self._identifier = identifier 36 | 37 | @property 38 | def date(self): 39 | return self._date 40 | 41 | @date.setter 42 | def date(self, date): 43 | self._date = date 44 | -------------------------------------------------------------------------------- /scripts/spillo/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | from query import ( 5 | QueryGlobal, 6 | QuerySpecific, 7 | ) 8 | from bookmark import Bookmark 9 | 10 | from Foundation import ( 11 | NSLibraryDirectory, 12 | NSSearchPathForDirectoriesInDomains, 13 | NSUserDomainMask, 14 | ) 15 | 16 | 17 | class Database(object): 18 | def __init__(self, account_identifier): 19 | self.connection = sqlite3.connect(self._retrieve_database_path(account_identifier)) 20 | 21 | def query(self, query): 22 | try: 23 | cursor = self.connection.cursor() 24 | if isinstance(query, QueryGlobal): 25 | return self._query_global(cursor, query) 26 | elif isinstance(query, QuerySpecific): 27 | return self._query_specific(cursor, query) 28 | else: 29 | raise DatabaseException('Unexpected query type') 30 | except sqlite3.OperationalError: 31 | raise DatabaseException('There was an unknown error while querying the database') 32 | 33 | # the name or url or desc is like the search term or the tag is exactly the search term 34 | GLOBAL_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND \ 35 | ZTITLE LIKE ? OR ZURL LIKE ? OR ZDESC LIKE ? OR Z_PK IN \ 36 | (SELECT Z_2POSTS FROM Z_2TAGS WHERE Z_3TAGS == \ 37 | (SELECT Z_PK FROM ZPINBOARDTAG WHERE ZTITLE == ? COLLATE NOCASE)) COLLATE NOCASE' 38 | 39 | def _query_global(self, cursor, query): 40 | # construct a search for each word in the query and intersect the queries 41 | queries = [] 42 | params = [] 43 | for word in query.value.split(): 44 | queries.append(Database.GLOBAL_QUERY) 45 | params.append('%' + word + '%') # name 46 | params.append('%' + word + '%') # url 47 | params.append('%' + word + '%') # desc 48 | params.append(word) # tag 49 | 50 | if not queries: 51 | return None 52 | 53 | sql = ' INTERSECT '.join(queries) + ' ORDER BY ZDATE DESC' 54 | cursor.execute(sql, params) 55 | return self._generate_bookmarks(cursor) 56 | 57 | # the name is like the search term 58 | NAME_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND ZTITLE LIKE ? COLLATE NOCASE' 59 | # the url is like the search term 60 | URL_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND ZURL LIKE ? COLLATE NOCASE' 61 | # the desc is like the search term 62 | DESC_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND ZDESC LIKE ? COLLATE NOCASE' 63 | # the tag is exactly the search term 64 | TAG_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND Z_PK IN \ 65 | (SELECT Z_2POSTS FROM Z_2TAGS WHERE Z_3TAGS == \ 66 | (SELECT Z_PK FROM ZPINBOARDTAG WHERE ZTITLE == ? COLLATE NOCASE))' 67 | # the unread status is 1 or 0 68 | UNREAD_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND ZUNREAD == ?' 69 | # the public status is 1 or 0 70 | PUBLIC_QUERY = 'SELECT ZTITLE, ZURL, ZIDENTIFIER, ZDATE FROM ZPINBOARDPOST WHERE ZDELETING=0 AND ZSHARED == ?' 71 | 72 | def _query_specific(self, cursor, query): 73 | queries = [] 74 | params = [] 75 | 76 | def create_and_add_query(sql_query, term): 77 | # construct a query for each word in the search term 78 | for word in term.split(): 79 | queries.append(sql_query) 80 | params.append('%' + word + '%') 81 | 82 | if query.name: 83 | create_and_add_query(Database.NAME_QUERY, query.name) 84 | if query.url: 85 | create_and_add_query(Database.URL_QUERY, query.url) 86 | if query.desc: 87 | create_and_add_query(Database.DESC_QUERY, query.desc) 88 | 89 | if query.tags: 90 | # construct an intersection of queries for each tag 91 | tag_queries = [] 92 | for tag in query.tags: 93 | tag_queries.append(Database.TAG_QUERY) 94 | params.append(tag) 95 | queries.append(' INTERSECT '.join(tag_queries)) 96 | 97 | if query.unread is not None: # check for None specifically since it can be 0 98 | queries.append(Database.UNREAD_QUERY) 99 | params.append(query.unread) 100 | 101 | if query.public is not None: # check for None specifically since it can be 0 102 | queries.append(Database.PUBLIC_QUERY) 103 | params.append(query.public) 104 | 105 | if not queries: 106 | return None 107 | 108 | sql = ' INTERSECT '.join(queries) + ' ORDER BY ZDATE DESC' 109 | cursor.execute(sql, params) 110 | return self._generate_bookmarks(cursor) 111 | 112 | @staticmethod 113 | def _retrieve_database_path(account_identifier): 114 | path = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, True).firstObject() 115 | path = os.path.join(path, 116 | 'Group Containers', 117 | 'Q8B696Y8U4.com.ddeville.spillo', 118 | 'Library', 119 | 'Application Support', 120 | 'Stores', 121 | account_identifier, 122 | 'Core', 123 | 'Pinboard.sqlite' 124 | ) 125 | # attempt to open the file so that we throw if it doesn't exist 126 | with open(path): 127 | pass 128 | return path 129 | 130 | @staticmethod 131 | def _generate_bookmarks(cursor): 132 | bookmarks = [] 133 | for row in cursor: 134 | bookmarks.append(Bookmark(title=row[0], url=row[1], identifier=row[2], date=row[3])) 135 | return bookmarks 136 | 137 | 138 | class DatabaseException(Exception): 139 | pass 140 | -------------------------------------------------------------------------------- /scripts/spillo/emitter.py: -------------------------------------------------------------------------------- 1 | class Emitter(object): 2 | def generate_empty(self): 3 | return "" 4 | 5 | def generate_output(self, bookmarks): 6 | return "" 7 | 8 | def generate_error(self, error): 9 | return error 10 | -------------------------------------------------------------------------------- /scripts/spillo/query.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import unicodedata 4 | 5 | 6 | class Query(object): 7 | @staticmethod 8 | def parse_query(query_string): 9 | """Factory method that parses a query string and returns a query 10 | subclass instance.""" 11 | parser = _QueryParser() 12 | 13 | parser.add_argument('value', nargs='*') 14 | parser.add_argument('-n', '--name', nargs='+', dest='name') 15 | parser.add_argument('-u', '--url', nargs='+', dest='url') 16 | parser.add_argument('-d', '--desc', nargs='+', dest='desc') 17 | parser.add_argument('-t', '--tags', nargs='+', dest='tags') 18 | parser.add_argument('-un', '--unread', nargs='?', dest='unread') 19 | parser.add_argument('-pu', '--public', nargs='?', dest='public') 20 | 21 | # make sure that the unicode query string is decoded 22 | try: 23 | query_string = unicodedata.normalize('NFC', query_string.decode('utf-8')) 24 | except UnicodeDecodeError: 25 | pass 26 | 27 | # try to parse the arguments, if an exception is thrown it's because some args 28 | # are incomplete and we shouldn't return any result until they are 29 | try: 30 | args = vars(parser.parse_args(query_string.split())) 31 | except: 32 | raise QueryException() 33 | 34 | if args['value']: 35 | return QueryGlobal(_format_string_arg(args, 'value')) 36 | 37 | name = _format_string_arg(args, 'name') 38 | url = _format_string_arg(args, 'url') 39 | desc = _format_string_arg(args, 'desc') 40 | tags = args['tags'] 41 | unread = _format_boolean_arg(args, 'unread') 42 | public = _format_boolean_arg(args, 'public') 43 | 44 | return QuerySpecific(name, url, desc, tags, unread, public) 45 | 46 | 47 | def _format_string_arg(args, key): 48 | return ' '.join(args[key]) if args[key] else None 49 | 50 | 51 | def _format_boolean_arg(args, key): 52 | value = args[key] 53 | if value: 54 | if isinstance(value, basestring): 55 | value = value.lower() 56 | if value == 'yes' or value == 'true': 57 | return 1 58 | if value == 'no' or value == 'false': 59 | return 0 60 | try: 61 | return 0 if int(value) == 0 else 1 62 | except (ValueError, TypeError): 63 | pass 64 | return None 65 | 66 | 67 | class QueryGlobal(Query): 68 | _value = None 69 | 70 | def __init__(self, value): 71 | self._value = value 72 | 73 | @property 74 | def value(self): 75 | return self._value 76 | 77 | 78 | class QuerySpecific(Query): 79 | _name = None 80 | _url = None 81 | _desc = None 82 | _tags = None 83 | _unread = None 84 | _public = None 85 | 86 | def __init__(self, name, url, desc, tags, unread, public): 87 | self._name = name 88 | self._url = url 89 | self._desc = desc 90 | self._tags = tags 91 | self._unread = unread 92 | self._public = public 93 | 94 | @property 95 | def name(self): 96 | return self._name 97 | 98 | @property 99 | def url(self): 100 | return self._url 101 | 102 | @property 103 | def desc(self): 104 | return self._desc 105 | 106 | @property 107 | def tags(self): 108 | return self._tags 109 | 110 | @property 111 | def unread(self): 112 | return self._unread 113 | 114 | @property 115 | def public(self): 116 | return self._public 117 | 118 | 119 | class QueryException(Exception): 120 | pass 121 | 122 | 123 | class _QueryParser(argparse.ArgumentParser): 124 | def _get_action_from_name(self, name): 125 | container = self._actions 126 | if name is None: 127 | return None 128 | for action in container: 129 | if '/'.join(action.option_strings) == name: 130 | return action 131 | elif action.metavar == name: 132 | return action 133 | elif action.dest == name: 134 | return action 135 | 136 | def error(self, message): 137 | exc = sys.exc_info()[1] 138 | if exc: 139 | exc.argument = self._get_action_from_name(exc.argument_name) 140 | raise exc 141 | super(_QueryParser, self).error(message) 142 | --------------------------------------------------------------------------------