Action

Send Multi-Items to Dynalist

Posted by sorashima, Last update almost 3 years ago

UPDATES

almost 3 years ago

The position where the items will be inserted is now selectable. (Top / End)

show all updates...

almost 3 years ago

The position where the items will be inserted is now selectable. (Top / End)

almost 3 years ago

Enabled to select the position where the item will be inserted. (Top / End)

almost 3 years ago

Enabled to select the position where the item will be inserted. (Prepend / Append)

about 3 years ago

Changed to keep the draft in the Inbox instead of moving it to the archive if you cancel when selecting a destination or if there is any error communicating with Dynalist.

Send action from Drafts app to Dynalist for multiple items with nested structures.

Initial setting

Access token registration

When you run this action for the first time, you will be asked for the access token of Dynalist, so enter it.

Access tokens can be obtained from Dynalist’s Developer Page.

Destination item settings

You can register destinations other than inbox in advance.

The first time this action is launched, <iCloud Drive>/Drafts/DynalistMultiNestBulkSend/destinations.txt will be created.

Add the links of the items you want to send to.

example:


When the action is executed, it asks for the destination.

Format




















Limitations

The Dynalist API used by this action has two restrictions:

Rate limit policy

 

You can access this API at a steady rate of 60 times per minute, with a burst of 20 requests at once. See the rate limit section for more information on how it works.

 

The rate limit above is for number of requests. There’s another rate limit for the number of changes you can send. You can send changes at a steady rate of 240 changes per minute, with a burst of 500 changes.

 

https://apidocs.dynalist.io/#rate-limit-policy-5

This action sends items of the same nesting level together, so you won’t hit the first limitation unless you use it abnormally to send an outline structure that exceeds 20 nesting levels at once.

However, even if the outline structure is one nested level, sending more than 500 items at a time can lead to a second limit.




https://sorashima.hatenablog.com/entry/MultiNestLvlMultiNodeSendFromDraftsToDynalist

