-
Notifications
You must be signed in to change notification settings - Fork 10
/
ulysses_items.py
347 lines (283 loc) · 12 KB
/
ulysses_items.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
#!/usr/bin/python
# encoding: utf-8
import sys
import os.path
import argparse
import json
from workflow.workflow3 import Workflow3
from workflow.workflow import MATCH_ALL, MATCH_ALLCHARS
from workflow.workflow import ICON_WARNING
import parse_ulysses
from parse_ulysses import ICLOUD_GROUPS_ROOT, ICLOUD_UNFILED_ROOT,\
LOCAL_GROUPS_ROOT, LOCAL_UNFILED_ROOT
"""Return Alfred items representing Ulysses groups and/or sheets.
See parser below.
"""
UPDATE_SETTINGS = {'github_slug': 'robwalton/alfred-ulysses-workflow'}
HELP_URL = 'https://github.com/robwalton/alfred-ulysses-workflow'
ICON_UPDATE = 'update-available.png'
GROUP_BULLET = u'\u25B6' # black triangle
INBOX_BULLET = u'\u25B7' # white triangle
INBOX_SHEET_BULLET = u'\u25E6' # white bullet
logger = None
EXTRA_DEBUG = False
def main(wf):
# Parse args
parser = argparse.ArgumentParser()
parser.add_argument('query', type=unicode, nargs='?', default=None,
help='query used normally for searching')
parser.add_argument('--kind', dest='kind', type=str, nargs='?',
help='items to return: group, sheet or all')
parser.add_argument('--limit-scope-to-dir', dest='limit_scope_dir',
nargs='?',
help='limit search to directory on file system')
parser.add_argument('--search-content', dest='search_content',
action='store_true',
help='search inside content')
parser.add_argument('--search-ulysses-path', dest='search_ulysses_path',
action='store_true',
help='search full path to item, not just node name')
args = parser.parse_args(wf.args)
logger.info('~' * 79)
logger.info('ulysses_items.main(wf): args = \n' + str(args))
logger.info('~' * 79)
validify_args(args)
# Check for updates
check_for_workflow_update(wf)
# Parse entire ulysses data structure
include_groups = args.kind in ('group', 'all')
include_sheets = args.kind in ('sheet', 'all')
groups = []
sheets = []
if not os.path.exists(ICLOUD_GROUPS_ROOT):
logger.warn("No iCloud library found at '%s'" % ICLOUD_GROUPS_ROOT)
wf.add_item('No iCloud items found and external folders not supported',
icon=ICON_WARNING)
for rootdir, label in [
(ICLOUD_GROUPS_ROOT, 'iCloud'),
(ICLOUD_UNFILED_ROOT, 'iCloud Inbox'),
(LOCAL_GROUPS_ROOT, 'local'),
(LOCAL_UNFILED_ROOT, 'local Inbox'),
]:
if os.path.exists(rootdir):
logger.info("Added %s items from '%s'" % (label, rootdir))
more_groups, more_sheets = parse_ulysses_for_groups_and_sheets(
rootdir, args.limit_scope_dir, include_groups,
include_sheets)
groups.extend(more_groups)
sheets.extend(more_sheets)
else:
logger.info("No %s items found at '%s'" % (label, rootdir))
# filter on internal conent if applicable. Only really impacts sheets, but
# use method on groups for simplicity.
if args.search_content and args.query:
groups, sheets = filter_based_on_content(groups, sheets, args.query)
# Merge groups and sheets to create a single list of nodes
nodes = groups + sheets
# Filter nodes using fuzzy matching for {query}
if args.query and not args.search_content:
search_whole_path = args.search_ulysses_path or args.kind == 'group'
nodes = fuzzy_filter_nodes(wf, nodes, args.query, search_whole_path)
# Show error if there are no results. Otherwise, Alfred will show
# its fallback searches (i.e. "Search Google for 'XYZ'")
if not nodes:
wf.add_item('No items', icon=ICON_WARNING)
# Create, add and modify item to represent node
for node in nodes:
item = add_ulysses_item_to_wf_results(wf, args, node)
# note that the wf keeps a copy of the item; we need only modify it
item = add_modifier_to_go_up_hierarchy(args, node, item)
if node.is_group:
item = add_modifier_to_drill_down_hierarchy(args, node, item)
wf.send_feedback()
def validify_args(args):
"""Validate args"""
assert args.kind in ('group', 'sheet', 'all')
if args.limit_scope_dir:
assert os.path.exists(args.limit_scope_dir), \
"Path does not exist: '%s'" % args.limit_scope_dir
if args.query:
args.query = args.query.strip()
if args.search_ulysses_path and args.search_content:
raise Exception('search-ulysses-path incompatible with search-content')
def check_for_workflow_update(wf):
if wf.update_available:
wf.add_item('A newer version is available',
'↩ to install update',
autocomplete='wf:update',
icon=ICON_UPDATE)
def parse_ulysses_for_groups_and_sheets(
root_dir, limit_scope_dir, include_groups, include_sheets):
"""Parse entire Ulysses trees and return list of groups and sheets"""
# Get ulysses groups & sheets from iCloud
groups_tree = parse_ulysses.create_tree(root_dir, None)
if limit_scope_dir:
try:
group_to_search = parse_ulysses.find_group_by_path(groups_tree,
limit_scope_dir)
groups = group_to_search.child_groups
sheets = group_to_search.child_sheets
except KeyError:
groups = []
sheets = []
else:
groups, sheets = parse_ulysses.walk(groups_tree)
if not include_groups:
groups = []
if not include_sheets:
sheets = []
return groups, sheets
def filter_based_on_content(groups, sheets, query):
"""Filter lists of groups and sheets.
Return only items which are also find by spotlight's mdfind.
"""
logger.info('>>> Filtering content with "%s"' % query)
groups = parse_ulysses.filter_groups(groups, query)
sheets = parse_ulysses.filter_sheets(sheets, query)
return groups, sheets
def fuzzy_filter_nodes(wf, nodes, query, search_whole_path):
"""Filter list of nodes with query.
If search_whole_path is true then search the Ulysses path for query,
otherwise just the name of the sheet or group.
"""
def expanded_node_path(node):
path_list = node.get_alfred_path_list()
path_list.append(node.title)
if EXTRA_DEBUG:
logger.info(' '.join(path_list))
return ' '.join(path_list)
def node_title(node):
return node.title
if search_whole_path:
key_func = expanded_node_path
else:
key_func = node_title
logger.info('Fuzzy matching with query="%s" and key func="%s"'
% (query, key_func.__name__))
# See: http://www.deanishe.net/alfred-workflow/user-manual/filtering.html
nodes = wf.filter(query, nodes, key_func,
match_on=MATCH_ALL ^ MATCH_ALLCHARS)
return nodes
def alfredworkflow(arg, node_type='', search_in='', content_query='',
kind_requested='', ulysses_path=''):
"""
Return JSON dictionary used instead of a normal Ulysses item's arg.
Used to return both arg and workflow environment variables. See:
https://www.alfredforum.com/topic/9070-how-to-workflowenvironment-variables/
- arg: the normal Alfred argument used to create {query} downstream
- node_type: 'sheet' or 'group'
- search_in: a group's location on disk (TODO: odd name)
- content_query: query used to search content, or ''
- kind_requested: record of nodes requested: 'sheet', 'group' or 'all'
- ulysses_path - the Ulysses path of item. See:
https://ulyssesapp.com/kb/x-callback-url/#paths
"""
variables_dict = dict(locals())
del variables_dict['arg'] # handled differently
return json.dumps(
{'alfredworkflow': {'arg': arg, 'variables': variables_dict}})
def add_ulysses_item_to_wf_results(wf, args, node):
"""Create and add Ulysses item to workflow results for this call.
Return item for subsequent modification
"""
pathlist = path_list_from_main(node)
ulysses_path = '/'.join([''] + pathlist)
if node.is_group:
ulysses_path += '/' + node.name
if ulysses_path == '/Inbox':
bullet = INBOX_BULLET
else:
bullet = GROUP_BULLET
title = bullet + ' ' + node.name
node_type = 'group'
metadata = ' (%i)' % node.number_descendents()
elif node.is_sheet:
if node.first_line.strip() == '':
name = '< first line blank >'
else:
name = node.first_line.replace('#', '').strip()
if ulysses_path == '/Inbox':
title = ' ' + INBOX_SHEET_BULLET + ' ' + name
else:
title = ' ' + name
node_type = 'sheet'
metadata = ''
else:
assert False
content_query = args.query if args.search_content else ''
item = wf.add_item(
title,
subtitle=' ' + '/' + '/'.join(pathlist) + metadata,
arg=alfredworkflow(node.openable_file, node_type,
content_query=content_query,
kind_requested=args.kind,
ulysses_path=ulysses_path),
autocomplete=node.name if node.is_group else node.first_line,
valid=True,
uid=node.openable_file,
icon=node.openable_file,
icontype='fileicon')
return item
def add_modifier_to_go_up_hierarchy(args, node, item):
"""Add shift modifier to Ulysses item to request move up hierarchy."""
ancestors = list(node.get_ancestors())
try:
current_group = ancestors.pop() # this is actually the group we are in
next_group_up = ancestors.pop()
path_list = path_list_from_main(current_group)
next_group_up_path = '/' + '/'.join(path_list)
except IndexError:
next_group_up = None
content_query = args.query if args.search_content else ''
if next_group_up:
arg = alfredworkflow('', 'group',
search_in=next_group_up.dirpath,
content_query=content_query,
kind_requested=args.kind)
item.add_modifier('shift',
subtitle=' Go up into: ' + next_group_up_path,
arg=arg)
else:
item.add_modifier('shift',
subtitle=' < at top level >',
valid=False)
return item
def add_modifier_to_drill_down_hierarchy(args, node, item):
"""Add shift modifier to Ulysses item to request move down hierarchy."""
contains_another_group = len(node.child_groups) > 0
contains_sheets = len(node.child_sheets) > 0
# work out if group should be drilled down into
if args.kind == 'group':
drillable = contains_another_group
elif args.kind == 'all':
drillable = contains_another_group or contains_sheets
else:
assert False
content_query = args.query if args.search_content else ''
if drillable:
pathlist = path_list_from_main(node)
subtitle = ' Go into: ' + '/'.join(pathlist) + '/' + node.name
arg = alfredworkflow('', 'group', search_in=node.dirpath,
content_query=content_query,
kind_requested=args.kind)
item.add_modifier('cmd', subtitle=subtitle, arg=arg)
else:
if args.kind == 'group':
subtitle = '< no groups >'
elif args.kind == 'all':
subtitle = '< empty >'
else:
assert False
item.add_modifier('cmd', subtitle=' ' + subtitle, valid=False)
def path_list_from_main(node):
"""Return Ulysses path as list, not including the inetrnally named Main node"""
pathlist = node.get_alfred_path_list()
if pathlist and (pathlist[0] == 'Main'):
pathlist = pathlist[1:]
return pathlist
if __name__ == "__main__":
wf = Workflow3(help_url=HELP_URL,
update_settings=UPDATE_SETTINGS)
wf.magic_prefix = 'wf:'
logger = wf.logger
sys.exit(wf.run(main))