Action

Things Parser

Posted by @pdavisonreiber, Last update 4 months ago

Things Parser 3 turns TaskPaper documents into Things tasks and projects. See below for examples of the syntax, or read my blog post for more details.

In addition, to this, it also allows the use of variables using the Mustache Prompt system, which you can read about in this blog post.

Update 2020-12-22: Now supports multi-line notes for tasks and projects.

For Things Parser 2 (legacy version) see here.

Tasks

- Tasks outside projects
- with date @when(2020-12-13)
- alternatively @defer(2020-12-13)
- with natural language date @when(tomorrow)

- with deadline @deadline(2020-12-25)
- alternatively @due(2020-12-25)

- Canceled task @canceled
- British cancelled task @cancelled

- Tasks can be @done
- Or @completed

- Tasks can be assigned to a @list(name)
- alternatively @project(name)
- or using list ID @listID(id)
- and within a project to a @heading(name)

- Tasks can have a @tag(name)
- Or more than one @tags(tag1, tag2, tag3)

- Tasks can have notes
    which are indented text
- And subtasks
    - which are indented tasks
    - like this

Projects

Projects can have dates too: @when(today)
    - also supports @defer(today)

They can have deadlines: @deadline(2020-12-25)
    - also supports @due(2020-12-25)

They can be: @canceled
    - or if they are British @cancelled

They can be: @completed
    - or @done

They can be assigned to an: @area(name)

Or using an id: @areaID(id)

They can have a: @tag(name)
    - or multiple @tags(tag1, tag2, tag3)

They can have notes:
    which are indented text

They can obviously have tasks:
    - like this one
    - and this one

But they can also have headings:
    which are written like sub-projects:
        - and tasks within the headings will be added to those headings

