Asynchronous Scripting in Drafts (async/await, Promises, etc.)

This is an advanced feature, and if you are just writing basic scripts, you probably don’t need to worry about it, but I wanted to draw attention to it’s available for those who are interested.

In release 17, the script action step added a new option to “allow asynchronous execution”.

By default, this option is off for all script steps, in which case they will behave as they always have, and when the end of the script is reached, Drafts will assume it’s operations are completed and it will move on to the next action step - or complete the action if no additional steps are defined.

All Drafts’ own script objects (at least at the moment) are designed to work synchronously and return results, but many JavaScript libraries, or people who are comfortable writing more advanced JavaScript might want to utilize asynchronous operations like Promises, async/await, which may allow execution of the script to continue while completing functions. This option enables those async operations to work in the context of Drafts action steps.

The key difference being, when relying on asynchronous operations in a script, Drafts does not have an implicit way of knowing when a script is complete, so it becomes the responsibility of the script writer to inform Drafts when it’s complete. It does this by calling the script.complete() function somewhere in it’s code.

When a script step with the async option enabled is run, Drafts waits for that script.complete() call to happen before continuing with the action. If you fail to call script.complete(), Drafts might appear to hang and never complete the action. Technically, there is a timeout (currently 60 seconds) where Drafts will assume you forgot to complete the script and it will fail the action, but in practice it is very important you design your script to always result in a script.complete() call.

Excited to see some of the possibilities this enabled in your scripts in the future!

4 Likes

Great feature !

Today, I found a way to integrate it into my actions.

One of them is completing some iOS reminders and adding some new ones depending on what I wrote in a draft.

But this action is long to execute and during the execution, I’m stuck and can not write anything in Drafts. I have to wait for the end of the action until all the reminders are marked as completed and new ones are added.

Thanks to an async function, the action is executed asynchronously and I’m able to continue writing even during its execution.

Thanks for this !

1 Like

Here are the scripts I added into my actions:

// in the first action, I define this
var asyncFunctions = [];

// Then, in any action, I can add into asynFunctions any function to be launched asynchronously
function Test (f) {
  alert(f.text);
}
asyncFunctions.push({name:'Test', f:{text:'Hey great!'}});

// Finally, in the last action (with the option 'allow asynchronous execution), I add this script to execute all asyncFunctions if any
async function asyncExec(funcs) {
  let promise = new Promise((resolve) => {
    for (let i = 0; i < funcs.length; i++) {
      if (funcs[i].name && typeof this[funcs[i].name] === 'function') this[funcs[i].name](funcs[i].f);
    }
    resolve('resolved!');
  });
  let result = await promise;
  script.complete();
}
asyncExec(asyncFunctions);
1 Like

I do note the OP says

All Drafts’ own script objects (at least at the moment) are designed to work synchronously and return results

But I was hoping that Prompt operations, which have to wait for user input, could be asynchronous. Might that be possible in the future? It would enable things like the following:

// Count how many drafts containing a specified string (mustContain)
// also contain a user-specified target string with default
// value "iPad"

async function getTarget() {
  // has to wait for user input, so do it asynchronously
  let p = new Prompt();
  p.addTextField("target", "Search target", "iPad");
  p.addButton("Search");
  if (p.show()) return p.fieldValues.target;
  return false;
}

function getDrafts(mustContain) {
  // takes several seconds with around 100 drafts, some long
  return Draft.query(mustContain, "all", []);
}

async function main() {
  let targetPromise = getTarget();
  let draftList = getDrafts('"# 2"');
  let target = await targetPromise;
  if (target === false || target === "") return;
  let count = draftList.reduce(
    (count, draft) => count + (draft.content.includes(target) ? 1 : 0),
    0
  );
  alert(`${count} drafts contain "${target}"`);
}

main();
script.complete();

At present this just causes Drafts to crash (version 49.1 (702) on iPad).

Not sure why this would crash, I’ll take a look. Likely because you are calling script.complete() before you operations on complete and abandoning the context.

There’s almost no cases where it’s beneficial in Drafts to use asynchronous pattern, but it is supported. If this is slow, it’s likely because it’s very inefficient to query a lot of drafts, then filter that list. You would want to construct a single query after you gather the input from the user, like the below and it would be very fast.

Draft.query(`"# 2" ${promptInput}`, "all", [])

Typically async patterns add a lot of complexity to the code without a lot of benefit in the case of Drafts.

Thanks for looking at it.

The actual application of which the above is a simplified version is searching by regular expression match, which Draft.query by itself can’t do. It is also sorting the drafts list by title, and that seems to be the slow step which I would like to happen during the wait for the user to provide input:

const compareFunc = f => (a, b) =>
  (a = f(a)) < (b = f(b)) ? -1 : a > b ? 1 : 0;

draftsList.sort(compareFunc(d => d.displayTitle));

The solution may be to sort the results after they are culled to smaller numbers by the matching; I’ll try that.

