Filter expressions

Filter expressions

A filter expression is a small query with which you comb through and evaluate the notes of the current context. You can use it to find all lines that match a condition — for example "all open tasks due this week" — or to compute a figure, such as "how many tasks are done?" or "what is the sum of all amounts?". The language behind it is deliberately terse, but it follows fixed rules. This text explains those rules step by step and, at the end, shows how the built-in standard filters are put together.

Feel free to read it once from top to bottom: each section builds on the previous one. Once you have grasped the basic building blocks — hashtags, connectives, and values — the rest follows almost on its own.

What is evaluated: lines

The smallest unit of an evaluation is the individual line of text within a note, not the note as a whole. Each note is split into its lines for this, and every condition is checked for each line separately. A predicate either "matches" a line or it does not. If a filter expression ultimately yields a list of matches, that list consists of exactly the lines for which the condition holds — the most recent first. Tapping a matching line opens the corresponding note at exactly that spot.

Only the notes of the context you are currently in are ever evaluated (the module notes, or the notes of a timeslot).

Hashtags and attributes

The pivot of almost every evaluation is the hashtags in your notes — words prefixed with #, such as #todo or #done (accented letters are allowed). Optionally, an attribute, an additional value, can follow directly after it in parentheses: #todo(d251231), #amount(-42.90), or #priority(high). The content of the parentheses stays raw text at first; only in a comparison is it interpreted as a number or a date, as needed.

In a filter expression you write a hashtag just as in the note, but without the parentheses: with #todo you refer to all occurrences of that name and then compare their attribute separately. The spelling of the name is taken precisely — #todo and #Todo count as different hashtags.

The basic principle: predicates and values

There are two fundamentally different kinds of expression in the language, and it is worth keeping them cleanly apart:

A predicate is a yes/no condition on a line. #todo is a predicate ("does this line contain the hashtag #todo?"), and so is #todo <= :today or a combination of several such conditions. A predicate yields a list of lines as its result.

A value, by contrast, is a single data point that stands to the right of a comparison: a number such as 15, a date such as d251231 or :today, or a text such as "urgent". Values never stand alone, but always as the right-hand side of a comparison.

The aggregate functions (further below) form a third category: they condense many lines into a single number and, instead of a list of matches, yield a computed result.

The distinction matters because some places require a predicate: and, or, not, and the where clause combine predicates only, never bare values or numbers. An expression like #done and 5 is therefore invalid.

Hashtags as the simplest predicate

The simplest filter expression is a single hashtag: #todo finds all lines in which that hashtag occurs — regardless of whether it carries an attribute or not. Whether #todo or #todo(tomorrow) stands in the line makes no difference to this pure existence predicate.

Logical connectives: and, or, not

Several predicates can be combined into a compound condition. #todo and #urgent matches only if both hashtags are in the same line; #done or #cancelled matches if at least one of the two occurs. The prefix operator not inverts a condition: not #done finds all lines without that hashtag.

As in mathematics, there is a fixed order of precedence: not binds most strongly, then and, and most weakly or. Without parentheses, not #a and #b is therefore read as (not #a) and #b, and #a and #b or #c as (#a and #b) or #c. With round parentheses ( … ) you control the grouping yourself and make the expression more readable at the same time: (#todo or #due) and not #done is something entirely different from #todo or (#due and not #done). When in doubt, add one more pair of parentheses.

The keywords and, or, not (and where) may be written in any letter case.

Comparisons

To check not just the existence of a hashtag but its attribute value, use a comparison. On the left there is always a hashtag, in the middle a comparison operator, on the right a value: #amount > 100, #todo <= :today, #priority = "high". A hashtag must stand to the left of a comparison — constructions like 5 < #a are not allowed.

There are six operators: = (equal), != (not equal), < (less than), > (greater than), <= (less than or equal), and >= (greater than or equal). Comparisons bind more strongly than and/or, so that #a < 15 and #b > 2 groups quite naturally as (#a < 15) and (#b > 2).

A comparison is meant existentially: it matches a line as soon as at least one hashtag of that name has a suitable attribute with a suitable value. A hashtag without an attribute, or with an attribute that cannot be interpreted as the compared value type (text where a number is expected, say), simply does not contribute — it causes no error, it just does not count as a match.

Values: numbers

A number value is a whole number or a decimal, negative too: 15, 3.14, -42.90. The decimal separator is always the period. #amount >= 0 finds lines with a non-negative amount, #duration < 30 those with a duration under thirty.