Steps

  • script

    function libraryIsInstalled(name) {
      let fm = FileManager.createCloud()
      if (fm.readString("/Library/Scripts/" + name)) {
        return true
      } else {
        return false
      }
    }
    
    HTTP.prototype.getContent = function(url) {
      let r = this.request({"url": url, "method": "GET"})
      if (r.success) {
        return r.responseText
      } else {
        alert("Error " + r.statusCode + ": " + r.error)
      }
    }
    
    HTTP.prototype.downloadLibrary = function(url, filename) {
      let fm = FileManager.createCloud()
      let path = "/Library/Scripts/" + filename
      let content = this.getContent(url)
      if (content) {
        fm.writeString(path, content)
        return true
      } else {
        return false
      }
    }
    
    const url = "https://raw.githubusercontent.com/pdavisonreiber/Public-Drafts-Scripts/master/Things%20Parser%203/birchoutline.js"
    
    if (libraryIsInstalled("birchoutline.js")) {
      require("birchoutline.js")
    } else {
      let p1 = Prompt.create()
      p1.title = "Confirm Download"
      p1.message = "Things Parser 3 requires the installation of the Birch Outline library. Press OK to download."
      p1.addButton("OK")
      
      if (p1.show()) {
        let http = HTTP.create()
        let success = http.downloadLibrary(url, "birchoutline.js")
        
        if (success) {
          let p2 = Prompt.create()
          p2.isCancellable = false
          p2.title = "Success"
          p2.message = "Birch Outline successfully installed to iCloud/Drafts/Library/Scripts/birchoutline.js"
          p2.addButton("OK")
          p2.show()
          require("birchoutline.js")
        }
      }
    }
  • script

    function mustachePrompt(text, dateFormat) {
      dateFormat = (dateFormat === undefined) ? "%Y-%m-%d" : dateFormat
      
      const variableRegex = /{{(?:(date|bool):)?(#|^)?(\w+)\??([+|-]\d+[d|w|m])?}}/g
      const variableMatches = text.matchAll(variableRegex)
      
      variables = {}
      
      for (match of variableMatches) {
        let instance = new Object()
        instance.string = match[0]
        instance.type = match[1]
        instance.modifier = match[2]
        instance.name = match[3]
        instance.offset = match[4]
        
        if (!variables.hasOwnProperty(instance.name)) {
          let variable = new Object()
          variable.type = instance.type
          variable.modifier = instance.modifier
          variable.instances = [instance]
          variables[instance.name] = variable
        } else {
          variables[instance.name].instances.push(instance)
          if (!variables[instance.name].type) {
            variables[instance.name].type = instance.type
          }
          if (!variables[instance.name].modifier) {
            variables[instance.name].modifier = instance.modifier
          }
        }
      }
      
      //alert(JSON.stringify(variables))
      
      let p = Prompt.create()
      
      for (name in variables) {
        let variable = variables[name]
        
        if (!variable.type) {
          p.addTextField(name, name, "")
        } else if (variable.type == "date") {
          p.addDatePicker(name, name, new Date(), {mode: "date"})
        } else if (variable.type == "bool") {
          p.addSwitch(name, name, false)
        }
      }
      
      p.addButton("OK")
      data = {}
      
      let cancel = true
      if (Object.keys(variables).length !== 0) {
        cancel = !p.show()
      }
      
      if (!cancel) {
        for (key in p.fieldValues) {
          let fieldValue = p.fieldValues[key]
          if (fieldValue instanceof Date) {
            data[key] = strftime(fieldValue, dateFormat)
          } else if (typeof fieldValue == "string") {
            if (fieldValue.includes(",") && variables[key].modifier == "#") {
              data[key] = fieldValue.split(",").map(s => s.trim())
            } else {
              data[key] = fieldValue
            }
          } else {
            data[key] = fieldValue
          }
          
          for (instance of variables[key].instances) {
            if (!instance.type && !instance.offset) {
              continue
            }
            
            if (instance.offset) {
              text = text.replace(instance.string, instance.string.replace(instance.type + ":", "").replace(instance.offset, snakify(instance.offset)))
              data[key + snakify(instance.offset)] = offsetDate(fieldValue, instance.offset, dateFormat)
            } else {
              text = text.replace(instance.string, instance.string.replace(instance.type + ":", ""))
            }
          }
        }
        //alert(JSON.stringify(data, 2))
        
        let template = MustacheTemplate.createWithTemplate(text)
        let result = template.render(data)
        return result
        
      } else {
        context.cancel()
      }  
    }
    
    function offsetDate(date, string, dateFormat) {
      let d = new Date(date)
      let offsetRegex = /(\+|-)(\d+)(d|w|m)/
      let match = string.match(offsetRegex)
      let multiplier = (match[1] == "+" ? 1 : -1)
      
      if (match[3] == "d") {
        d.setDate(d.getDate() + multiplier * parseInt(match[2]))
      } else if (match[3] == "w") {
        d.setDate(d.getDate() + multiplier * 7 * parseInt(match[2]))
      } else if (match[3] == "m") {
        d = addMonths(d, multiplier * parseInt(match[2]))
      }
      
      return strftime(d, dateFormat)
    }
    
    function addMonths(date, months) {
      var d = date.getDate();
      date.setMonth(date.getMonth() + +months);
      if (date.getDate() != d) {
        date.setDate(0);
      }
      return date;
    }
    
    function snakify(offset) {
      return offset.replace("+", "_offset_forward_").replace("-", "_offset_backwards_")
    }
  • script

    let text = draft.content
    
    if (text.includes("{{")) {
      let mustache = mustachePrompt(text)
      if (mustache) {
        text = mustache
      }
    }
    
    
    var taskPaperOutline = new birchoutline.Outline.createTaskPaperOutline(draft.processTemplate(text))
    var rootChildren = taskPaperOutline.root.children
    
    var tjsArray = []
    
    for (child of rootChildren) {
      if (child.getAttribute("data-type") == "project") {
        tjsArray.push(processProject(child))
      } else if (child.getAttribute("data-type") == "task") {
        tjsArray.push(processTask(child))
      }
    }
    
    var container = TJSContainer.create(tjsArray)
    
    var cb = CallbackURL.create()
    cb.baseURL = container.url
    cb.addParameter("reveal", true)
    if (text && tjsArray.length != 0) {
      app.openURL(cb.url)
    } else {
      context.cancel()
    }
    
    function processSubTask(item) {
      var subtask = TJSChecklistItem.create()
      subtask.title = item.bodyContentString.trim()
      subtask.canceled = item.hasAttribute("data-canceled") || item.hasAttribute("data-cancelled")
      subtask.completed = item.hasAttribute("data-completed") || item.hasAttribute("data-done")
      return subtask
    }
    
    function processTask(item) {
      var task = TJSTodo.create()
      task.title = item.bodyContentString.trim()
      
      if (item.hasAttribute("data-when")) {
        task.when = item.getAttribute("data-when")  
      } else if (item.hasAttribute("data-defer")) {
        task.when = item.getAttribute("data-defer")  
      }
      
      if (item.hasAttribute("data-due")) {
        task.deadline = item.getAttribute("data-due")  
      } else if (item.hasAttribute("data-deadline")) {
        task.deadline = item.getAttribute("data-deadline")  
      }
      
      task.canceled = item.hasAttribute("data-canceled") || item.hasAttribute("data-cancelled")
      task.completed = item.hasAttribute("data-completed") || item.hasAttribute("data-done")
      
      if (item.hasAttribute("data-heading")) {
        task.heading = item.getAttribute("data-heading")
      }
      
      if (item.hasAttribute("data-list")) {
        task.list = item.getAttribute("data-list")
      } else if (item.hasAttribute("data-project")) {
        task.list = item.getAttribute("data-project")
      }
      
      if (item.hasAttribute("data-listID")) {
        task.listID = item.getAttribute("data-listID")
      } else if (item.hasAttribute("data-listid")) {
        task.listID = item.getAttribute("data-listid")
      }
      
      if (item.hasAttribute("data-tag")) {
        task.tags = [item.getAttribute("data-tag")]
      } else if (item.hasAttribute("data-tags")) {
        task.tags = item.getAttribute("data-tags").split(",").map(t => t.trim())
      }
      
      if (item.hasChildren) {
        for (child of item.children) {
          if (child.getAttribute("data-type") == "task") {
           task.addChecklistItem(processSubTask(child)) 
          } else if (child.getAttribute("data-type") == "note") {
            if (task.notes) {
              task.notes += "\n" + child.bodyContentString
            } else {
              task.notes = child.bodyContentString
            }
          }
        }
      }
      return task
    }
    
    function processHeading(item) {
      var heading = TJSHeading.create()
      heading.title = item.bodyContentString.trim()
      heading.archived = item.hasAttribute("data-archived") || item.hasAttribute("data-done")
      
      return heading
    }
    
    function processProject(item) {
      var project = TJSProject.create()
      project.title = item.bodyContentString.trim()
      
      if (item.hasAttribute("data-when")) {
        project.when = item.getAttribute("data-when")  
      } else if (item.hasAttribute("data-defer")) {
        project.when = item.getAttribute("data-defer")  
      }
      
      if (item.hasAttribute("data-due")) {
        project.deadline = item.getAttribute("data-due")  
      } else if (item.hasAttribute("data-deadline")) {
        project.deadline = item.getAttribute("data-deadline")  
      }
      
      project.canceled = item.hasAttribute("data-canceled") || item.hasAttribute("data-cancelled")
      project.completed = item.hasAttribute("data-completed") || item.hasAttribute("data-done")
      
      if (item.hasAttribute("data-area")) {
        project.area = item.getAttribute("data-area")
      }
      
      if (item.hasAttribute("data-areaID")) {
        project.areaID = item.getAttribute("data-areaID")
      } else if (item.hasAttribute("data-areaid")) {
        project.areaID = item.getAttribute("data-areaid")
      }
      
      if (item.hasAttribute("data-tag")) {
        project.tags = [item.getAttribute("data-tag")]
      } else if (item.hasAttribute("data-tags")) {
        project.tags = item.getAttribute("data-tags").split(",").map(t => t.trim())
      }
      
      if (item.hasChildren) { 
        
        for (child of item.children) {
          if (child.getAttribute("data-type") == "task") {
           project.addTodo(processTask(child)) 
          } else if (child.getAttribute("data-type") == "note") {
            if (project.notes) {
              project.notes += "\n" + child.bodyContentString
            } else {
              project.notes = child.bodyContentString
            }
          } else if (child.getAttribute("data-type") == "project") {
            let heading = processHeading(child)
            project.addHeading(heading)
            
            if (child.hasChildren) {
              for (grandchild of child.children) {
                if (grandchild.getAttribute("data-type") == "task") {
                  let task = processTask(grandchild)
                  task.heading = heading.title
                  project.addTodo(task)
                }
              }
            }
          }
        }
      }
      return project
      
    }
      

Options

  • After Success Archive
    Notification Info
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.