Draft.query supports all the standard advanced queries, including regex…and, yes, if you need a compare function, it would be much more efficient on the smallest set possible.

The advanced search options do look useful thanks. But in the full application, I need to find all matches with each draft, not just whether or not at least one match is present.

I switched to sorting the matches only, and you’re right: it is a lot faster.

My original hope stands: if some time you do implement async on any operations within Drafts, then Prompt could be a good one to have, to cover more general cases if not this one.

You can use the Drafts APIs wrapped in aync functions. That’s not a problem.

See:

async function showPrompt() {
	let p = new Prompt()
	p.title = "Hello"
	p.addButton("OK")
	return p.show()
}

showPrompt()
	.then(result => {
	alert(result)
	script.complete()
})

Hah, that gives a clue to fixing the original code I posted so it doesn’t crash thanks! The solution is to move script.complete()from the end of the top level and instead put it as the last statement in main(). With that change it no longer crashes, and gives the results expected.

That’s correct. But nothing asynchronous can happen, because the Drafts functions are not themselves async.

Consider this amended code:

async function getTarget() {
  // has to wait for user input, so do it asynchronously
  let p = new Prompt();
  p.addTextField("target", "Search target", "iPad");
  p.addButton("Search");
  let success = await p.show();
  return success && p.fieldValues.target;
}

const compareFunc = f => (a, b) =>
  (a = f(a)) < (b = f(b)) ? -1 : a > b ? 1 : 0;

function getDrafts(mustContain) {
  // takes several seconds with around 100 drafts, some long
  return Draft.query(mustContain, "all", [])
    .toSorted(compareFunc(d => d.displayTitle));
}

async function main() {
  let targetPromise = getTarget();
  let draftList = getDrafts('"# 2"');
  let target = await targetPromise;
  if (!target) { script.complete(); return; };
  let count = draftList.reduce(
    (count, draft) => count + (draft.content.includes(target) ? 1 : 0),
    0
  );
  alert(`${count} drafts contain "${target}"`);
  script.complete();
}

main();

Because p.show() is not async, even when called with await it blocks until finished. So no other code can run while the user is slowly responding to the prompt.

Here’s a version with timing to illustrate better:

function hms(d = new Date()) {
  return [
    { f: "getHours", s: ":" },
    { f: "getMinutes", s: ":" },
    { f: "getSeconds", s: "." },
    { f: "getMilliseconds", s: "" }
  ]
    .map(({ f, s }) => {
      let t = d[f].bind(d)().toString();
      if (f.includes("Milli")) t = t.padEnd(3, "0");
      else t = t.padStart(2, "0");
      return t + s;
    })
    .join("");
}

async function getTarget() {
  let p = new Prompt();
  p.addTextField("target", "Search target", "iPad");
  p.addButton("Search");
  let result = { function: "getTarget", start: hms() };
  let success = await p.show();
  result.target = success && p.fieldValues.target;
  result.end = hms();
  return result;
}

const compareFunc = f => (a, b) =>
  (a = f(a)) < (b = f(b)) ? -1 : a > b ? 1 : 0;

function getDrafts(mustContain) {
  let result = { function: "getDrafts", start: hms() };
  // takes several seconds with around 100 drafts, some long
  result.drafts = Draft.query(mustContain, "all", []).toSorted(
    compareFunc(d => d.displayTitle)
  );
  result.end = hms();
  return result;
}

async function main() {
  let mainTimes = { function: "main", callingGetTarget: hms() };
  let targetPromise = getTarget();
  mainTimes.callingGetDraft = hms();
  let draftsResult = getDrafts("");
  mainTimes.receivedDrafts = hms();
  let draftList = draftsResult.drafts;
  delete draftsResult.drafts; // don't want to log this
  let targetResult = await targetPromise;
  mainTimes.receivedTarget = hms();
  let target = targetResult.target;
  if (target) {
    let count = draftList.reduce(
      (count, draft) => count + (draft.content.includes(target) ? 1 : 0),
      0
    );
    alert(`${count} drafts contain "${target}"`);
  }

  let logDraft = Draft.create();
  logDraft.content = JSON.stringify(
    [mainTimes, targetResult, draftsResult],
    undefined,
    1
  );
  logDraft.update();
  script.complete();
}

main();

I started the action then waited about 30 seconds before pressing the Search button in the prompt. Here is the result:

[
 {
  "function": "main",
  "callingGetTarget": "01:11:59.538",
  "callingGetDraft": "01:12:32.860",
  "receivedDrafts": "01:12:36.867",
  "receivedTarget": "01:12:36.867"
 },
 {
  "function": "getTarget",
  "start": "01:11:59.539",
  "target": "iPad",
  "end": "01:12:36.867"
 },
 {
  "function": "getDrafts",
  "start": "01:12:32.860",
  "end": "01:12:36.867"
 }
]

