class CollectionView(Record): """ A "view" is a particular visualization of a collection, with a "type" (board, table, list, etc) and filters, sort, etc. """ _type = "collection_view" _table = "collection_view" name = field_map("name") type = field_map("type") def __init__(self, *args, collection, **kwargs): super().__init__(*args, **kwargs) self.collection = collection def build_query(self, **kwargs) -> CollectionQuery: return CollectionQuery(collection=self.collection, collection_view=self, **kwargs) def default_query(self) -> CollectionQuery: """ Return default query. """ return self.build_query(**self.get("query", {})) @property def parent(self): return self._client.get_block(self.get("parent_id"))
class BookmarkBlock(EmbedBlock): """ Bookmark Block. """ _type = "bookmark" bookmark_cover = field_map("format.bookmark_cover") bookmark_icon = field_map("format.bookmark_icon") description = property_map("description") link = property_map("link") title = property_map("title") def set_new_link(self, link: str): data = {"blockId": self.id, "url": link} self._client.post("setBookmarkMetadata", data) self.refresh()
class ColumnBlock(Block): """ Should be added as children of a ColumnListBlock. """ _type = "column" column_ratio = field_map("format.column_ratio")
class BasicBlock(Block): _type = "block" _str_fields = "title" title = property_map("title") title_plaintext = plaintext_property_map("title") color = field_map("format.block_color")
class UploadBlock(EmbedBlock): """ Upload Block. """ file_id = field_map(["file_ids", 0]) def upload_file(self, path: str): """ Upload a file and embed it in Notion. Arguments --------- path : str Valid path to a file. Raises ------ HTTPError On API error. """ content_type = guess_type(path)[0] or "text/plain" file_name = os.path.split(path)[-1] file_size = human_size(path) data = { "bucket": "secure", "name": file_name, "contentType": content_type } resp = self._client.post("getUploadFileUrl", data).json() with open(path, mode="rb") as f: response = requests.put(resp["signedPutUrl"], data=f, headers={"Content-Type": content_type}) response.raise_for_status() query = urlencode({ "cache": "v2", "name": file_name, "id": self._id, "table": self._table, "userId": self._client.current_user.id, }) url = resp["url"] query_url = f"{url}?{query}" # special case for FileBlock if hasattr(self, "size"): setattr(self, "size", file_size) self.source = query_url self.display_source = query_url self.file_id = urlparse(url).path.split("/")[2]
class EmbedBlock(MediaBlock): """ Embed Block. """ _type = "embed" _str_fields = "source" display_source = prefixed_field_map("format.display_source") source = prefixed_property_map("source") height = field_map("format.block_height") width = field_map("format.block_width") full_width = field_map("format.block_full_width") page_width = field_map("format.block_page_width") def set_source_url(self, url: str): self.source = remove_signed_prefix_as_needed(url) self.display_source = get_embed_link(self.source, self._client)
class EmbedBlock(MediaBlock): """ Embed Block. """ _type = "embed" _str_fields = "source" # TODO: why this exists? is it the same as `source`? display_source = prefixed_field_map("format.display_source") source = prefixed_property_map("source") height = field_map("format.block_height") width = field_map("format.block_width") full_width = field_map("format.block_full_width") page_width = field_map("format.block_page_width") def set_source_url(self, url): self.source = remove_signed_prefix_as_needed(url) self.display_source = get_embed_link(self.source)
class NotionUser(Record): """ Representation of a Notion user. """ _table = "notion_user" _str_fields = "email", "full_name" user_id = field_map("user_id") given_name = field_map("given_name") family_name = field_map("family_name") email = field_map("email") locale = field_map("locale") time_zone = field_map("time_zone") @property def full_name(self): """ Get full user name. Returns ------- str User name. """ given = self.given_name or "" family = self.family_name or "" return f"{given} {family}".strip()
class BoardView(CollectionView): _type = "board" group_by = field_map("query.group_by")
class NotionSpace(Record): """ Class representing notion's Space - user workplace. """ _type = "space" _table = "space" _str_fields = "name", "domain" _child_list_key = "pages" name = field_map("name") domain = field_map("domain") icon = field_map("icon") @property def pages(self) -> list: # The page list includes pages the current user # might not have permissions on, so it's slow to query. # Instead, we just filter for pages with the space as the parent. return self._client.search_pages_with_parent(self.id) @property def users(self) -> list: ids = [p["user_id"] for p in self.get("permissions")] self._client.refresh_records(notion_user=ids) return [self._client.get_user(uid) for uid in ids] def add_page( self, title, type: str = "page", shared: bool = False) -> Union[PageBlock, CollectionViewPageBlock]: """ Create new page. Arguments --------- title : str Title for the newly created page. type : str, optional Type of the page. Must be one of "page" or "collection_view_page". Defaults to "page". shared : bool, optional Whether or not the page should be shared (public). TODO: is it true? Defaults to False. """ perms = [{ "role": "editor", "type": "user_permission", "user_id": self._client.current_user.id, }] if shared: perms = [{"role": "editor", "type": "space_permission"}] page_id = self._client.create_record("block", self, type=type, permissions=perms) page = self._client.get_block(page_id) page.title = title return page
class CodeBlock(BasicBlock): _type = "code" language = property_map("language") wrap = field_map("format.code_wrap")
class CalloutBlock(BasicBlock): _type = "callout" icon = field_map("format.page_icon")
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") @property def children(self): 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 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 id in removed: changes.append(("content_removed", "content", id)) for id in added: changes.append(("content_added", "content", id)) # ignore the added/removed items, and see whether order has changed for id in removed: old.remove(id) for id in added: new.remove(id) 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) 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 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.submit_transaction( build_operation( 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.submit_transaction( build_operation(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.submit_transaction( build_operation( id=self.parent.id, path=[self.parent.child_list_key], args={"id": self.id}, command="listRemove", table=self.parent._table, )) if permanently: self._client.post("deleteBlocks", { "blockIds": [self.id], "permanentlyDelete": True }) del self._client._store._values["block"][self.id] def move_to(self, target_block: "Block", position="last-child"): assert position in ["first-child", "last-child", "before", "after"] 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" list_args = {"id": self.id} if position in ["before", "after"]: list_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.submit_transaction( build_operation( 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.submit_transaction( build_operation( id=new_parent_id, path=["content"], args=list_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"), ]) 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. """ args = dict(block_locked=locked, block_locked_by=self._client.current_user.id) with self._client.as_atomic_transaction(): self._client.submit_transaction( build_operation( 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])
class CollectionBlock(Block): """ Collection Block. """ _type = "collection" _table = "collection" _str_fields = "name" cover = field_map("cover") name = markdown_field_map("name") description = markdown_field_map("description") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._templates = None @property def templates(self) -> Templates: if not self._templates: template_ids = self.get("template_pages", []) self._client.refresh_records(block=template_ids) self._templates = Templates(parent=self) return self._templates def get_schema_properties(self) -> list: """ Fetch a flattened list of all properties in the collection's schema. Returns ------- list All properties. """ properties = [] for block_id, item in self.get("schema").items(): slug = slugify(item["name"]) prop = {"id": block_id, "slug": slug, **item} properties.append(prop) return properties def get_schema_property(self, identifier: str) -> Optional[dict]: """ Look up a property in the collection's schema by "property id" (generally a 4-char string), or name (human-readable -- there may be duplicates so we pick the first match we find). Attributes ---------- identifier : str Value used for searching the prop. Can be set to ID, slug or title (if property type is also title). Returns ------- dict, optional Schema of the property if found, or None. """ for prop in self.get_schema_properties(): if identifier == prop["id"] or slugify(identifier) == prop["slug"]: return prop if identifier == "title" and prop["type"] == "title": return prop return None def add_row(self, update_views=True, **kwargs) -> "CollectionRowBlock": """ Create a new empty CollectionRowBlock under this collection, and return the instance. Arguments --------- update_views : bool, optional Whether or not to update the views after adding the row to Collection. Defaults to True. kwargs : dict, optional Additional pairs of keys and values set in newly created CollectionRowBlock. Defaults to empty dict() Returns ------- CollectionRowBlock Added row. """ row_id = self._client.create_record("block", self, type="page") row = CollectionRowBlock(self._client, row_id) with self._client.as_atomic_transaction(): for key, val in kwargs.items(): setattr(row, key, val) if update_views: # make sure the new record is inserted at the end of each view for view in self.parent.views: if isinstance(view, CalendarView): continue view.set("page_sort", view.get("page_sort", []) + [row_id]) return row @property def parent(self): """ Get parent block. Returns ------- Block Parent block. """ assert self.get("parent_table") == "block" return self._client.get_block(self.get("parent_id")) def _get_a_collection_view(self): """ Get an arbitrary collection view for this collection, to allow querying. """ parent = self.parent assert isinstance(parent, CollectionViewBlock) assert len(parent.views) > 0 return parent.views[0] def query(self, **kwargs): """ Run a query inline and return the results. Returns ------- CollectionQueryResult Result of passed query. """ return CollectionQuery(self, self._get_a_collection_view(), **kwargs).execute() def get_rows(self, **kwargs): return self.query(**kwargs) def _convert_diff_to_changelist(self, difference, old_val, new_val): changes = [] remaining = [] for operation, path, values in difference: if path == "rows": changes.append((operation, path, values)) else: remaining.append((operation, path, values)) return changes + super()._convert_diff_to_changelist( remaining, old_val, new_val)
class UploadBlock(EmbedBlock): """ Upload Block. """ file_id = field_map("file_ids.0") def upload_file(self, path: str): """ Upload a file and embed it in Notion. Arguments --------- path : str Valid path to a file. Raises ------ HTTPError On API error. """ content_type = guess_type(path)[0] or "text/plain" file_name = os.path.split(path)[-1] data = { "bucket": "secure", "name": file_name, "contentType": content_type } resp = self._client.post("getUploadFileUrl", data) resp.raise_for_status() resp_data = resp.json() url = resp_data["url"] signed_url = resp_data["signedPutUrl"] with open(path, mode="rb") as f: headers = {"Content-Type": content_type} resp = self._client.put(signed_url, data=f, headers=headers) resp.raise_for_status() query = urlencode({ "cache": "v2", "name": file_name, "id": self._id, "table": self._table, "userId": self._client.current_user.id, }) query_url = f"{url}?{query}" self.source = query_url self.display_source = query_url self.file_id = urlparse(url).path.split("/")[2] def download_file(self, path: str): """ Download a file. Arguments --------- path : str Path for saving file. Raises ------ HTTPError On API error. """ record_data = self._get_record_data() source = record_data["properties"]["source"] s3_url = from_list(source) file_name = s3_url.split("/")[-1] params = { "cache": "v2", "name": file_name, "id": self._id, "table": self._table, "userId": self._client.current_user.id, "download": True, } url = SIGNED_URL_PREFIX + quote(s3_url, safe="") resp = self._client.session.get(url, params=params, stream=True) resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_content(chunk_size=4096): f.write(chunk)