Values: dates

You write dates as a date literal: the letter d followed by digits only. Exactly three lengths are permitted — four, six, or eight digits: d1231 means December 31 of the current year (format mmdd), d251231 means December 31, 2025 (yymmdd), and d20251231 the same day spelled out in full (yyyymmdd). With the two-digit year, the year lies in a sliding window around the present, so that nearby years fall into the 21st century as expected. Impossible calendar days (February 30, say) are rejected.

For a comparison to take effect, the attribute in the note must also be a date in the same d notation, e.g. #todo(d251231). Optionally, a time of day can follow the date after a single space, in the format hmm or hhmm — hours and minutes, no seconds: d251231 930 means 9:30 on that day, d20251231 1700 means 17:00. The same applies to the attribute in the note, e.g. #todo(d251231 1700). Hours run from 0 to 23, minutes from 0 to 59.

The comparison runs at the coarser of the two resolutions: only when both sides — the attribute and the comparison value — carry a time is it compared to the minute. If a time is missing on either side, both are reduced to the start of the day and compared day by day, exactly as before. So #todo = d251231 matches an attribute #todo(d251231 1700) (same day), whereas #todo = d251231 1700 requires that exact minute.

Values: date variables

Instead of a fixed date, you can use a variable that resolves at evaluation time — handy for filters meant to keep "moving along" over time. A variable begins with : and, apart from :now, always denotes a concrete day: :today, :yesterday, :tomorrow, :startofweek and :endofweek (the first and last day of the current week), :startofmonth and :endofmonth, as well as :startofyear and :endofyear. The start of the week follows your region settings. So #todo <= :endofweek finds everything to do by the end of this week, no matter what day it currently is.

The special variable :now resolves to today including the current time of day (to the minute), rather than to the start of a day. Together with a time on the attribute it enables minute-precise filters: #todo <= :now finds everything to do up to this very minute, and #meeting >= :now the appointments still ahead today.

Values: recurrence

Besides a fixed date, you can also give a recurrence rule — a readable pattern that repeats regularly. A rule begins with the word every, followed by a day specification and, optionally, a time specification after at. Examples: every day at 0900 (daily at 9:00), every 15 at 0900 (on the 15th of every month), every mon,wed at 0900,1800 (Mondays and Wednesdays, each at 9:00 and 18:00), and every 1st,3rd mon at 0900 (the first and third Monday of the month). Write the times as hmm or hhmm, just as for a date.

The day specification comes in four forms: the word day for every day; a list of days of the month such as 15 or 1,15; a list of weekdays from mon, tue, wed, thu, fri, sat, sun; and nth weekdays of the month, by prefixing the weekday with an ordinal 1st, 2nd, 3rd, 4th, 5th, or last — e.g. every last fri at 1700 for the last Friday of the month. Separate multiple entries with a comma; capitalization does not matter.

As a hashtag attribute in a note, a rule produces recurring notifications — a time (at …) is required for that, just as for a single date. So #reminder(every mon,wed at 0900,1800) fires four times a week. A rule without a time is pure metadata and triggers no notification.

In a comparison, a rule tests whether a date matches its pattern. Because a pattern has no ordering, only = and != are allowed: #appt = every 1st mon finds appointments falling on a first Monday, #appt != every sat,sun everything outside the weekend. If both the rule and the attribute carry a time, that too must agree; if it is missing on either side, only the day counts.

Values: text and regular expressions

A text value is enclosed in double quotation marks: "high". Its interpretation depends on the operator. With = and !=, the text is understood as a regular expression (regex) and checked against the attribute case-insensitively — a partial match is enough. #status = "wait" would thus also match an attribute awaiting. With the ordering operators <, >, <=, >=, however, the same text is taken literally and compared alphabetically (lexicographically) with the attribute.

Inside the quotation marks there is no special handling of backslashes; regex classes such as \d (digit) or \w (word character) therefore arrive unchanged. A " itself cannot appear in the pattern. A faulty regex pattern is reported as an error on submission, rather than silently finding nothing. The filter line also converts typographic quotation marks into straight " automatically.

Searching lines of text without a hashtag

If a text literal stands on its own — with no hashtag and operator before it — it becomes a predicate that checks the entire line text against the regular expression (unanchored, case-insensitive). "Invoice" thus finds every line in which that word occurs anywhere, regardless of hashtags. This can be combined freely with the other predicates, e.g. "Rent" and #done.