Note that getDrafts was called more than 30 seconds after getRequest, not almost immediately as it should have been if the intended asynchronicity had worked. Once it was called, getDrafts took 4 seconds to run, and this caused a noticeable pause after I hit Search before the alert came up.

This approach is pretty backwards. Typically, async should be used to do “work” in the background to prevent the user interface from becoming unresponsive. User interface and input should be on the main thread. UI-related code will always run on the main thread…so, even if Prompt was written to be called async, it would be calling out to the main thread to actually run the prompt. Drafts script step are already running on a background thread to begin with, so it would be sort of silly to do an async dance with it like this…that’s just the way UI works on Apple OSes.

Try flipping it to start your “work” (fetching draft) as the async operation, like:

function hms(d = new Date()) {
  return [
    { f: "getHours", s: ":" },
    { f: "getMinutes", s: ":" },
    { f: "getSeconds", s: "." },
    { f: "getMilliseconds", s: "" }
  ]
    .map(({ f, s }) => {
      let t = d[f].bind(d)().toString();
      if (f.includes("Milli")) t = t.padEnd(3, "0");
      else t = t.padStart(2, "0");
      return t + s;
    })
    .join("");
}

function getTarget() {
  let p = new Prompt();
  p.addTextField("target", "Search target", "test");
  p.addButton("Search");
  let result = { function: "getTarget", start: hms() };
  let success = p.show();
  result.target = success && p.fieldValues.target;
  result.end = hms();
  return result;
}

const compareFunc = f => (a, b) =>
  (a = f(a)) < (b = f(b)) ? -1 : a > b ? 1 : 0;

async function getDrafts(mustContain) {
  let result = { function: "getDrafts", start: hms() };
  // takes several seconds with around 100 drafts, some long
  result.drafts = Draft.query(mustContain, "all", []).toSorted(
    compareFunc(d => d.displayTitle)
  );
  result.end = hms();
  return result;
}

async function main() {
  let mainTimes = { function: "main", callingGetTarget: hms() };
  mainTimes.callingGetDraft = hms();
  let promise = getDrafts("test");
  let targetResult = getTarget()
  mainTimes.receivedTarget = hms();
  let draftsResult = await promise;
  mainTimes.receivedDrafts = hms();
  let draftList = draftsResult.drafts;
  delete draftsResult.drafts; // don't want to log this
  let target = targetResult.target;
  if (target) {
    let count = draftList.reduce(
      (count, draft) => count + (draft.content.toLowerCase().includes(target) ? 1 : 0),
      0
    );
    alert(`${count} drafts contain "${target}"`);
  }

  let logDraft = Draft.create();
  logDraft.content = JSON.stringify(
    [mainTimes, targetResult, draftsResult],
    undefined,
    1
  );
  logDraft.update();
  script.complete();
}

main();

Mostly, however, this dance should not be necessary if you simplify this operation and don’t fetch so many drafts to begin with. If you just get your target value, and construct a single query call that looks for both your mustContain and your target in one query, it would be fast and you should not need to do any of this additional stuff.

I think, overall, something along these lines would be better and a lot simpler (with async disabled for the step):

function getTarget() {
  let p = new Prompt();
  p.addTextField("target", "Search target", "test");
  p.addButton("Search");
  if (p.show()) {
     return p.fieldValues.target;
  }
  else {
     return null
  }
}

const compareFunc = f => (a, b) =>
  (a = f(a)) < (b = f(b)) ? -1 : a > b ? 1 : 0;

function getDrafts(mustContain, target) {
  return Draft.query(`"${mustContain}" AND "${target}"`, "all", []).toSorted(
    compareFunc(d => d.displayTitle)
  );
}

function main() {
  let target = getTarget()
  if (!target) { return }
  let drafts = getDrafts("test", target);
  alert(`${drafts.length} drafts contain "${target}"`);
}

main();

Your flipped code isn’t asynchronous either. It just waits four seconds sorting the drafts before it even raises the prompt.

I appreciate the suggestions in your later shorter version, but the trouble is that the code I’ve given is not the full application, but only a cut-down fraction of it, to indicate what I’d like to do and how it doesn’t work

I want the full list of drafts, which are organised by month over several years, to be sorted in chronological order so that I can slice off various periods and only search those. I also do multiple queries on the data, not just one per run.

Doing the sort in the thousands of milliseconds it takes me to type in the first target query seemed the ideal solution. There will be ways to get around it, but I don’t think this is a unique use case for async Prompt. If Drafts and/or the Apple UI can’t manage it, that’s a pity.

A viable workaround for this particular case is to reduce to a string sort:

  let titles = [],
    titleToDraft = {};
  Draft.query(mustContain, "all", []).forEach(d => {
    let title = d.displayTitle;
    if (titleToDraft[title])
      throw new Error(`Unexpected duplicate title ${title}`);
    titles.push(title);
    titleToDraft[title] = d;
  });
  titles.sort();
  let draftList = titles.map(title => titleToDraft[title]);

This cuts the time to find and sort drafts from 4 to 0.6 seconds.