I’d been using Obsidian for almost a year and I thought it was great but it has its issues, particularly with portions of it not being open source. I was excited to learn about SilverBullet. What Zef has done largely on his own is incredible, so huge thanks for his amazing work are in order.
For me though there’s still some missing features, particularly around task management. I intend to try to fix that with the following Space Script:
const rrule = import("https://esm.sh/[email protected]")
const checkbox = /^\[([^\]]+)]\s+/
const hashTags = /(^|\s)#[^ !@#$%^&*(),.?":{}|<>]+/g
const taskId = /[a-zA-Z0-9-_]+/.source
const priorities = {
"🔺": 0, // highest
"⏫": 1, // high
"🔼": 2, // medium
"🔽": 4, // low
"⏬": 5, // lowest
}
const matchPriority = Object.keys(priorities).join("|")
// Match anywhere in the string
const matchers = {
priority: new RegExp(`\\s+(${matchPriority})`, "u"),
start: /\s+🛫\s*(\d{4}-\d{2}-\d{2})/u,
created: /\s+➕\s*(\d{4}-\d{2}-\d{2})/u,
scheduled: /\s+(?:⏳|⌛)\s*(\d{4}-\d{2}-\d{2})/u,
deadline: /\s+(?:📅|📆|🗓)\s*(\d{4}-\d{2}-\d{2})/u,
completed: /\s+✅\s*(\d{4}-\d{2}-\d{2})/u,
cancelled: /\s+❌\s*(\d{4}-\d{2}-\d{2})/u,
recurrence: /\s+🔁\s*([a-zA-Z0-9, !]+)/iu,
depends: new RegExp(`\\s+⛔️\\s*(${taskId}( *, *${taskId} *)*)`, "iu"),
id: new RegExp(`\\s+🆔\s*(${taskId})`, "iu"),
tags: new RegExp(hashTags.source, "iu"),
}
// Only match at the end for iterative extraction
const extractors = Object.entries(matchers).map(([prop, regex]) => [
prop,
new RegExp(regex.source + "$", regex.flags),
])
const parsePriority = (emoji) => priorities[emoji] ?? 3
const differenceInCalendarDays = (left, right) => {
left = Temporal.PlainDate.from(left)
right = Temporal.PlainDate.from(right)
return left.until(right, { largestUnit: "days" }).days
}
const dueCoefficient = 12.0
const scheduledCoefficient = 5.0
const startedCoefficient = -3.0
const priorityCoefficient = 6.0
const parseUrgency = (task) => {
const now = Temporal.Now.plainDateISO().toString()
const { deadline, scheduled, start, priority } = task
let urgency = 0.0
if (deadline) {
const overdue = differenceInCalendarDays(deadline, now)
const dueMult = overdue >= 7
? 1.0
: overdue >= -14.0
? ((overdue + 14.0) * 0.8) / 21.0 + 0.2
: 0.2
urgency += dueCoefficient * dueMult
}
if (scheduled && differenceInCalendarDays(now, scheduled) >= 0)
urgency += scheduledCoefficient * 1
if (start && differenceInCalendarDays(now, start) > 0)
urgency += startedCoefficient * 1
switch (priority) {
case 0: urgency += priorityCoefficient * 1.5; break
case 1: urgency += priorityCoefficient * 1.0; break
case 2: urgency += priorityCoefficient * 0.65; break
case 3: urgency += priorityCoefficient * 0.325; break
case 5: urgency -= priorityCoefficient * 0.3; break
}
return Math.round(urgency * 100) / 100
}
silverbullet.registerFunction({ name: "parseUrgency" }, parseUrgency)
// Continue matching and trimming attributes from the end of the
// task until none are left, or we hit a max limit in which case
// there is probably something wrong. If a duplicate property is found
// it is discarded. This has the effect of last property wins.
const extractAttributes = (name) => {
const attrs = { tags: [] }
const extractTag = ([prop, regex]) => {
const match = regex.exec(name)
if (!match) return false
const value = match[1]
const previous = attrs[prop]
if (Array.isArray(previous)) previous.push(value)
else attrs[prop] = value
name = name.slice(0, match.index)
return true
}
let max = 20
while (extractors.some(extractTag) && max) max--
if (!max) console.warn(`Attribute limit exceeded for:`, name)
const { priority } = attrs
if (priority) attrs.name = `${name.trimEnd()} ${priority}`
else attrs.name = name
// attrs.happens = happens
attrs.priority = parsePriority(priority)
// attrs.urgency = parseUrgency(attrs)
return attrs
}
silverbullet.registerAttributeExtractor({ tags: ["task"] }, (text) => {
let { tags, ...attrs } = extractAttributes(
text.trimEnd().replace(checkbox, "")
)
// Re-append the tags to the name. Currently, SilverBullet extracts
// tags ahead of this function running so it will never have items
// but it is kept here for feature parity with Obsidian Tasks.
if (tags.length) attrs.name += ` ${tags.join(" ")}`
// If the task is cancelled, override the done state
if (text.startsWith("[-]")) attrs.done = true
return attrs
})
const appendDate = async (from, emoji) => {
const now = Temporal.Now.plainDateISO().toString()
await syscall("editor.dispatch", {
changes: { from, insert: ` ${emoji} ${now}` },
})
}
const finalDates = new RegExp(
`${matchers.completed.source}|${matchers.cancelled.source}`,
"gu"
)
const stateChanges = {
// Completed
"x": ({ to }) => appendDate(to, "✅"),
// Incomplete, remove finalization dates from the task
" ": async ({ from, text, to }) => {
const insert = text.replaceAll(finalDates, "")
if (insert === text) return
// The raw text includes the checkbox area which has the old task
// state and we don't want that.
const [current] = checkbox.exec(text) || [""]
const offset = current.length
await syscall("editor.dispatch", {
changes: {
from: from + offset,
to,
insert: insert.slice(offset),
},
})
},
// Cancelled
"-": ({ to }) => appendDate(to, "❌")
}
// Add or remove finalization date when changing a task
silverbullet.registerEventListener(
{ name: "task:stateChange" },
({ data }) => stateChanges[data.newState]?.(data)
)
const toDateString = (date) => date
.toTemporalInstant()
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toPlainDate()
// Setup a new recurring task when one is completed.
silverbullet.registerEventListener(
{ name: "task:stateChange" },
async ({ data }) => {
if (data.newState !== "x") return
const { from, text } = data
const match = matchers.recurrence.exec(text)
if (!match) return // non-recurring
const [,deadline] = matchers.deadline.exec(text) || []
if (!deadline) {
return syscall(
"editor.flashNotification",
"No deadline found for recurrence"
)
}
const { RRule } = await rrule
const start = new Date(`${deadline}T00:00:00`)
const rule = new RRule({
...RRule.parseText(match[1].trimEnd()),
dtstart: start,
})
const next = rule.after(start)
const nextLine = text.replace(
matchers.deadline,
` 📅 ${toDateString(next)}`
)
await syscall("editor.insertAtPos", `${nextLine}\n- `, from)
}
)
Features implemented and roadmap:
- Extract all Obsidian-Tasks style emoji attributes. The RegExps are copied nearly verbatim from Obsidian-Tasks. Priorities are numerically identical. 2024-03-04
- Completed/Cancelled dates are appended AND deleted when checked/unchecked. 2024-03-04
- Recurring tasks are copied to a new line when completed. 2024-03-05
- Compute
urgency
property (source) 2024-03-08
Currently, SB extractsdeadline
ahead of us so we are unable to fully compute it during indexing. It is registered as a custom functionparseUrgency(task)
which can be used in templates and queries. - Compute
happens
property (the earliest of start date, scheduled date, and due date) - Add
heading
property set to the value of the nearest heading.