Scriptable widget for Obsidian journal tasks
8 minutes read | 1691 words by Ruben BerenguelA bit of Sunday-inspired yak shaving
If you just want the code, find it here.
A few months ago I started using the journal feature in Obsidian, the PKM (personal knowledge management) tool I use. I used to keep an on-and-off journal in Bear, a previous PKM tool I used, and when I migrated I moved them across. In each daily note I tracked some things that happened, like meetings I had or requests from business, a mix of work and life stuff. After a while it was surprisingly useful to look back at it and see what had been going on.
I picked it up again recently to store my daily to-dos. Blog posts I want to write, remembering to do something, etc. So, each morning I’d create a ###
heading named Plan
with a checklist of stuff I want to get done on the day (eventually I moved it to the autopopulating template for daily notes, which I combine with the Calendar plugin).
These days I have been moving many of my lists from Things 3 to Obsidian, including my reading lists. Most of my projects were already living either in both places or only on Obsidian… so it’s pretty clear I’m going to eventually abandon completely Things 3. Things 3 is pretty and smooth, but Obsidian is an upgrade in terms of how easily you can connect all the pieces together.
There are a couple conveniences that make it hard to leave:
- Apple Watch complication with either today’s tasks or Inbox (GTD term) tasks.
- Homepage widget with either of the above.
For the first I wrote a shortcut that pulls today’s date and shows me the tasks when I click it. It’s not as nice, but gets enough of the work done. I don’t check the watch list of tasks that often, so it’s ok.
I didn’t know what to do for the second until I remembered Scriptable lets you do pretty cool stuff with widgets. So, I wrote my own widget with it to display today’s tasks. What does it do?
- Pull today’s note;
- Find the
### Plan
heading; - Collect all the checklist items (anything starting with
- [
); - Present it as a list widget for iOS in Scriptable.
It does a bit more, but this is the gist. I’ll try to explain the code below, as a very short-scoped tutorial for a list widget with Scriptable.
Widget refresh times can vary a lot with Scriptable scripts. I don’t care that much for this refresh time, since I only need this widget to see what may be still not finished. This is not a Scriptable limitation, but an iOS limitation.
Pull today’s note from iCloud
async function loadList(today, journalBookmark) {
const fm = FileManager.iCloud();
const file = fm.bookmarkedPath(journalBookmark) + "/" + today + ".md";
const download = fm.downloadFileFromiCloud(file);
return download
.then(() => {
let lines = fm.readString(file);
let found = false;
let items = [];
for (let line of lines.split("\n")) {
if (found && line.startsWith("- [")) {
let newline = line.replace("- [x]", "✓");
newline = newline.replace("- [ ]", "☓");
items.push(replaceURLs(newline));
}
if (line.includes("### Plan")) found = true;
}
return items;
})
.catch((err) => {
console.log(err);
return [];
});
}
It is an async function because it will pull the file from iCloud if it’s not available locally, and that’s a promise.
This function expects two things:
today
, formatted asYYYYMMDD
, which is the format I use for daily notes.journalBookmark
, the name of an Scriptable bookmark to access the daily notes.
This second variable needs a bit of an explanation. For Scriptable scripts to be able to access iCloud locations, they need to be added to the app via a bookmark in Scriptable’s settings. Then you add a name to this bookmark. In my case journal
is the name of the bookmark that points to the journal
folder in my Obsidian vault, which I selected with the file picker when I created the bookmark.
The rest of the code should be pretty readable, but from top to bottom, it…
- Creates an iCloud
FileManager
to access stuff in iCloud. - Defines which file it is opening via the bookmark and the filemanager.
- Downloads it in a promise. Once the promise resolves (having read the file or failing miserably)…Thanks to Stephan Walkner for suggesting this improvement, it makes a huge difference when using Obsidian across several devices that may not have iCloud synchronized properly.
- Reads the file.
- Defines a couple of variables it will use in the next for loop (a flag and a container following the variable classification in The Programmer’s Brain );
- Loops through each line in the file.
- If it has
found
and the line is a checklist item, add it to the accumulator after replacing URLs (this function is explained later). - Replaces undone item mark for a particular nice UTF-8 cross and done item mark for a nice UTF-8 checkmark.
- Once it finds
### Plan
toggles the flag to true. This is after the other conditions just becausefound
can’t be true and the line be a checklist item, so it feels more of an end condition in a loop. Personal taste. - Returns all the collected items as a list. If the promise fails (99.9% probably because the file does not exist) it returns an empty list.
There is one obvious improvement here: I should also set found
to false once I have left the ### Plan
section (for instance if we find any other header or a horizontal ruler in markdown). I use done and undone checklist items in several Tracker plots, so I’m already very aware of not having any checklist item outside of the plan. A potential improvement would be having 3 potential return values (a list, possibly empty and undefined for the error case), but since I always have something in my task lists the error message will only display if the file does not really exist.
Create a widget out of a list
function createWidget(items, today, obsidianURL) {
let clippedItems = items;
if (clippedItems.length > 5) {
clippedItems = clippedItems.slice(0, 5);
clippedItems.push("…");
}
console.log(clippedItems);
let w = new ListWidget();
w.url = obsidianURL;
let titleStack = w.addStack();
let title = titleStack.addText(today);
title.font = Font.boldSystemFont(17);
title.textColor = Color.white();
w.addSpacer(4);
let stack = w.addStack();
stack.layoutVertically();
if (clippedItems.length == 0) {
let line = stack.addText("File does not exist");
line.textColor = new Color("#772222");
}
for (let item of clippedItems) {
let line = stack.addText(item);
line.textColor = Color.white();
}
return w;
}
This should be very straightforward, also Scriptable’s documentation is excellent (although it would benefit from having more examples!).
- If there are more than 5 items, it clips the list at the first 5 and adds an ellipsis at the end.
- Creates a new
ListWidget
which is the main thing we are interested as users. - Sets its
url
to something. This is so that when we tap on the widget the corresponding daily note is opened, using Obsidian’s URI scheme. - Adds a stack to the list. This is a tapable block with its own settings. It could have its own
url
, too. I may take advantage of this fact at some point. This is going to be the title stack. - Adds the
today
string astitle
. Set font to large-ish bold, white (because I use pure black backgrounds for widgets). - Adds some spacing after this stack.
- Adds another stack, this one will contain the list of tasks.
- If there are no tasks, it assumes the file does not exist. This is consistent with my usage: any day has something to be done, even if it’s just lazying around.
- For each task item, adds it as text to the list. And makes sure it’s white text (this could very probably be done after the for loop).
- Returns the widget.
There’s some things to improve here, but since it works well I don’t worry much. It looks good enough for my use case so far, but Scriptable is flexible enough that I could do way more.
Extra boilerplate
This is all the stuff that uses these two functions to create a widget.
let when = args.widgetParameter || "today";
let now = new Date();
if (when == "tomorrow") {
now.setDate(now.getDate() + 1);
}
let formatter = new DateFormatter();
formatter.dateFormat = "yyyyMMdd";
let today = formatter.string(now);
const journalBookmark = "journal";
const journalPath = "journal";
const obsidianURL = `obsidian://open?file=${journalPath}/"${today}.md`;
let items = await loadList(today, journalBookmark);
let widget = createWidget(items, today, obsidianURL);
if (config.runsInWidget) {
Script.setWidget(widget);
Script.complete();
} else {
widget.presentMedium();
}
function replaceURLs(task) {
return task.replace(/\[([^\]]+)\]\([^\)]+\)/, "🔗$1");
}
- Scriptable widgets have an optional argument. In this, optionally passing
tomorrow
will render tomorrow’s note. - Defines
today
(as seen by the script) as either today’s date or tomorrow’s date depending on the argument. - Formats it in the expected
YYYYMMDD
format (capitalization is different instrftime
format, beware). - Defines the constants for the bookmark and the path to make it easier for others to reuse this script.
- Pulls the items from the file.
- Creates the widget from the items.
- If the script is running in a widget, runs it as a widget 🤷.
- Otherwise, shows the widget in the Scriptable editor. As a medium widget, because that’s what I use.
- Finally, the helper function to replace markdown URLs
[text](url)
by🔗text
.
I hope this was useful for any wanna-be Scriptable widget creator and/or Obsidian user. You can find the full script (possibly with updates!) in this gist.