Declarative notes: Notes as code#

One of the goals of this project is to enable building, maintaining, and sharing complex note hierarchies using Python. This approach is declarative in nature, inspired by SQLAlchemy’s declarative mapping approach.

The general idea of declarative programming is that you specify the desired end state, not the steps needed to reach it.

For a fully-featured example of a note hierarchy designed using this approach, see Event tracker.

Note subclasses#

The basic technique is to subclass Note:

class MyNote(Note): pass

Mixin subclasses#

Sometimes you want to logically group attributes or children together in a reusable way, but don’t need a fully-featured Note. In those cases you can use a Mixin.

The basic technique is to subclass Mixin:

class MyMixin(Mixin): pass

Note

Note inherits from Mixin, so the following semantics can be applied to Note subclasses and Mixin subclasses equally.

Setting fields#

You can set the following fields by setting attribute values:

class MyNote(Note):
    title = "My title"
    note_type = "text"
    mime = "text/html"
    content = "<p>Hello, world!</p>"

Setting content from file#

Set note content from a file by setting Note.content_file or Mixin.content_file:

class MyFrontendScript(Note):
    note_type = "code"
    mime = "application/javascript;env=frontend"
    content_file = "assets/myFrontendScript.js"

The filename is relative to the package the class is defined in. Currently accessing parent paths ("..") is not supported.

Adding labels#

Use the decorator label to add a label:

@label("sorted")
class Sorted(Mixin): pass

Now you can simply subclass this mixin if you want a note’s children to be sorted:

@label("iconClass", "bx bx-group")
class Contacts(Note, Sorted): pass

The above is equivalent to the following imperative approach:

contacts = Note(title="Contacts")
contacts += [Label("iconClass", "bx bx-group"), Label("sorted")]

Singleton notes#

In some cases it’s important to generate the same Note.note_id every time the class is instantiated. Templates, for example, should have only one instance and be automatically updated as changes are made to the code. This behavior can be accomplished in a number of ways.

Warning

Without setting Note.leaf or Mixin.leaf, TriliumAlchemy assumes that you want to explicitly specify the note’s children. Therefore it will delete any existing children which aren’t declaratively added. See Leaf notes to learn more.

Setting singleton#

When Note.singleton or Mixin.singleton is set, the note’s Note.note_id is generated based on the fully qualified class name, i.e. the class name including its modpath.

The following creates a template note for a task:

@label("template")
@label("iconClass", "bx bx-task")
class Task(Note):
    singleton = True

Setting note_id_seed#

When Note.note_id_seed or Mixin.note_id_seed is set, the provided value is hashed to generate Note.note_id.

It uses the same hash algorithm used by Mixin.singleton.

# now note_id won't change if we move the class to a different module
@label("template")
@label("iconClass", "bx bx-task")
class Task(Note):
    note_id_seed = "Task"

Todo

Add a flag to set Mixin.note_id_seed from class name (user guarantees uniqueness of class names)

Setting note_id#

When Note.note_id or Mixin.note_id is set, the provided value is used as Note.note_id directly.

class MyNote(Note):
    note_id = "my_note_id"

Passing note_id#

When note_id is passed in the constructor of a Note subclass, it’s similarly considered a singleton.

Child of singleton#

Every child of a singleton note is required to also have a deterministic Note.note_id. Therefore a note_id is generated for children of singletons, even if they don’t satisfy any of the above criteria.

This is recursive, so an entire note tree specified by Note subclasses will be instantiated with a deterministic note_id if the root satisfies any of the above criteria.

Adding relations#

You can declaratively add a relation to another note, as long as the target note is a singleton.

For example, to create a ~template relation:

@relation("template", Task)
class TaskInstance(Note): pass

Now you can create a task by simply instantiating TaskInstance, and it will automatically have ~template=Task.

Adding children#

Use children or child to add children:

class Child1(Note): pass
class Child2(Note): pass
class Child3(Note): pass

@children(Child1, Child2) # add children with no branch prefix
@child(Child3, prefix="My prefix") # add child with branch prefix
class Parent(Note): pass

Custom initializer to add attributes, children#

Define Note.init or Mixin.init to add attributes and children dynamically. Use the following APIs to add attributes and children:

These APIs are required for singleton notes to generate a deterministic id for attributes and children, generating the same subtree every time the Note subclass is instantiated.

For example, this mechanism is used internally to add an iconClass label for certain Note subclasses like Workspace:

class IconMixin(Mixin):
    icon: str = None
    """
    If provided, defines value of `#iconClass` label.
    """

    def init(self, attributes: list[Attribute], children: list[Branch]):
        """
        Set `#iconClass` value by defining {obj}`IconMixin.icon`.
        """
        if self.icon:
            attributes += [
                self.create_declarative_label("iconClass", self.icon)
            ]

Leaf notes#

If you design a note hierarchy using this approach, you might want to designate some “folder” notes to hold user-maintained notes. Set Note.leaf or Mixin.leaf to indicate this, in which case using children or child will raise an exception.

For example, this would be necessary for a list of contacts:

@label("sorted")
@label("iconClass", "bx bx-group")
class Contacts(Note):
    singleton = True
    leaf = True

Now, assuming it’s been placed in your hierarchy, you can access your contact list by simply instantiating Contacts.