Line groups and indentation: partof

Planning notes are often structured by indentation: a less-indented line forms the heading of a group, and the more-indented lines underneath it are its members. A line belongs to every heading that stands above it in the indentation — that is, not just the immediately superior one, but all superior levels up to the very top. This membership stays within a single note; there are no groups across note boundaries.

The prefix predicate partof selects lines by their heading: partof <condition> matches a line if at least one of its (superior) group headings satisfies the condition. The condition may be anything that is otherwise a predicate — a hashtag, a text pattern, or a whole parenthesized predicate: partof #head finds the lines whose heading carries the hashtag #head; partof "Project" the lines whose heading contains the regular expression Project (unanchored, case-insensitive, just like a bare text pattern); partof (#todo and not #done) the lines whose heading carries #todo but not #done.

Since partof binds exactly one such condition to itself, it combines freely with the other predicates: partof "Project" and #done is read as (partof "Project") and #done and finds the #done lines within the project groups. It works inside aggregates just as well, e.g. count(#done where (#todo or #due) and partof "Project"). The heading line itself does not belong to its group and therefore does not appear in the match list of a plain partof condition.

Attribute present: hasAttr

Sometimes you only want to know whether a hashtag carries an attribute at all, without comparing its value. For that there is the function hasAttr(#tag): it matches a line if a hashtag of that name with an attribute stands there. hasAttr(#todo) thus finds all lines with a date set, whatever it may be.

Aggregate functions: a number instead of a list

The expressions so far yield lines. The aggregate functions, by contrast, compute across many lines and yield a single number, which appears in the results list as a value. There are five of them.

count( … ) counts. If you pass a single hashtag, all occurrences of that name are counted — if it occurs twice in a line, it counts twice: count(#todo). If you pass a compound predicate instead, the lines that satisfy it are counted: count(#done or #cancelled) yields the number of done or cancelled task lines.

sum(#tag) forms the sum of the numeric attributes of all hashtags of that name, avg(#tag) their average (rounded to two decimal places), max(#tag) the largest and min(#tag) the smallest attribute value. Attributes that cannot be interpreted as a number are not included. If no suitable value is present at all, these functions yield 0.

The where clause in aggregates

All aggregates except hasAttr can be restricted to certain lines with a trailing where clause. After where comes an arbitrary predicate, and the computation runs only over the lines that satisfy it: sum(#amount where #todo <= :today) sums only the amounts whose to do date is today or earlier. count(#todo where #urgent) is possible as well — it counts the #todo occurrences in lines marked urgent.

Predicate or number — what determines the result

Whether a filter expression yields a list of matches or a number is decided solely by its topmost level: if a predicate stands there, you get lines; if an aggregate function stands there, you get a number. The two cannot be mixed. An aggregate function may therefore not appear as an operand of and, or, not, or in a where clause — only predicates are expected there. Conversely, you may use a predicate inside an aggregate without any trouble (as the argument of count or in a where clause). If the expression does not fit together, the evaluation reports a clear error instead of yielding a misleading result.

The standard filters as examples

The built-in standard filters show the language in interplay. It is worth reading them as templates for your own expressions.

Due tasks: (#due <= :today or #todo) and not (#done or #cancelled). This finds lines that carry either a #due date due by today or a #todo at all — and that are at the same time marked neither #done nor #cancelled. What shows nicely here is the interplay of parentheses, or, and, and not.

Due tasks (today): #due = :today and not (#done or #cancelled). Instead of "by today" (<=), this checks for exactly today's day (=).

Due tasks (this week): #due <= :endofweek and not (#done or #cancelled). The variable :endofweek moves the boundary to the weekend automatically.

Open tasks: #todo and not (#done or #cancelled). All not-yet-completed tasks, independent of a date.

Open tasks (count): count(#todo and not (#done or #cancelled)). The same predicate expression, but wrapped in count( … ) — it now yields the number of lines instead of the lines themselves.

Done tasks: #done or #cancelled, and as a count Done tasks (count): count(#done or #cancelled). The same pattern: once as a list, once as a number.

Balance (today): sum(#amount where #todo <= :today). The sum of all #amount amounts whose #todo date is today or earlier — a small running account. Balance (end of month): sum(#amount where #todo <= :endofmonth) extends the period to the end of the month.

From these building blocks almost any evaluation of your own can be assembled. Frequently used expressions can be saved permanently as named filters — more on that in the help for the notes area.