33Inspired by https://github.com/ftnext/sphinx-notion/blob/main/upload.py.
44"""
55
6+ import hashlib
67import json
78from enum import Enum
9+ from functools import cache
810from pathlib import Path
911from typing import TYPE_CHECKING , Any
1012from urllib .parse import urlparse
1113from urllib .request import url2pathname
14+ from uuid import UUID
1215
1316import click
1417from beartype import beartype
18+ from notion_client .errors import APIResponseError
1519from ultimate_notion import Emoji , Session
1620from ultimate_notion .blocks import PDF as UnoPDF # noqa: N811
1721from ultimate_notion .blocks import Audio as UnoAudio
1822from ultimate_notion .blocks import Block
1923from ultimate_notion .blocks import Image as UnoImage
2024from ultimate_notion .blocks import Video as UnoVideo
21- from ultimate_notion .file import UploadedFile
2225from ultimate_notion .obj_api .blocks import Block as UnoObjAPIBlock
2326
2427if TYPE_CHECKING :
2528 from ultimate_notion .database import Database
2629 from ultimate_notion .page import Page
2730
31+ _FILE_BLOCK_TYPES = (UnoImage , UnoVideo , UnoAudio , UnoPDF )
32+ _FileBlock = UnoImage | UnoVideo | UnoAudio | UnoPDF
33+
34+
35+ @beartype
36+ @cache
37+ def _calculate_file_sha (* , file_path : Path ) -> str :
38+ """
39+ Calculate SHA-256 hash of a file.
40+ """
41+ sha256_hash = hashlib .sha256 ()
42+ with file_path .open (mode = "rb" ) as f :
43+ for chunk in iter (lambda : f .read (4096 ), b"" ):
44+ sha256_hash .update (chunk )
45+ return sha256_hash .hexdigest ()
46+
2847
2948@beartype
30- def _upload_local_file (
49+ def _clean_deleted_blocks_from_mapping (
3150 * ,
32- url : str ,
51+ sha_to_block_id : dict [ str , str ] ,
3352 session : Session ,
34- ) -> UploadedFile | None :
53+ ) -> dict [str , str ]:
54+ """Remove deleted blocks from SHA mapping.
55+
56+ Returns a new dictionary with only existing blocks.
57+ """
58+ cleaned_mapping = sha_to_block_id .copy ()
59+ deleted_block_shas : set [str ] = set ()
60+
61+ for sha , block_id_str in sha_to_block_id .items ():
62+ block_id = UUID (hex = block_id_str )
63+ try :
64+ session .api .blocks .retrieve (block = block_id )
65+ except APIResponseError :
66+ deleted_block_shas .add (sha )
67+ msg = f"Block { block_id } does not exist, removing from SHA mapping"
68+ click .echo (message = msg )
69+
70+ for deleted_block_sha in deleted_block_shas :
71+ del cleaned_mapping [deleted_block_sha ]
72+
73+ return cleaned_mapping
74+
75+
76+ @beartype
77+ def _find_last_matching_block_index (
78+ * ,
79+ existing_blocks : list [Block ] | tuple [Block , ...],
80+ local_blocks : list [Block ],
81+ sha_to_block_id : dict [str , str ],
82+ ) -> int | None :
83+ """Find the last index where existing blocks match local blocks.
84+
85+ Returns the last index where blocks are equivalent, or None if no
86+ blocks match.
3587 """
36- Upload a local file and return the uploaded file object.
88+ last_matching_index : int | None = None
89+ for index , existing_page_block in enumerate (iterable = existing_blocks ):
90+ if index < len (local_blocks ) and (
91+ _is_existing_equivalent (
92+ existing_page_block = existing_page_block ,
93+ local_block = local_blocks [index ],
94+ sha_to_block_id = sha_to_block_id ,
95+ )
96+ ):
97+ last_matching_index = index
98+ else :
99+ break
100+ return last_matching_index
101+
102+
103+ @beartype
104+ def _is_existing_equivalent (
105+ * ,
106+ existing_page_block : Block ,
107+ local_block : Block ,
108+ sha_to_block_id : dict [str , str ],
109+ ) -> bool :
37110 """
38- parsed = urlparse (url = url )
39- if parsed .scheme != "file" :
40- return None
111+ Check if a local block is equivalent to an existing page block.
112+ """
113+ if existing_page_block == local_block :
114+ return True
41115
42- # Ignore ``mypy`` error as the keyword arguments are different across
43- # Python versions and platforms.
44- file_path = Path (url2pathname (parsed .path )) # type: ignore[misc]
45- with file_path .open (mode = "rb" ) as f :
46- uploaded_file = session .upload (
47- file = f ,
48- file_name = file_path .name ,
49- )
116+ if isinstance (local_block , _FILE_BLOCK_TYPES ):
117+ parsed = urlparse (url = local_block .url )
118+ if parsed .scheme == "file" :
119+ file_path = Path (url2pathname (parsed .path )) # type: ignore[misc]
120+ file_sha = _calculate_file_sha (file_path = file_path )
121+ existing_page_block_id_with_file_sha = sha_to_block_id .get (
122+ file_sha
123+ )
124+ if not existing_page_block_id_with_file_sha :
125+ return False
126+ if (
127+ UUID (hex = existing_page_block_id_with_file_sha )
128+ == existing_page_block .id
129+ ):
130+ return True
50131
51- uploaded_file .wait_until_uploaded ()
52- return uploaded_file
132+ return False
53133
54134
55135@beartype
@@ -58,15 +138,26 @@ def _block_from_details(
58138 details : dict [str , Any ],
59139 session : Session ,
60140) -> Block :
61- """Create a Block from a serialized block details.
62-
63- Upload any required local files.
141+ """
142+ Create a Block from a serialized block details.
64143 """
65144 block = Block .wrap_obj_ref (UnoObjAPIBlock .model_validate (obj = details ))
66145
67- if isinstance (block , (UnoImage , UnoVideo , UnoAudio , UnoPDF )):
68- uploaded_file = _upload_local_file (url = block .url , session = session )
69- if uploaded_file is not None :
146+ if isinstance (block , _FILE_BLOCK_TYPES ):
147+ parsed = urlparse (url = block .url )
148+ if parsed .scheme == "file" :
149+ # Ignore ``mypy`` error as the keyword arguments are different
150+ # across Python versions and platforms.
151+ file_path = Path (url2pathname (parsed .path )) # type: ignore[misc]
152+
153+ with file_path .open (mode = "rb" ) as file_stream :
154+ uploaded_file = session .upload (
155+ file = file_stream ,
156+ file_name = file_path .name ,
157+ )
158+
159+ uploaded_file .wait_until_uploaded ()
160+
70161 return block .__class__ (file = uploaded_file , caption = block .caption )
71162
72163 return block
@@ -115,6 +206,20 @@ class _ParentType(Enum):
115206 help = "Icon of the page" ,
116207 required = False ,
117208)
209+ @click .option (
210+ "--sha-mapping" ,
211+ help = (
212+ "JSON file mapping file SHAs to Notion block IDs "
213+ "(use one file per document)" ,
214+ ),
215+ required = False ,
216+ type = click .Path (
217+ exists = True ,
218+ path_type = Path ,
219+ file_okay = True ,
220+ dir_okay = False ,
221+ ),
222+ )
118223@beartype
119224def main (
120225 * ,
@@ -123,12 +228,23 @@ def main(
123228 parent_type : _ParentType ,
124229 title : str ,
125230 icon : str | None = None ,
231+ sha_mapping : Path | None = None ,
126232) -> None :
127233 """
128234 Upload documentation to Notion.
129235 """
130236 session = Session ()
131237
238+ sha_mapping_content = (
239+ sha_mapping .read_text (encoding = "utf-8" ) if sha_mapping else "{}"
240+ )
241+ sha_to_block_id : dict [str , str ] = dict (json .loads (s = sha_mapping_content ))
242+
243+ sha_to_block_id = _clean_deleted_blocks_from_mapping (
244+ sha_to_block_id = sha_to_block_id ,
245+ session = session ,
246+ )
247+
132248 blocks = json .loads (s = file .read_text (encoding = "utf-8" ))
133249
134250 parent : Page | Database
@@ -159,16 +275,15 @@ def main(
159275 page .icon = Emoji (emoji = icon )
160276
161277 block_objs = [
162- _block_from_details ( details = details , session = session )
278+ Block . wrap_obj_ref ( UnoObjAPIBlock . model_validate ( obj = details ) )
163279 for details in blocks
164280 ]
165281
166- last_matching_index : int | None = None
167- for index , existing_page_block in enumerate (iterable = page .children ):
168- if index < len (blocks ) and existing_page_block == block_objs [index ]:
169- last_matching_index = index
170- else :
171- break
282+ last_matching_index = _find_last_matching_block_index (
283+ existing_blocks = page .children ,
284+ local_blocks = block_objs ,
285+ sha_to_block_id = sha_to_block_id ,
286+ )
172287
173288 click .echo (
174289 message = (
@@ -180,5 +295,42 @@ def main(
180295 for existing_page_block in page .children [delete_start_index :]:
181296 existing_page_block .delete ()
182297
183- page .append (blocks = block_objs [delete_start_index :])
298+ block_objs_to_upload = [
299+ _block_from_details (details = details , session = session )
300+ for details in blocks [delete_start_index :]
301+ ]
302+ page .append (blocks = block_objs_to_upload )
303+
304+ if sha_mapping :
305+ for uploaded_block_index , uploaded_block in enumerate (
306+ iterable = block_objs_to_upload
307+ ):
308+ if isinstance (uploaded_block , _FILE_BLOCK_TYPES ):
309+ pre_uploaded_block = block_objs [
310+ delete_start_index + uploaded_block_index
311+ ]
312+ assert isinstance (pre_uploaded_block , _FILE_BLOCK_TYPES )
313+ parsed = urlparse (url = pre_uploaded_block .url )
314+ if parsed .scheme == "file" :
315+ # Ignore ``mypy`` error as the keyword arguments are
316+ # different across Python versions and platforms.
317+ file_path = Path (url2pathname (parsed .path )) # type: ignore[misc]
318+ file_sha = _calculate_file_sha (file_path = file_path )
319+ sha_to_block_id [file_sha ] = str (object = uploaded_block .id )
320+
321+ sha_mapping .write_text (
322+ data = json .dumps (
323+ obj = sha_to_block_id , indent = 2 , sort_keys = True
324+ )
325+ + "\n " ,
326+ encoding = "utf-8" ,
327+ )
328+
329+ click .echo (
330+ message = (
331+ f"Updated SHA mapping for { file_path .name } :"
332+ f"{ uploaded_block .id } "
333+ )
334+ )
335+
184336 click .echo (message = f"Updated existing page: '{ title } ' ({ page .url } )" )
0 commit comments