Steps

  • script

    /* DynalistMultiNestBulkSend */
    /* 2021-12-13 */
    
    const ROOTPATH = "/"
    const DIRNAME = "DynalistMultiNestBulkSend"
    const FILENAME = "destinations.txt"
    
    
    
    // Store Secret Token of Dynalist in Credential.
    const cre = Credential.create("Dynalist", "Dynalist")
    cre.addPasswordField("token", "Secret Token")
    cre.authorize()
    
    
    
    
    
    // 
    // If the directory "DynalistMultiNestBulkSend" in iCloud drive does not exist, create it.
    // 
    const mkDir = function () {
      const fmCloud = FileManager.createCloud()
    
      let success = true
      if (!fmCloud.listContents(ROOTPATH).includes(DIRNAME + "/")) {
        if ( !fmCloud.createDirectory(DIRNAME, ROOTPATH) ) {
          alert(`Can't create directory "${DIRNAME}".`)
          success = false
        }
      }
      return success
    }
    
    
    
    // 
    // read destination urls from "DynalistMultiNestBulkSend/destinations.txt" stored in iCloud drive
    // 
    const readDestTxt = function () {
      const fmCloud = FileManager.createCloud()
    
      const res = {success: true}
      let content = fmCloud.readString(ROOTPATH + DIRNAME + "/" + FILENAME)
      if (content === undefined) {
        // if the file does not exist or could not be read, try to create the file.
        content = "destinations\n// Write the destination Dynalist item links one by one below.\n"
        if ( !fmCloud.writeString(ROOTPATH + DIRNAME + "/" + FILENAME, content) ) {
          alert(`Can't create file "${FILENAME}".`)
          res.success = false
        }
      }
      res.content = content
      return res
    }
    
    
    
    // 
    // Read items from Dynalist based on URL read from destinations.txt.
    // 
    const readDestContentFromDL = function (t) {
      const http = HTTP.create()
      const destA = []
    
      t.split("\n").forEach(l => {
    
        const idA = l.match(/https:\/\/dynalist\.io\/d\/([^#]+)(#z=(.+)|)$/)
    
        if (idA !== null) {
          const file_id = idA[1]
          const node_id = idA[3] !== undefined ? idA[3] : "root"
          
          const response = http.request({
            url: "https://dynalist.io/api/v1/doc/read",
            encoding: "json",
            method: "POST",
            data: {
              token: cre.getValue("token"),
              file_id: file_id
            }
          })
          
          if (response.success) {
            // If the HTTP request completes successfully.
            const dynalistRes = JSON.parse(response.responseText)
            
            if (dynalistRes._code == 'Ok') {
              // If the _code in the response is Ok (successful request)
              const destO = {title: dynalistRes.title}
              const nodeO = dynalistRes.nodes.find(n => n.id == node_id)
              
              if (nodeO !== undefined) {
                destO.content = nodeO.content
                destO.file_id = file_id
                destO.node_id = node_id
                destA.push(destO)
              }
              
            }
            
          }
        }
      })
      return destA
    }
    
    
    
    // 
    // Get the location of inbox
    // 
    const getLocOfInbox = function () {
      const res = {success: false}
    
      const http = HTTP.create()
      const response = http.request({
        url: "https://dynalist.io/api/v1/pref/get",
        encoding: "json",
        method: "POST",
        data: {
          token: cre.getValue("token"),
          key: "inbox_location"  
        }
      })
    
      if (response.success) {
        // If the HTTP request completes successfully.
        const dynalistRes = JSON.parse(response.responseText)
    
        if (dynalistRes._code == 'Ok') {
          // If the _code in the response is Ok (successful request)
          const valueA = dynalistRes.value.split("/")
    
          res.file_id = valueA[0]
          res.node_id = valueA[1] !== undefined ? valueA[1] : "root"
          res.success = true
        } else {
          alert(dynalistRes._code + "\n" + dynalistRes._msg)
        }
      } else {
        alert(response.error)
      }
      return res
    }
    
    
    
    // 
    // Read the draft line by line, parse it, and save the items in an array.
    // 
    const parseDraft = function () {
      let isTitle = true
      let maxLvl = 0
      let preLvl = 0
      let cNodeNum = 0
      const parentStack = []
      const nodeStack = []
      let noteStack = []
      let aNode = {param: {content: ""}}
    
    
    
      draft.lines.forEach(l => {
    
        const bulletA = l.match(/^([-ー]+)([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?[  ]/i)
    
    
        if (bulletA !== null) {
          // 
          // title of a item
          // 
    
    
          // Stack the previous item on the array
          if (cNodeNum != 0) {
            // Execute if not the first loop.
            
            // Delete the last blank line in the note.
            if (noteStack[noteStack.length - 1] == "") noteStack.pop()
            aNode.param.note = noteStack.join("\n")
            noteStack = []
          
            nodeStack.push(aNode)
    
            aNode = {param: {content: ""}}
          }
    
    
          isTitle = true
          
          // Determine nesting level
          aNode.nestLvl = bulletA[1].length
    
          // Determine maximum level
          if (aNode.nestLvl > maxLvl) maxLvl = aNode.nestLvl
    
    
          switch (true) {
            case aNode.nestLvl == preLvl:
              // If the nesting level does not change, do nothing to the parent stack.
              break
            case aNode.nestLvl == preLvl + 1:
              // If the nesting level is one deeper, stack the previous nesting level on the parent stack.
              parentStack.push(cNodeNum)
              break
            case aNode.nestLvl < preLvl:
              // Break the delta parent stack when the nesting level is shallow.
              for (let i = 0; i < preLvl - aNode.nestLvl; i++) {
                parentStack.pop()
              }
              break
            case aNode.nestLvl > preLvl + 1:
              // Add empty diff node if nesting level goes up by 2 or more.
              for (let i = preLvl + 1; i < aNode.nestLvl; i++) {
                const iNode = {param: {content: ""}}
                iNode.nestLvl = i
                parentStack.push(cNodeNum)
                iNode.parentNum = parentStack[parentStack.length - 1]
                iNode.num = ++cNodeNum     
                nodeStack.push(iNode)
              }
              parentStack.push(cNodeNum)
              break
          }
    
          aNode.parentNum = parentStack[parentStack.length - 1]
    
          aNode.num = ++cNodeNum
          preLvl = aNode.nestLvl
    
          aNode.param.content = l.match(/^([-ー]+)([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?([0-30-3]|[roygbpれおいぐぶぱレオイグブパ]|[?!?!])?[  ](.*)$/i)[5]
    
    
          Array.from(bulletA[0]).forEach(s => {
            switch (true) {
              case /[00]/.test(s):
                break
              case /[11]/.test(s):
                aNode.param.heading = 1
                break
              case /[22]/.test(s):
                aNode.param.heading = 2
                break
              case /[33]/.test(s):
                aNode.param.heading = 3
                break
              case /[rれレ]/i.test(s):
                aNode.param.color = 1
                break
              case /[oおオ]/i.test(s):
                aNode.param.color = 2
                break
              case /[yいイ]/i.test(s):
                aNode.param.color = 3
                break
              case /[gぐグ]/i.test(s):
                aNode.param.color = 4
                break
              case /[bぶブ]/i.test(s):
                aNode.param.color = 5
                break
              case /[pぱパ]/i.test(s):
                aNode.param.color = 6
                break
              case /[??]/.test(s):
                aNode.param.checkbox = true
                aNode.param.checked = false
                break
              case /[!!]/.test(s):
                aNode.param.checkbox = true
                aNode.param.checked = true
                break
            }
          })
    
        } else {
      
          // 
          // [note | other than the first line of the title] of a item
          // 
          const bullet2A = l.match(/^[、,](.*)$/)
          if (bullet2A !== null && isTitle) {
            // 
            // If the lines starting with a comma follow the title line, treat it as a continuation of the title, otherwise treat it as part of a note.
            // 
            aNode.param.content += `\n${bullet2A[1]}`
        
          } else {
            // 
            // note
            // 
            isTitle = false
            noteStack.push(l)
          
          }
        
        }
      
      })
    
      // Stack last item on the array
      //Delete the last blank line in the note.
      if (noteStack[noteStack.length - 1] == "") noteStack.pop()
      aNode.param.note = noteStack.join("\n")
      nodeStack.push(aNode)
    
      return {nodeStack: nodeStack, maxLvl: maxLvl}
    }
    
    
    
    // 
    // Select a destination.
    // 
    const selDest = function (locOfInbox, destA) {
      const res = {selected: true}
      const p = Prompt.create()
    
      p.title = "Select Destination"
      p.addButton("Inbox")
      destA.forEach((dest, idx) => {
        p.addButton(dest.content, idx)
      })
    
      p.addSelect("position","Position",["Top","End"],["Top"],false)
      
      if ( !p.show() ) {
        res.selected = false
      } else {
        res.index = p.fieldValues["position"] == "Top" ? 0 : -1
    
        if (p.buttonPressed == "Inbox") {
          res.file_id = locOfInbox.file_id
          res.node_id = locOfInbox.node_id
        } else {
          p.buttonPressed
          res.file_id = destA[p.buttonPressed].file_id
          res.node_id = destA[p.buttonPressed].node_id
        }
      } 
      return res
    }
    
    
    
    // 
    // Insert items of the same nesting level together into Dynalist from the highest nesting level to lowest.
    // 
    const sendToDynalistInBulk = function (parsDrft, dest) {
    
      let sendingStatOK = true
      const http = HTTP.create()
    
      const req = {
        url: "https://dynalist.io/api/v1/doc/edit",
        encoding: "json",
        method: "POST",
        data: {
          token: cre.getValue("token"),
          file_id: dest.file_id
        }
      }
    
      for (i = 1; i <= parsDrft.maxLvl; i++) {
        if (sendingStatOK) {
          const changesA = []
          const sentNode = []
    
    
          parsDrft.nodeStack.forEach((node, idx) => {
          
            if (node.nestLvl == i) {
              const changeO = {
                action: "insert",
                index: dest.index
              }
              // Parameter settings for each item (content, note, color, heading, checkboxe and it status)
              Object.assign(changeO, node.param)
    
              // For the outermost nested node, parent_id should be the node_id of the selected destination.
              if (node.parentNum == 0) {
                changeO.parent_id = dest.node_id
              } else {
                //  Otherwise, parent_id should be the node_id of the parent node of each item.
                changeO.parent_id = parsDrft.nodeStack.find(n => n.num == node.parentNum).node_id
              }  
              
              changesA.push(changeO)
              sentNode.push(idx)
            }
          })
          
          req.data.changes = changesA.reverse()
    console.log(JSON.stringify(req))
          const response = http.request(req)
          
          if (response.success) {
            // If the HTTP request completes successfully.
            const dynalistRes = JSON.parse(response.responseText)
            
            console.log(JSON.stringify(dynalistRes))
            
            if (dynalistRes._code == 'Ok') {
              // If the _code in the response is Ok (successful request)
              
              // If sending is successful, store the item's node_id for descendant nodes.        
              dynalistRes.new_node_ids.reverse().forEach((n, idx) => {
                parsDrft.nodeStack[sentNode[idx]].node_id = n
              })  
            } else {
              alert(dynalistRes._code + "\n" + dynalistRes._msg)
              sendingStatOK = false
            }
          } else {
            alert(response.error)
            sendingStatOK = false
          }
        }
      }
    }
    
    
    
    
    
    
    // 
    // supervisor
    // 
    let success = false
    if (mkDir()) {
    
      const destTxt = readDestTxt()
      if (destTxt.success) {
      
        const destA = readDestContentFromDL(destTxt.content)
        
        const locOfInbox = getLocOfInbox()
        if (locOfInbox.success) {
        
          const pd = parseDraft()
          
          const dest = selDest(locOfInbox, destA)
          success = true
          if (dest.selected) {
          
            sendToDynalistInBulk(pd, dest)
          } else {
            context.cancel()
          }
        }
      }
    }
    if (!success) context.fail()

Options

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