from copy import deepcopy
from notion.maps import (
property_map,
plaintext_property_map,
field_map,
prefixed_field_map,
nested_field_map,
boolean_property_map,
Mapper,
)
from notion.record import Record
from notion.settings import BASE_URL
from notion.utils import get_by_path
[docs]class Block(Record):
"""
Base class for every kind of notion block object.
Most data in Notion is stored as a "block". That includes pages
and all the individual elements within a page. These blocks have
different types, and in some cases we create subclasses of this
class to represent those types.
Attributes on the `Block` are mapped to useful attributes of the
server-side data structure, as properties, so you can get and set
values on the API just by reading/writing attributes on these classes.
We store a shared local cache on the `NotionClient` object
of all block data, and reference that as needed from here.
Data can be refreshed from the server using the `refresh` method.
"""
_table = "block"
_type = "block"
_str_fields = "type"
# we'll mark it as an alias if we load the Block
# as a child of a page that is not its parent
_alias_parent = None
_child_list_key = "content"
type = field_map("type")
alive = field_map("alive")
def _convert_diff_to_changelist(self, difference, old_val, new_val):
# TODO: cached property?
mappers = {}
for name in dir(self.__class__):
field = getattr(self.__class__, name)
if isinstance(field, Mapper):
mappers[name] = field
changed_fields = set()
changes = []
remaining = []
content_changed = False
for d in deepcopy(difference):
operation, path, values = d
# normalize path
path = path if path else []
path = path.split(".") if isinstance(path, str) else path
if operation in ["add", "remove"]:
path.append(values[0][0])
while isinstance(path[-1], int):
path.pop()
path = ".".join(map(str, path))
# check whether it was content that changed
if path == "content":
content_changed = True
continue
# check whether the value changed matches
# one of our mapped fields/properties
fields = [
(name, field)
for name, field in mappers.items()
if path.startswith(field.path)
]
if fields:
changed_fields.add(fields[0])
continue
remaining.append(d)
if content_changed:
old = deepcopy(old_val.get("content", []))
new = deepcopy(new_val.get("content", []))
# track what's been added and removed
removed = set(old) - set(new)
added = set(new) - set(old)
for i in removed:
changes.append(("content_removed", "content", i))
for i in added:
changes.append(("content_added", "content", i))
# ignore the added/removed items, and see whether order has changed
for i in removed:
old.remove(i)
for i in added:
new.remove(i)
if old != new:
changes.append(("content_reordered", "content", (old, new)))
for name, field in changed_fields:
old = field.api_to_python(get_by_path(field.path, old_val))
new = field.api_to_python(get_by_path(field.path, new_val))
changes.append(("changed_field", name, (old, new)))
return changes + super()._convert_diff_to_changelist(
remaining, old_val, new_val
)
[docs] def get_browseable_url(self) -> str:
"""
Return direct URL to given Block.
Returns
-------
str
valid URL
"""
short_id = self.id.replace("-", "")
if "page" in self._type:
return BASE_URL + short_id
else:
return self.parent.get_browseable_url() + "#" + short_id
[docs] def remove(self, permanently: bool = False):
"""
Remove the node from its parent, and mark it as inactive.
This corresponds to what happens in the Notion UI when you
delete a block. Note that it doesn't *actually* delete it,
just orphan it, unless `permanently` is set to True,
in which case we make an extra call to hard-delete.
Arguments
---------
permanently : bool, optional
Whether or not to hard-delete the block.
Defaults to False.
"""
if self.is_alias:
# only remove it from the alias parent's content list
return self._client.build_and_submit_transaction(
record_id=self._alias_parent,
path="content",
args={"id": self.id},
command="listRemove",
)
with self._client.as_atomic_transaction():
# Mark the block as inactive
self._client.build_and_submit_transaction(
record_id=self.id, path="", args={"alive": False}, command="update"
)
# Remove the block's ID from a list on its parent, if needed
if self.parent._child_list_key:
self._client.build_and_submit_transaction(
record_id=self.parent.id,
path=self.parent._child_list_key,
args={"id": self.id},
command="listRemove",
table=self.parent._table,
)
if permanently:
data = {"blockIds": [self.id], "permanentlyDelete": True}
self._client.post("deleteBlocks", data=data)
del self._client._store._values["block"][self.id]
[docs] def move_to(self, target_block: "Block", position="last-child"):
if position not in ["first-child", "last-child", "before", "after"]:
raise ValueError("Provided value for position is not valid.")
if "child" in position:
new_parent_id = target_block.id
new_parent_table = "block"
else:
new_parent_id = target_block.get("parent_id")
new_parent_table = target_block.get("parent_table")
if position in ["first-child", "before"]:
list_command = "listBefore"
else:
list_command = "listAfter"
args = {"id": self.id}
if position in ["before", "after"]:
args[position] = target_block.id
with self._client.as_atomic_transaction():
# First, remove the node, before we re-insert
# and re-activate it at the target location
self.remove()
if not self.is_alias:
# Set the parent_id of the moving block to the new parent,
# and mark it as active again
self._client.build_and_submit_transaction(
record_id=self.id,
path="",
args={
"alive": True,
"parent_id": new_parent_id,
"parent_table": new_parent_table,
},
command="update",
)
else:
self._alias_parent = new_parent_id
# Add the moving block's ID to the "content" list of the new parent
self._client.build_and_submit_transaction(
record_id=new_parent_id,
path="content",
args=args,
command=list_command,
)
# update the local block cache to reflect the updates
self._client.refresh_records(
block=[
self.id,
self.get("parent_id"),
target_block.id,
target_block.get("parent_id"),
]
)
[docs] def change_lock(self, locked: bool):
"""
Set or free the lock according to the value passed in `locked`.
Arguments
---------
locked : bool
Whether or not to lock the block.
"""
user_id = self._client.current_user.id
args = {"block_locked": locked, "block_locked_by": user_id}
with self._client.as_atomic_transaction():
self._client.build_and_submit_transaction(
record_id=self.id,
path="format",
args=args,
command="update",
)
# update the local block cache to reflect the updates
self._client.refresh_records(block=[self.id])
@property
def children(self):
"""
Get block children.
Returns
-------
Children
Children of this block.
"""
if not self._children:
children_ids = self.get("content", [])
self._client.refresh_records(block=children_ids)
# TODO: can we do something about that without breaking
# the current code layout?
from notion.block.children import Children
self._children = Children(parent=self)
return self._children
@property
def is_alias(self):
return self._alias_parent is not None
@property
def parent(self):
parent_id = self._alias_parent
parent_table = "block"
if not self.is_alias:
parent_id = self.get("parent_id")
parent_table = self.get("parent_table")
getter = getattr(self._client, f"get_{parent_table}")
if getter:
return getter(parent_id)
return None
[docs]class BasicBlock(Block):
_type = "block"
_str_fields = "title"
title = property_map("title")
title_plaintext = plaintext_property_map("title")
color = field_map("format.block_color")
[docs]class DividerBlock(Block):
_type = "divider"
[docs]class ColumnBlock(Block):
"""
Should be added as children of a ColumnListBlock.
"""
_type = "column"
column_ratio = field_map("format.column_ratio")
[docs]class ColumnListBlock(Block):
"""
Must contain only ColumnBlocks as children.
"""
_type = "column_list"
[docs] def evenly_space_columns(self):
with self._client.as_atomic_transaction():
for child in self.children:
child.column_ratio = 1 / len(self.children)
[docs]class PageBlock(BasicBlock):
_type = "page"
icon = prefixed_field_map("format.page_icon")
cover = prefixed_field_map("format.page_cover")
[docs]class TextBlock(BasicBlock):
_type = "text"
[docs]class CalloutBlock(BasicBlock):
_type = "callout"
icon = field_map("format.page_icon")
[docs]class CodeBlock(BasicBlock):
_type = "code"
language = property_map("language")
wrap = field_map("format.code_wrap")
[docs]class LinkToPageBlock(BasicBlock):
_type = "link_to_page"
[docs]class EquationBlock(BasicBlock):
_type = "equation"
latex = nested_field_map("properties.title")
[docs]class QuoteBlock(BasicBlock):
_type = "quote"
[docs]class ToDoBlock(BasicBlock):
_type = "to_do"
_str_fields = "checked"
checked = boolean_property_map("checked")
[docs]class ToggleBlock(BasicBlock):
_type = "toggle"
[docs]class BulletedListBlock(BasicBlock):
_type = "bulleted_list"
[docs]class NumberedListBlock(BasicBlock):
_type = "numbered_list"
[docs]class FactoryBlock(BasicBlock):
"""
Also known as a "Template Button"
The title is the button text,
and the children are the templates to clone.
"""
_type = "factory"