├── README.md ├── extract-annotations.py ├── get-calendar-dates.js ├── get-calendar.sh ├── get-completed-tasks.js ├── get-timing-summary.js └── write-to-obsidian.js /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian scripts 2 | 3 | This repository is a collection of small scripts that I use with [Obsidian](https://obsidian.md). For now it's Mac OS X only and requires [icalbuddy](https://hasseg.org/icalBuddy/) & [Keyboard Meastro](https://www.keyboardmaestro.com/main/). 4 | 5 | ## Completed Omnifocus tasks to Obsidian 6 | 7 | The `get-completed-tasks.js` get called within Obsidian with the help of Keyboard Meastro. This should pop-up a dialog asking you for a date to get the completed tasks 8 | 9 | ## Timing summary to Obsidian 10 | 11 | The `get-timing.js` get called within Obsidian with the help of Keyboard Meastro. This should pop-up a dialog asking you for a date and get a summary of the [Timing App](https://timingapp.com/) on my productivity. (_note: you will need a premium version of Timing with scripting support_) 12 | 13 | ## Get calendar appointments 14 | 15 | The `get-calendar-dates.js` get called within Obsidian with the help of Keyboard Meastro. This should pop-up a dialog asking you for a date that is then put into the `get-calendar.sh` script that uses icalbuddy to fetch the calendar appointments for that date, excluding some calendars. 16 | 17 | ## Extract annotations 18 | 19 | This script is a work in progress and extracts the highlights from a PDF to create a markdown file which can be used with Obsidian (including a backlink to the file itself) -------------------------------------------------------------------------------- /extract-annotations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Extracts annotations from a PDF file in markdown format for use in reviewing. 6 | """ 7 | 8 | import sys, io, textwrap, argparse 9 | from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter 10 | from pdfminer.pdfpage import PDFPage 11 | from pdfminer.layout import LAParams, LTContainer, LTAnno, LTChar, LTTextBox 12 | from pdfminer.converter import TextConverter 13 | from pdfminer.pdfparser import PDFParser 14 | from pdfminer.pdfdocument import PDFDocument, PDFNoOutlines 15 | from pdfminer.psparser import PSLiteralTable, PSLiteral 16 | import pdfminer.pdftypes as pdftypes 17 | import pdfminer.settings 18 | import pdfminer.utils 19 | 20 | pdfminer.settings.STRICT = False 21 | 22 | SUBSTITUTIONS = { 23 | u'ff': 'ff', 24 | u'fi': 'fi', 25 | u'fl': 'fl', 26 | u'ffi': 'ffi', 27 | u'ffl': 'ffl', 28 | u'‘': "'", 29 | u'’': "'", 30 | u'“': '"', 31 | u'”': '"', 32 | u'…': '...', 33 | } 34 | 35 | ANNOT_SUBTYPES = frozenset({'Text', 'Highlight', 'Squiggly', 'StrikeOut', 'Underline'}) 36 | ANNOT_NITS = frozenset({'Squiggly', 'StrikeOut', 'Underline'}) 37 | 38 | COLUMNS_PER_PAGE = 2 # default only, changed via a command-line parameter 39 | 40 | DEBUG_BOXHIT = False 41 | 42 | def boxhit(item, box): 43 | (x0, y0, x1, y1) = box 44 | assert item.x0 <= item.x1 and item.y0 <= item.y1 45 | assert x0 <= x1 and y0 <= y1 46 | 47 | # does most of the item area overlap the box? 48 | # http://math.stackexchange.com/questions/99565/simplest-way-to-calculate-the-intersect-area-of-two-rectangles 49 | x_overlap = max(0, min(item.x1, x1) - max(item.x0, x0)) 50 | y_overlap = max(0, min(item.y1, y1) - max(item.y0, y0)) 51 | overlap_area = x_overlap * y_overlap 52 | item_area = (item.x1 - item.x0) * (item.y1 - item.y0) 53 | assert overlap_area <= item_area 54 | 55 | if DEBUG_BOXHIT and overlap_area != 0: 56 | print("'%s' %f-%f,%f-%f in %f-%f,%f-%f %2.0f%%" % 57 | (item.get_text(), item.x0, item.x1, item.y0, item.y1, x0, x1, y0, y1, 58 | 100 * overlap_area / item_area)) 59 | 60 | if item_area == 0: 61 | return False 62 | else: 63 | return overlap_area >= 0.5 * item_area 64 | 65 | class RectExtractor(TextConverter): 66 | def __init__(self, rsrcmgr, codec='utf-8', pageno=1, laparams=None): 67 | dummy = io.StringIO() 68 | TextConverter.__init__(self, rsrcmgr, outfp=dummy, codec=codec, pageno=pageno, laparams=laparams) 69 | self.annots = set() 70 | 71 | def setannots(self, annots): 72 | self.annots = {a for a in annots if a.boxes} 73 | 74 | # main callback from parent PDFConverter 75 | def receive_layout(self, ltpage): 76 | self._lasthit = frozenset() 77 | self._curline = set() 78 | self.render(ltpage) 79 | 80 | def testboxes(self, item): 81 | hits = frozenset({a for a in self.annots if any({boxhit(item, b) for b in a.boxes})}) 82 | self._lasthit = hits 83 | self._curline.update(hits) 84 | return hits 85 | 86 | # "broadcast" newlines to _all_ annotations that received any text on the 87 | # current line, in case they see more text on the next line, even if the 88 | # most recent character was not covered. 89 | def capture_newline(self): 90 | for a in self._curline: 91 | a.capture('\n') 92 | self._curline = set() 93 | 94 | def render(self, item): 95 | # If it's a container, recurse on nested items. 96 | if isinstance(item, LTContainer): 97 | for child in item: 98 | self.render(child) 99 | 100 | # Text boxes are a subclass of container, and somehow encode newlines 101 | # (this weird logic is derived from pdfminer.converter.TextConverter) 102 | if isinstance(item, LTTextBox): 103 | self.testboxes(item) 104 | self.capture_newline() 105 | 106 | # Each character is represented by one LTChar, and we must handle 107 | # individual characters (not higher-level objects like LTTextLine) 108 | # so that we can capture only those covered by the annotation boxes. 109 | elif isinstance(item, LTChar): 110 | for a in self.testboxes(item): 111 | a.capture(item.get_text()) 112 | 113 | # Annotations capture whitespace not explicitly encoded in 114 | # the text. They don't have an (X,Y) position, so we need some 115 | # heuristics to match them to the nearby annotations. 116 | elif isinstance(item, LTAnno): 117 | text = item.get_text() 118 | if text == '\n': 119 | self.capture_newline() 120 | else: 121 | for a in self._lasthit: 122 | a.capture(text) 123 | 124 | 125 | class Page: 126 | def __init__(self, pageno, mediabox): 127 | self.pageno = pageno 128 | self.mediabox = mediabox 129 | self.annots = [] 130 | 131 | def __eq__(self, other): 132 | return self.pageno == other.pageno 133 | 134 | def __lt__(self, other): 135 | return self.pageno < other.pageno 136 | 137 | 138 | class Annotation: 139 | def __init__(self, page, tagname, coords=None, rect=None, contents=None, author=None): 140 | self.page = page 141 | self.tagname = tagname 142 | if contents == '': 143 | self.contents = None 144 | else: 145 | self.contents = contents 146 | self.rect = rect 147 | self.author = author 148 | self.text = '' 149 | 150 | if coords is None: 151 | self.boxes = None 152 | else: 153 | assert len(coords) % 8 == 0 154 | self.boxes = [] 155 | while coords != []: 156 | (x0,y0,x1,y1,x2,y2,x3,y3) = coords[:8] 157 | coords = coords[8:] 158 | xvals = [x0, x1, x2, x3] 159 | yvals = [y0, y1, y2, y3] 160 | box = (min(xvals), min(yvals), max(xvals), max(yvals)) 161 | self.boxes.append(box) 162 | 163 | def capture(self, text): 164 | if text == '\n': 165 | # Kludge for latex: elide hyphens 166 | if self.text.endswith('-'): 167 | self.text = self.text[:-1] 168 | 169 | # Join lines, treating newlines as space, while ignoring successive 170 | # newlines. This makes it easier for the for the renderer to 171 | # "broadcast" LTAnno newlines to active annotations regardless of 172 | # box hits. (Detecting paragraph breaks is tricky anyway, and left 173 | # for future future work!) 174 | elif not self.text.endswith(' '): 175 | self.text += ' ' 176 | else: 177 | self.text += text 178 | 179 | def gettext(self): 180 | if self.boxes: 181 | if self.text: 182 | # replace tex ligatures (and other common odd characters) 183 | return ''.join([SUBSTITUTIONS.get(c, c) for c in self.text.strip()]) 184 | else: 185 | # something's strange -- we have boxes but no text for them 186 | return "(XXX: missing text!)" 187 | else: 188 | return None 189 | 190 | def getstartpos(self): 191 | if self.rect: 192 | (x0, y0, x1, y1) = self.rect 193 | elif self.boxes: 194 | (x0, y0, x1, y1) = self.boxes[0] 195 | else: 196 | return None 197 | # XXX: assume left-to-right top-to-bottom text 198 | return Pos(self.page, min(x0, x1), max(y0, y1)) 199 | 200 | # custom < operator for sorting 201 | def __lt__(self, other): 202 | return self.getstartpos() < other.getstartpos() 203 | 204 | 205 | class Pos: 206 | def __init__(self, page, x, y): 207 | self.page = page 208 | self.x = x 209 | self.y = y 210 | 211 | def __lt__(self, other): 212 | if self.page < other.page: 213 | return True 214 | elif self.page == other.page: 215 | assert self.page is other.page 216 | # XXX: assume left-to-right top-to-bottom documents 217 | (sx, sy) = self.normalise_to_mediabox() 218 | (ox, oy) = other.normalise_to_mediabox() 219 | (x0, y0, x1, y1) = self.page.mediabox 220 | colwidth = (x1 - x0) / COLUMNS_PER_PAGE 221 | self_col = (sx - x0) // colwidth 222 | other_col = (ox - x0) // colwidth 223 | return self_col < other_col or (self_col == other_col and sy > oy) 224 | else: 225 | return False 226 | 227 | def normalise_to_mediabox(self): 228 | x, y = self.x, self.y 229 | (x0, y0, x1, y1) = self.page.mediabox 230 | if x < x0: 231 | x = x0 232 | elif x > x1: 233 | x = x1 234 | if y < y0: 235 | y = y0 236 | elif y > y1: 237 | y = y1 238 | return (x, y) 239 | 240 | 241 | def getannots(pdfannots, page): 242 | annots = [] 243 | for pa in pdfannots: 244 | subtype = pa.get('Subtype') 245 | if subtype is not None and subtype.name not in ANNOT_SUBTYPES: 246 | continue 247 | 248 | contents = pa.get('Contents') 249 | if contents is not None: 250 | # decode as string, normalise line endings, replace special characters 251 | contents = pdfminer.utils.decode_text(contents) 252 | contents = contents.replace('\r\n', '\n').replace('\r', '\n') 253 | contents = ''.join([SUBSTITUTIONS.get(c, c) for c in contents]) 254 | 255 | coords = pdftypes.resolve1(pa.get('QuadPoints')) 256 | rect = pdftypes.resolve1(pa.get('Rect')) 257 | author = pdftypes.resolve1(pa.get('T')) 258 | if author is not None: 259 | author = pdfminer.utils.decode_text(author) 260 | a = Annotation(page, subtype.name, coords, rect, contents, author=author) 261 | annots.append(a) 262 | 263 | return annots 264 | 265 | 266 | class PrettyPrinter: 267 | """ 268 | Pretty-print the extracted annotations according to the output options. 269 | """ 270 | def __init__(self, outlines, wrapcol): 271 | """ 272 | outlines List of outlines 273 | wrapcol If not None, specifies the column at which output is word-wrapped 274 | """ 275 | self.outlines = outlines 276 | self.wrapcol = wrapcol 277 | 278 | self.BULLET_INDENT1 = " * " 279 | self.BULLET_INDENT2 = " " 280 | self.QUOTE_INDENT = self.BULLET_INDENT2 + "> " 281 | 282 | if wrapcol: 283 | # for bullets, we need two text wrappers: one for the leading bullet on the first paragraph, one without 284 | self.bullet_tw1 = textwrap.TextWrapper( 285 | width=wrapcol, 286 | initial_indent=self.BULLET_INDENT1, 287 | subsequent_indent=self.BULLET_INDENT2) 288 | 289 | self.bullet_tw2 = textwrap.TextWrapper( 290 | width=wrapcol, 291 | initial_indent=self.BULLET_INDENT2, 292 | subsequent_indent=self.BULLET_INDENT2) 293 | 294 | # for blockquotes, each line is prefixed with "> " 295 | self.quote_tw = textwrap.TextWrapper( 296 | width=wrapcol, 297 | initial_indent=self.QUOTE_INDENT, 298 | subsequent_indent=self.QUOTE_INDENT) 299 | 300 | def nearest_outline(self, pos): 301 | prev = None 302 | for o in self.outlines: 303 | if o.pos < pos: 304 | prev = o 305 | else: 306 | break 307 | return prev 308 | 309 | def format_pos(self, annot): 310 | apos = annot.getstartpos() 311 | o = self.nearest_outline(apos) if apos else None 312 | if o: 313 | return "Page %d (%s)" % (annot.page.pageno + 1, o.title) 314 | else: 315 | return "Page %d" % (annot.page.pageno + 1) 316 | 317 | # format a Markdown bullet, wrapped as desired 318 | def format_bullet(self, paras, quotepos=None, quotelen=None): 319 | # quotepos/quotelen specify the first paragraph (if any) to be formatted 320 | # as a block-quote, and the length of the blockquote in paragraphs 321 | if quotepos: 322 | assert quotepos > 0 323 | assert quotelen > 0 324 | assert quotepos + quotelen <= len(paras) 325 | 326 | # emit the first paragraph with the bullet 327 | if self.wrapcol: 328 | ret = self.bullet_tw1.fill(paras[0]) 329 | else: 330 | ret = self.BULLET_INDENT1 + paras[0] 331 | 332 | # emit subsequent paragraphs 333 | npara = 1 334 | for para in paras[1:]: 335 | # are we in a blockquote? 336 | inquote = quotepos and npara >= quotepos and npara < quotepos + quotelen 337 | 338 | # emit a paragraph break 339 | # if we're going straight to a quote, we don't need an extra newline 340 | ret = ret + ('\n' if npara == quotepos else '\n\n') 341 | 342 | if self.wrapcol: 343 | tw = self.quote_tw if inquote else self.bullet_tw2 344 | ret = ret + tw.fill(para) 345 | else: 346 | indent = self.QUOTE_INDENT if inquote else self.BULLET_INDENT2 347 | ret = ret + indent + para 348 | 349 | npara += 1 350 | 351 | return ret 352 | 353 | def format_annot(self, annot, extra=None): 354 | # capture item text and contents (i.e. the comment), and split each into paragraphs 355 | rawtext = annot.gettext() 356 | text = [l for l in rawtext.strip().splitlines() if l] if rawtext else [] 357 | comment = [l for l in annot.contents.splitlines() if l] if annot.contents else [] 358 | 359 | # we are either printing: item text and item contents, or one of the two 360 | # if we see an annotation with neither, something has gone wrong 361 | assert text or comment 362 | 363 | # compute the formatted position (and extra bit if needed) as a label 364 | label = self.format_pos(annot) + (" " + extra if extra else "") + ":" 365 | 366 | # If we have short (single-paragraph, few words) text with a short or no 367 | # comment, and the text contains no embedded full stops or quotes, then 368 | # we'll just put quotation marks around the text and merge the two into 369 | # a single paragraph. 370 | if (text and len(text) == 1 and len(text[0].split()) <= 10 # words 371 | and all([x not in text[0] for x in ['"', '. ']]) 372 | and (not comment or len(comment) == 1)): 373 | msg = label + ' "' + text[0] + '"' 374 | if comment: 375 | msg = msg + ' -- ' + comment[0] 376 | return self.format_bullet([msg]) + "\n" 377 | 378 | # If there is no text and a single-paragraph comment, it also goes on 379 | # one line. 380 | elif comment and not text and len(comment) == 1: 381 | msg = label + " " + comment[0] 382 | return self.format_bullet([msg]) + "\n" 383 | 384 | # Otherwise, text (if any) turns into a blockquote, and the comment (if 385 | # any) into subsequent paragraphs. 386 | else: 387 | msgparas = [label] + text + comment 388 | quotepos = 1 if text else None 389 | quotelen = len(text) if text else None 390 | return self.format_bullet(msgparas, quotepos, quotelen) + "\n" 391 | 392 | def printall(self, annots, outfile): 393 | for a in annots: 394 | print(self.format_annot(a, a.tagname), file=outfile) 395 | 396 | def printall_grouped(self, sections, annots, outfile): 397 | """ 398 | sections controls the order of sections output 399 | e.g.: ["highlights", "comments", "nits"] 400 | """ 401 | self._printheader_called = False 402 | 403 | def printheader(name): 404 | # emit blank separator line if needed 405 | if self._printheader_called: 406 | print("", file=outfile) 407 | else: 408 | self._printheader_called = True 409 | print("## " + name + "\n", file=outfile) 410 | 411 | highlights = [a for a in annots if a.tagname == 'Highlight' and a.contents is None] 412 | comments = [a for a in annots if a.tagname not in ANNOT_NITS and a.contents] 413 | nits = [a for a in annots if a.tagname in ANNOT_NITS] 414 | 415 | for secname in sections: 416 | if highlights and secname == 'highlights': 417 | printheader("Highlights") 418 | for a in highlights: 419 | print(self.format_annot(a), file=outfile) 420 | 421 | if comments and secname == 'comments': 422 | printheader("Detailed comments") 423 | for a in comments: 424 | print(self.format_annot(a), file=outfile) 425 | 426 | if nits and secname == 'nits': 427 | printheader("Nits") 428 | for a in nits: 429 | if a.tagname == 'StrikeOut': 430 | extra = "delete" 431 | else: 432 | extra = None 433 | print(self.format_annot(a, extra), file=outfile) 434 | 435 | 436 | def resolve_dest(doc, dest): 437 | if isinstance(dest, bytes): 438 | dest = pdftypes.resolve1(doc.get_dest(dest)) 439 | elif isinstance(dest, PSLiteral): 440 | dest = pdftypes.resolve1(doc.get_dest(dest.name)) 441 | if isinstance(dest, dict): 442 | dest = dest['D'] 443 | return dest 444 | 445 | class Outline: 446 | def __init__(self, title, dest, pos): 447 | self.title = title 448 | self.dest = dest 449 | self.pos = pos 450 | 451 | def get_outlines(doc, pageslist, pagesdict): 452 | result = [] 453 | for (_, title, destname, actionref, _) in doc.get_outlines(): 454 | if destname is None and actionref: 455 | action = pdftypes.resolve1(actionref) 456 | if isinstance(action, dict): 457 | subtype = action.get('S') 458 | if subtype is PSLiteralTable.intern('GoTo'): 459 | destname = action.get('D') 460 | if destname is None: 461 | continue 462 | dest = resolve_dest(doc, destname) 463 | 464 | # consider targets of the form [page /XYZ left top zoom] 465 | if dest[1] is PSLiteralTable.intern('XYZ'): 466 | (pageref, _, targetx, targety) = dest[:4] 467 | 468 | if type(pageref) is int: 469 | page = pageslist[pageref] 470 | elif isinstance(pageref, pdftypes.PDFObjRef): 471 | page = pagesdict[pageref.objid] 472 | else: 473 | sys.stderr.write('Warning: unsupported pageref in outline: %s\n' % pageref) 474 | page = None 475 | 476 | if page: 477 | pos = Pos(page, targetx, targety) 478 | result.append(Outline(title, destname, pos)) 479 | return result 480 | 481 | 482 | def process_file(fh, emit_progress): 483 | rsrcmgr = PDFResourceManager() 484 | laparams = LAParams() 485 | device = RectExtractor(rsrcmgr, laparams=laparams) 486 | interpreter = PDFPageInterpreter(rsrcmgr, device) 487 | parser = PDFParser(fh) 488 | doc = PDFDocument(parser) 489 | 490 | pageslist = [] # pages in page order 491 | pagesdict = {} # map from PDF page object ID to Page object 492 | allannots = [] 493 | 494 | for (pageno, pdfpage) in enumerate(PDFPage.create_pages(doc)): 495 | page = Page(pageno, pdfpage.mediabox) 496 | pageslist.append(page) 497 | pagesdict[pdfpage.pageid] = page 498 | if pdfpage.annots: 499 | # emit progress indicator 500 | if emit_progress: 501 | sys.stderr.write((" " if pageno > 0 else "") + "%d" % (pageno + 1)) 502 | sys.stderr.flush() 503 | 504 | pdfannots = [] 505 | for a in pdftypes.resolve1(pdfpage.annots): 506 | if isinstance(a, pdftypes.PDFObjRef): 507 | pdfannots.append(a.resolve()) 508 | else: 509 | sys.stderr.write('Warning: unknown annotation: %s\n' % a) 510 | 511 | page.annots = getannots(pdfannots, page) 512 | page.annots.sort() 513 | device.setannots(page.annots) 514 | interpreter.process_page(pdfpage) 515 | allannots.extend(page.annots) 516 | 517 | if emit_progress: 518 | sys.stderr.write("\n") 519 | 520 | outlines = [] 521 | try: 522 | outlines = get_outlines(doc, pageslist, pagesdict) 523 | except PDFNoOutlines: 524 | if emit_progress: 525 | sys.stderr.write("Document doesn't include outlines (\"bookmarks\")\n") 526 | except Exception as ex: 527 | sys.stderr.write("Warning: failed to retrieve outlines: %s\n" % ex) 528 | 529 | device.close() 530 | 531 | return (allannots, outlines) 532 | 533 | 534 | def parse_args(): 535 | p = argparse.ArgumentParser(description=__doc__) 536 | 537 | p.add_argument("input", metavar="INFILE", type=argparse.FileType("rb"), 538 | help="PDF files to process", nargs='+') 539 | 540 | g = p.add_argument_group('Basic options') 541 | g.add_argument("-p", "--progress", default=False, action="store_true", 542 | help="emit progress information") 543 | g.add_argument("-o", metavar="OUTFILE", type=argparse.FileType("w"), dest="output", 544 | default=sys.stdout, help="output file (default is stdout)") 545 | g.add_argument("-n", "--cols", default=2, type=int, metavar="COLS", dest="cols", 546 | help="number of columns per page in the document (default: 2)") 547 | 548 | g = p.add_argument_group('Options controlling output format') 549 | allsects = ["highlights", "comments", "nits"] 550 | g.add_argument("-s", "--sections", metavar="SEC", nargs="*", 551 | choices=allsects, default=allsects, 552 | help=("sections to emit (default: %s)" % ', '.join(allsects))) 553 | g.add_argument("--no-group", dest="group", default=True, action="store_false", 554 | help="emit annotations in order, don't group into sections") 555 | g.add_argument("--print-filename", dest="printfilename", default=False, action="store_true", 556 | help="print the filename when it has annotations") 557 | g.add_argument("-w", "--wrap", metavar="COLS", type=int, 558 | help="wrap text at this many output columns") 559 | 560 | return p.parse_args() 561 | 562 | 563 | def main(): 564 | args = parse_args() 565 | 566 | global COLUMNS_PER_PAGE 567 | COLUMNS_PER_PAGE = args.cols 568 | 569 | for file in args.input: 570 | (annots, outlines) = process_file(file, args.progress) 571 | 572 | pp = PrettyPrinter(outlines, args.wrap) 573 | 574 | if args.printfilename and annots: 575 | print("# File: '%s'\n" % file.name) 576 | 577 | if args.group: 578 | pp.printall_grouped(args.sections, annots, args.output) 579 | else: 580 | pp.printall(annots, args.output) 581 | 582 | return 0 583 | 584 | 585 | if __name__ == "__main__": 586 | sys.exit(main()) -------------------------------------------------------------------------------- /get-calendar-dates.js: -------------------------------------------------------------------------------- 1 | // 2 | // Build a summary of Calendar events 3 | // 4 | // This is intended to be run as a TextExpander macro, 5 | // but will work anywhere you can invoke a JS script. 6 | // with "osascript -l JavaScript" 7 | // 8 | // v 1.0.2 (full release history at bottom) 9 | 10 | var app = Application.currentApplication() 11 | var calendars = Application("Calendar").calendars 12 | app.includeStandardAdditions = true 13 | 14 | Date.prototype.valid = function() { 15 | return isFinite(this); 16 | } 17 | 18 | Date.prototype.toIsoString = function() { 19 | var tzo = -this.getTimezoneOffset(), 20 | dif = tzo >= 0 ? '+' : '-', 21 | pad = function(num) { 22 | var norm = Math.floor(Math.abs(num)); 23 | return (norm < 10 ? '0' : '') + norm; 24 | }; 25 | return this.getFullYear() + 26 | '-' + pad(this.getMonth() + 1) + 27 | '-' + pad(this.getDate()) 28 | } 29 | 30 | var dates = getDates(); 31 | 32 | function getDates() { 33 | try { 34 | // Ask for a date 35 | var dateString = app.displayDialog('Fetch tasks for which date? (defaults to today if empty)', { defaultAnswer: "" }); 36 | 37 | if (dateString.textReturned === "") { 38 | var startDate = new Date() 39 | } else { 40 | var startDate = new Date(Date.parse(dateString.textReturned)) 41 | } 42 | 43 | startDate.setHours(0, 0, 0, 0); 44 | 45 | if (!startDate.valid()){ 46 | app.displayNotification('Did you try (YYYY-MM-DD)', { withTitle: "Invalid date" }); throw "Invalid date provided"; 47 | } 48 | 49 | var endDate = new Date(new Date().setDate(startDate.getDate())) 50 | endDate.setHours(23, 59, 59, 0) 51 | 52 | return { 53 | startDate: startDate.toIsoString(), 54 | endDate: endDate.toIsoString(), 55 | } 56 | } catch (err) { 57 | console.log(err) 58 | } 59 | } 60 | 61 | `${dates.startDate}` -------------------------------------------------------------------------------- /get-calendar.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | /usr/local/bin/icalBuddy -npn -ec Contacts -eep notes -b "* " eventsFrom:"${KMVAR_d} 00:00:00" to:"${KMVAR_d} 23:59:59" -------------------------------------------------------------------------------- /get-completed-tasks.js: -------------------------------------------------------------------------------- 1 | // 2 | // Build a summary of OmniFocus tasks completed today. 3 | // 4 | // This is intended to be run as a TextExpander macro, 5 | // but will work anywhere you can invoke a JS script. 6 | // with "osascript -l JavaScript" 7 | // 8 | 9 | var NoProjectMarker = "Other tasks (no project)"; 10 | var app = Application.currentApplication() 11 | var doc = Application('OmniFocus').defaultDocument; 12 | app.includeStandardAdditions = true 13 | 14 | // Validation function to check if a date is valid 15 | Date.prototype.valid = function() { 16 | return isFinite(this); 17 | } 18 | 19 | // Function for getting dates 20 | Date.prototype.toIsoString = function() { 21 | var tzo = -this.getTimezoneOffset(), 22 | dif = tzo >= 0 ? '+' : '-', 23 | pad = function(num) { 24 | var norm = Math.floor(Math.abs(num)); 25 | return (norm < 10 ? '0' : '') + norm; 26 | }; 27 | return this.getFullYear() + 28 | '-' + pad(this.getMonth() + 1) + 29 | '-' + pad(this.getDate()) + 30 | 'T' + pad(this.getHours()) + 31 | ':' + pad(this.getMinutes()) + 32 | ':' + pad(this.getSeconds()) + 33 | dif + pad(tzo / 60) + 34 | ':' + pad(tzo % 60); 35 | } 36 | 37 | function getDates() { 38 | try { 39 | // Ask for a date 40 | var dateString = app.displayDialog('Fetch tasks for which date? (defaults to today if empty)', { defaultAnswer: "" }); 41 | 42 | if (dateString.textReturned === "") { 43 | var startDate = new Date() 44 | } else { 45 | var startDate = new Date(Date.parse(dateString.textReturned)) 46 | } 47 | 48 | startDate.setHours(0, 0, 0, 0) 49 | 50 | if (!startDate.valid()){ 51 | app.displayNotification('Did you try (YYYY-MM-DD)', { withTitle: "Invalid date" }); throw "Invalid date provided"; 52 | } 53 | 54 | var endDate = new Date(new Date().setDate(startDate.getDate() + 1)) 55 | endDate.setHours(0, 0, 0, 0) 56 | 57 | return { 58 | startDate: startDate, 59 | endDate: endDate, 60 | } 61 | } catch (err) { 62 | console.log(err) 63 | } 64 | } 65 | 66 | function getCompletedTasksForDates(startDate, endDate) { 67 | 68 | var tasks = doc.flattenedTasks.whose({_and: [ 69 | {completionDate: {'>=': startDate}}, 70 | {completionDate: {'<=': endDate}} 71 | ]})(); 72 | 73 | if (tasks.length === 0) { 74 | app.displayNotification('From ' + startDate.toIsoString() + ' to ' + endDate.toIsoString(), { withTitle: "No tasks found.."}); 75 | } else { 76 | return groupArrayByKey(tasks, function(v) { 77 | var proj = v.containingProject(); 78 | if (proj) { 79 | return proj.id(); 80 | } 81 | return NoProjectMarker; 82 | }); 83 | } 84 | } 85 | 86 | function getProjectsForTasks(tasks) { 87 | var allProjects = doc.flattenedProjects(); 88 | var progressedProjects = allProjects.filter(function(p) { 89 | return p.id() in tasks; 90 | }); 91 | return progressedProjects 92 | } 93 | 94 | function getSummaryPerProject(progressedProjects){ 95 | var summary = progressedProjects.reduce(function(s,project){ 96 | return s + summaryForProject(project); 97 | }, ""); 98 | 99 | var tasksWithNoProject = completedTasks[NoProjectMarker]; 100 | if (tasksWithNoProject) { 101 | summary += summaryForTasksWithTitle(tasksWithNoProject, "No Project\n"); 102 | } 103 | 104 | // This needs to be in this scope because it captures groupedTasks 105 | function summaryForProject(p) { 106 | var projectID = p.id(); 107 | var tasks = completedTasks[projectID].filter(function(t) { 108 | return projectID != t.id(); // Don't include the project itself 109 | }); 110 | return summaryForTasksWithTitle(tasks, p.name() + "\n"); 111 | } 112 | function summaryForTasksWithTitle(tasks, title) { 113 | return title + tasks.reduce(summaryForTasks,"") + "\n"; 114 | } 115 | 116 | return summary 117 | } 118 | 119 | function lineForTask(task) { 120 | return " - [x] " + task.name() + "\n"; 121 | } 122 | function summaryForTasks(s,t) { 123 | return s + lineForTask(t); 124 | } 125 | 126 | // Group an array of items by the key returned by this function 127 | function groupArrayByKey(array,keyForValue) { 128 | var dict = {}; 129 | for (var i = 0; i < array.length; i++) { 130 | var value = array[i]; 131 | var key = keyForValue(value); 132 | if (!(key in dict)) { 133 | dict[key] = []; 134 | } 135 | dict[key].push(value); 136 | } 137 | return dict; 138 | } 139 | 140 | var dates = getDates(); 141 | var completedTasks = getCompletedTasksForDates(dates.startDate, dates.endDate); 142 | var progressedProjects = getProjectsForTasks(completedTasks); 143 | var summary = getSummaryPerProject(progressedProjects); 144 | 145 | console.log(summary) -------------------------------------------------------------------------------- /get-timing-summary.js: -------------------------------------------------------------------------------- 1 | // 2 | // Build a summary of OmniFocus tasks completed today. 3 | // 4 | // This is intended to be run as a TextExpander macro, 5 | // but will work anywhere you can invoke a JS script. 6 | // with "osascript -l JavaScript" 7 | // 8 | 9 | var NoProjectMarker = "Other tasks (no project)"; 10 | var app = Application.currentApplication() 11 | var timing = Application("TimingHelper") 12 | app.includeStandardAdditions = true 13 | 14 | // Validation function to check if a date is valid 15 | Date.prototype.valid = function() { 16 | return isFinite(this); 17 | } 18 | 19 | // Function for getting dates 20 | function getDates() { 21 | try { 22 | // Ask for a date 23 | var dateString = app.displayDialog('Fetch tasks for which date? (defaults to today if empty)', { defaultAnswer: "" }); 24 | 25 | if (dateString.textReturned === "") { 26 | var startDate = new Date() 27 | } else { 28 | var startDate = new Date(Date.parse(dateString.textReturned)) 29 | } 30 | 31 | startDate.setHours(0, 0, 1, 0) 32 | 33 | if (!startDate.valid()){ 34 | app.displayNotification('Did you try (YYYY-MM-DD)', { withTitle: "Invalid date" }); throw "Invalid date provided"; 35 | } 36 | 37 | var endDate = new Date(new Date().setDate(startDate.getDate())) 38 | endDate.setHours(23, 59, 59, 0) 39 | 40 | return { 41 | startDate: startDate, 42 | endDate: endDate, 43 | } 44 | } catch (err) { 45 | console.log(err) 46 | } 47 | } 48 | 49 | function createSummary(hours, percentage, perProject) { 50 | var projectSummaries = '' 51 | for (let [projectName, projectSeconds] of Object.entries(perProject)) { 52 | var projectHours = (projectSeconds / 3600).toFixed(2) 53 | projectSummaries = projectSummaries + `${projectName}: ${projectHours}h\n` 54 | } 55 | return `Total time: ${hours}h (${percentage}% productive)\n\n` + projectSummaries 56 | } 57 | 58 | // Get the dates to fetch data for 59 | var dates = getDates(); 60 | var dailySummary = timing.getTimeSummary({between: dates.startDate, and: dates.endDate}); 61 | var perProject = dailySummary.timesPerProject(); 62 | var hours = (dailySummary.overallTotal() / 3600).toFixed(2); 63 | var percentage = (dailySummary.productivityScore() * 100).toFixed(2); 64 | 65 | createSummary(hours, percentage, perProject); 66 | -------------------------------------------------------------------------------- /write-to-obsidian.js: -------------------------------------------------------------------------------- 1 | // 2 | // Build a list of tasks from Obsidian to Omnifocus 3 | // 4 | // Will work anywhere you can invoke a JS automation script. 5 | // with "osascript -l JavaScript" 6 | // 7 | // v 1.0.2 (full release history at bottom) 8 | 9 | 10 | 11 | var SystemEvents = Application("System Events") 12 | var fileManager = $.NSFileManager.defaultManager 13 | var currentApp = Application.currentApplication() 14 | var extensionsToProcess = ['md'] 15 | currentApp.includeStandardAdditions = true 16 | 17 | getObsidianTasks(); 18 | 19 | function getObsidianTasks() { 20 | markdownFiles = getMarkdownFiles('/Users/joostplattel/Dropbox/2Projects/obsidian') 21 | } 22 | 23 | function getMarkdownFiles(path) { 24 | var isDir = Ref() 25 | if (fileManager.fileExistsAtPathIsDirectory(path, isDir) && isDir[0]) { 26 | processFolder(path) 27 | } else { 28 | processFile(path) 29 | } 30 | } 31 | 32 | function processFolder(folder) { 33 | // Retrieve a list of any visible items in the folder 34 | var folderItems = currentApp.listFolder(Path(folder), { invisibles: false }) 35 | 36 | // Loop through the visible folder items 37 | for (var item of folderItems) { 38 | var itemPath = `${folder}/${item}` 39 | getMarkdownFiles(itemPath) 40 | } 41 | // Add additional folder processing code here 42 | } 43 | 44 | // processFile(currentItem) 45 | function processFile(filePath) { 46 | // console.log(filePath) 47 | // Try getting the modification date of the file 48 | try { 49 | if (filePath.endsWith('md')) { 50 | fileContent = currentApp.read(filePath).split('\r\n') 51 | console.log(fileContent[0]) 52 | // console.log(fileContent) 53 | 54 | // for (const line in fileContent) { 55 | // console.log(line) 56 | // if (line.includes('- [ ]')) { 57 | // console.log(line) 58 | // } 59 | // } 60 | } 61 | } catch (e) { 62 | console.log(e) 63 | } 64 | } 65 | // // NOTE: The variable file is an instance of the Path object 66 | // var fileString = 67 | // var alias = SystemEvents.aliases.byName(fileString) 68 | // var extension = alias.nameExtension() 69 | // var fileType = alias.fileType() 70 | // var typeIdentifier = alias.typeIdentifier() 71 | // if (extensionsToProcess.includes(extension)) { 72 | // // Add file processing code here 73 | // } 74 | // } --------------------------------------------------------------------------------