Skip to content

Utils Layers

This section is about the underlying utility functions that are used in the plugin, will be called by plugin core and CLI directly.

And specifically, the common module of CLI will call plugin core to get the plugin configuration instance, and scanner module will call meta module to validate the note file's frontmatter.

Notion sync lives under mkdocs_note.utils.notion and is invoked only from the CLI (notion-sync / ns), not from MkDocs build hooks.

Developer note: Cursor MCP token (optional)

notion_sync.allow_cursor_mcp_token (default false) may allow reading a Notion token from ~/.cursor/mcp.json for local developer convenience. When a token is loaded this way, a warning is emitted unless silence_mcp_token_warning: true. Prefer NOTION_TOKEN / project .env for normal use. This option is intentionally omitted from the user handbook.

Metadata helpers for note frontmatter.

Supports both MkDocs File objects (plugin build path) and plain text / path APIs used by CLI tools such as Notion sync.

extract_date(f)

Extract date from docs file.

Parameters:

Name Type Description Default
f File

The file to extract date from.

required

Returns:

Type Description
datetime | None

Optional[datetime]: The date if successful, None otherwise.

Source code in src/mkdocs_note/utils/meta.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def extract_date(f: File) -> datetime | None:
	"""Extract date from docs file.

	Args:
	    f: The file to extract date from.

	Returns:
	    Optional[datetime]: The date if successful, None otherwise.
	"""
	try:
		return f.note_date
	except AttributeError:
		return None

extract_tags(meta_dict)

Normalize frontmatter tags / tag into a list of non-empty strings.

Parameters:

Name Type Description Default
meta_dict dict[str, Any]

Parsed frontmatter mapping.

required

Returns:

Type Description
list[str]

List of tag strings.

Source code in src/mkdocs_note/utils/meta.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def extract_tags(meta_dict: dict[str, Any]) -> list[str]:
	"""Normalize frontmatter ``tags`` / ``tag`` into a list of non-empty strings.

	Args:
	    meta_dict: Parsed frontmatter mapping.

	Returns:
	    List of tag strings.
	"""
	raw = meta_dict.get("tags", meta_dict.get("tag", None))
	if raw is None:
		return []
	if isinstance(raw, str):
		parts = [p.strip() for p in re.split(r"[,;]", raw)]
		return [p for p in parts if p]
	if isinstance(raw, (list, tuple)):
		out: list[str] = []
		for item in raw:
			if item is None:
				continue
			s = str(item).strip()
			if s:
				out.append(s)
		return out
	s = str(raw).strip()
	return [s] if s else []

extract_title(f)

Extract title from docs file.

Parameters:

Name Type Description Default
f File

The file to extract title from.

required

Returns:

Type Description
str | None

Optional[str]: The title if successful, None otherwise.

Source code in src/mkdocs_note/utils/meta.py
162
163
164
165
166
167
168
169
170
171
172
173
174
def extract_title(f: File) -> str | None:
	"""Extract title from docs file.

	Args:
	    f: The file to extract title from.

	Returns:
	    Optional[str]: The title if successful, None otherwise.
	"""
	try:
		return f.note_title
	except AttributeError:
		return None

parse_frontmatter(text)

Split markdown text into frontmatter dict and body.

Parameters:

Name Type Description Default
text str

Full file contents.

required

Returns:

Type Description
tuple[dict[str, Any], str]

Tuple of (metadata dict, body without frontmatter).

Source code in src/mkdocs_note/utils/meta.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
	"""Split markdown text into frontmatter dict and body.

	Args:
	    text: Full file contents.

	Returns:
	    Tuple of (metadata dict, body without frontmatter).
	"""
	if not text.startswith("---"):
		return {}, text
	end = text.find("\n---", 3)
	if end == -1:
		return {}, text
	raw = text[3:end].strip()
	body = text[end + 4 :].lstrip("\n")
	if not raw:
		return {}, body
	try:
		loaded = yaml.safe_load(raw)
		meta_dict = loaded if isinstance(loaded, dict) else {}
	except (yaml.YAMLError, AttributeError, TypeError):
		# Fallback: flat key: value (no nested lists).
		meta_dict = {}
		for line in raw.splitlines():
			if ":" not in line:
				continue
			key, value = line.split(":", 1)
			key = key.strip()
			value = value.strip()
			if value.startswith('"') and value.endswith('"'):
				value = value[1:-1]
			meta_dict[key] = value
	return meta_dict, body

parse_frontmatter_file(path)

Read a file and parse its frontmatter.

Parameters:

Name Type Description Default
path Path

Path to a markdown (or text) file.

required

Returns:

Type Description
tuple[dict[str, Any], str]

Tuple of (metadata dict, body).

Source code in src/mkdocs_note/utils/meta.py
58
59
60
61
62
63
64
65
66
67
68
def parse_frontmatter_file(path: Path) -> tuple[dict[str, Any], str]:
	"""Read a file and parse its frontmatter.

	Args:
	    path: Path to a markdown (or text) file.

	Returns:
	    Tuple of (metadata dict, body).
	"""
	raw = path.read_text(encoding="utf-8")
	return parse_frontmatter(raw)

validate_frontmatter(f)

Validate the frontmatter of the file.

Parameters:

Name Type Description Default
f File

The file to validate.

required

Returns:

Name Type Description
bool bool

True if the frontmatter is valid, False otherwise.

Source code in src/mkdocs_note/utils/meta.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def validate_frontmatter(f: File) -> bool:
	"""Validate the frontmatter of the file.

	Args:
	    f: The file to validate.

	Returns:
	    bool: True if the frontmatter is valid, False otherwise.
	"""
	try:
		_, frontmatter = mkdocs_meta.get_data(f.content_string)

		if not frontmatter.get("publish", False):
			logger.debug(f"Skipping {f.src_uri} because it is not published")
			return False

		if "date" not in frontmatter:
			logger.error(f"Invalid frontmatter for {f.src_uri}: 'date' is required")
			return False

		date = frontmatter["date"]
		if not isinstance(date, datetime):
			logger.error(
				f"Invalid frontmatter for {f.src_uri}: 'date' must be a datetime object"
			)
			return False

		f.note_date = date

		if "title" not in frontmatter:
			logger.error(f"Invalid frontmatter for {f.src_uri}: 'title' is required")
			return False

		title = frontmatter["title"]
		if not isinstance(title, str):
			logger.error(
				f"Invalid frontmatter for {f.src_uri}: 'title' must be a string"
			)
			return False

		f.note_title = title
		return True

	except Exception as e:
		logger.error(f"Error validating frontmatter for {f.src_uri}: {e}")
		raise

scan_notes(files, config)

Scan notes directory, return all supported note files

Parameters:

Name Type Description Default
files Files

The list of files to scan

required
config

Plugin configuration

required

Returns:

Type Description
tuple[list[File], list[File]]

tuple[list[File], list[File]]: (valid notes, invalid files)

Source code in src/mkdocs_note/utils/scanner.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def scan_notes(files: Files, config) -> tuple[list[File], list[File]]:
	"""Scan notes directory, return all supported note files

	Args:
		files (Files): The list of files to scan
		config: Plugin configuration

	Returns:
		tuple[list[File], list[File]]: (valid notes, invalid files)
	"""
	notes_dir = (
		Path(config.notes_root)
		if isinstance(config.notes_root, str)
		else config.notes_root
	)
	if not notes_dir.exists():
		logger.warning(f"Notes directory does not exist: {notes_dir}")
		return [], []

	notes = []
	invalid_files = []

	try:
		for f in files:
			# Skip non-documentation pages
			if not f.is_documentation_page():
				continue

			# Check if file is within notes_root by comparing absolute paths
			# f.abs_src_path is the absolute path to the source file
			try:
				file_path = Path(f.abs_src_path)
				# Check if the file is within the notes_root directory
				file_path.relative_to(notes_dir)
			except (ValueError, AttributeError):
				# File is not within notes_root
				continue

			# Validate frontmatter
			if validate_frontmatter(f):
				notes.append(f)
			else:
				invalid_files.append(f)
	except Exception as e:
		logger.error(f"Error scanning notes: {e}")
		raise

	return notes, invalid_files

Hierarchical page-tree helpers shared by CLI tools and Notion sync.

Produces a uniform TreeNode shape from either .nav.yml (awesome-nav) or a filesystem walk under notes_root (directory hierarchy preserved).

TreeNode dataclass

A navigation / filesystem tree node.

Leaf content pages set file_rel; intermediate sections leave it None.

Source code in src/mkdocs_note/utils/tree.py
21
22
23
24
25
26
27
28
29
30
31
32
@dataclass
class TreeNode:
	"""A navigation / filesystem tree node.

	Leaf content pages set ``file_rel``; intermediate sections leave it ``None``.
	"""

	key: str
	title: str
	file_rel: str | None
	parent_key: str
	children: list[TreeNode] = field(default_factory=list)

build_directory_tree(root, *, parent_key='', rel_prefix='')

Build a TreeNode forest from filesystem hierarchy under root.

Directories become sections; .md / .ipynb leaves become content pages. index.md files are omitted from the tree (callers still skip them on sync).

Parameters:

Name Type Description Default
root Path

Absolute or relative notes root directory.

required
parent_key str

Parent nav key for this level.

''
rel_prefix str

Docs-relative path prefix for children.

''

Returns:

Type Description
list[TreeNode]

Ordered list of tree nodes for this directory level.

Source code in src/mkdocs_note/utils/tree.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def build_directory_tree(
	root: Path,
	*,
	parent_key: str = "",
	rel_prefix: str = "",
) -> list[TreeNode]:
	"""Build a ``TreeNode`` forest from filesystem hierarchy under ``root``.

	Directories become sections; ``.md`` / ``.ipynb`` leaves become content pages.
	``index.md`` files are omitted from the tree (callers still skip them on sync).

	Args:
	    root: Absolute or relative notes root directory.
	    parent_key: Parent nav key for this level.
	    rel_prefix: Docs-relative path prefix for children.

	Returns:
	    Ordered list of tree nodes for this directory level.
	"""
	root = Path(root)
	if not root.is_dir():
		return []

	items: list[TreeNode] = []
	# Stable ordering: directories first (alpha), then files (alpha).
	entries = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
	for entry in entries:
		if entry.name.startswith("."):
			continue
		if entry.is_dir():
			# Skip co-located asset directories.
			if entry.name == "assets":
				continue
			rel = docs_rel(f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name)
			section_key = rel
			children = build_directory_tree(
				entry, parent_key=section_key, rel_prefix=rel
			)
			if not children:
				continue
			items.append(
				TreeNode(
					key=section_key,
					title=entry.name,
					file_rel=None,
					parent_key=parent_key,
					children=children,
				)
			)
			continue

		suffix = entry.suffix.lower()
		if suffix not in CONTENT_SUFFIXES:
			continue
		rel = docs_rel(f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name)
		if is_index_doc(rel):
			continue
		items.append(
			TreeNode(
				key=rel,
				title=title_from_path(entry),
				file_rel=rel,
				parent_key=parent_key,
			)
		)
	return items

build_nav_tree(nodes, parent_key='', *, docs_prefix='docs/')

Build a TreeNode forest from awesome-nav YAML nodes.

Source code in src/mkdocs_note/utils/tree.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def build_nav_tree(
	nodes: list[Any],
	parent_key: str = "",
	*,
	docs_prefix: str = "docs/",
) -> list[TreeNode]:
	"""Build a ``TreeNode`` forest from awesome-nav YAML nodes."""
	items: list[TreeNode] = []
	for node in nodes:
		if isinstance(node, str):
			rel = resolve_doc_path(node, docs_prefix=docs_prefix)
			items.append(
				TreeNode(
					key=rel,
					title=title_from_path(Path(rel)),
					file_rel=rel,
					parent_key=parent_key,
				)
			)
			continue
		if not isinstance(node, dict):
			continue
		for title, child in node.items():
			title_s = str(title)
			if isinstance(child, str):
				rel = resolve_doc_path(child, docs_prefix=docs_prefix)
				items.append(
					TreeNode(
						key=rel,
						title=title_s,
						file_rel=rel,
						parent_key=parent_key,
					)
				)
			elif isinstance(child, list):
				section_key = f"{parent_key}/{title_s}" if parent_key else title_s
				section = TreeNode(
					key=section_key,
					title=title_s,
					file_rel=None,
					parent_key=parent_key,
					children=build_nav_tree(
						child, section_key, docs_prefix=docs_prefix
					),
				)
				items.append(section)
	return items

build_page_tree(*, nav_file, notes_root, docs_dir)

Resolve page tree: prefer .nav.yml, else notes_root directory scan.

Parameters:

Name Type Description Default
nav_file Path | None

Path to .nav.yml (may be None or missing).

required
notes_root Path

Notes directory for filesystem fallback.

required
docs_dir Path

Docs root used when stripping prefixes in nav paths.

required

Returns:

Type Description
list[TreeNode]

Tuple of (tree, source_label) where source_label is "nav.yml" or

str

"directory".

Source code in src/mkdocs_note/utils/tree.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def build_page_tree(
	*,
	nav_file: Path | None,
	notes_root: Path,
	docs_dir: Path,
) -> tuple[list[TreeNode], str]:
	"""Resolve page tree: prefer ``.nav.yml``, else ``notes_root`` directory scan.

	Args:
	    nav_file: Path to ``.nav.yml`` (may be None or missing).
	    notes_root: Notes directory for filesystem fallback.
	    docs_dir: Docs root used when stripping prefixes in nav paths.

	Returns:
	    Tuple of (tree, source_label) where source_label is ``"nav.yml"`` or
	    ``"directory"``.
	"""
	docs_prefix = docs_rel(str(docs_dir)).rstrip("/") + "/"
	if nav_file is not None and nav_file.is_file():
		nodes = load_nav_yaml(nav_file)
		return build_nav_tree(nodes, docs_prefix=docs_prefix), "nav.yml"

	logger.warning(
		"Navigation file not found (%s). Falling back to directory scan of "
		"'%s' (directory hierarchy preserved). For custom titles and grouping "
		"that match your site nav, install mkdocs-awesome-nav and add a "
		".nav.yml under your docs directory.",
		nav_file,
		notes_root,
	)
	return build_directory_tree(notes_root), "directory"

docs_rel(path)

Normalize path separators to forward slashes.

Source code in src/mkdocs_note/utils/tree.py
35
36
37
def docs_rel(path: str) -> str:
	"""Normalize path separators to forward slashes."""
	return path.replace("\\", "/")

index_tree(tree)

Map node key → node for the entire tree.

Source code in src/mkdocs_note/utils/tree.py
71
72
73
def index_tree(tree: list[TreeNode]) -> dict[str, TreeNode]:
	"""Map node key → node for the entire tree."""
	return {item.key: item for item in walk_tree(tree)}

is_index_doc(rel)

True for any docs-relative path whose basename is index.md.

Source code in src/mkdocs_note/utils/tree.py
45
46
47
def is_index_doc(rel: str) -> bool:
	"""True for any docs-relative path whose basename is ``index.md``."""
	return Path(rel).name.lower() == "index.md"

load_nav_yaml(nav_path)

Load the nav list from an awesome-nav .nav.yml file.

Source code in src/mkdocs_note/utils/tree.py
76
77
78
79
80
81
82
83
84
def load_nav_yaml(nav_path: Path) -> list[Any]:
	"""Load the ``nav`` list from an awesome-nav ``.nav.yml`` file."""
	with nav_path.open("r", encoding="utf-8") as f:
		data = yaml.safe_load(f) or {}
	if isinstance(data, dict):
		return data.get("nav", data) if isinstance(data.get("nav", data), list) else []
	if isinstance(data, list):
		return data
	return []

resolve_doc_path(raw, docs_prefix='docs/')

Strip optional docs prefix and quotes from a nav path entry.

Source code in src/mkdocs_note/utils/tree.py
50
51
52
53
54
55
56
57
58
59
def resolve_doc_path(raw: str, docs_prefix: str = "docs/") -> str:
	"""Strip optional docs prefix and quotes from a nav path entry."""
	raw = raw.strip().strip('"').strip("'")
	prefix = docs_prefix if docs_prefix.endswith("/") else f"{docs_prefix}/"
	# Also accept bare "docs/" when docs_dir is custom.
	if raw.startswith("docs/"):
		raw = raw[5:]
	elif prefix != "docs/" and raw.startswith(prefix):
		raw = raw[len(prefix) :]
	return docs_rel(raw)

title_from_path(path)

Default title from a file stem.

Source code in src/mkdocs_note/utils/tree.py
40
41
42
def title_from_path(path: Path) -> str:
	"""Default title from a file stem."""
	return path.stem

walk_tree(items)

Depth-first flatten of a tree.

Source code in src/mkdocs_note/utils/tree.py
62
63
64
65
66
67
68
def walk_tree(items: list[TreeNode]) -> list[TreeNode]:
	"""Depth-first flatten of a tree."""
	ordered: list[TreeNode] = []
	for item in items:
		ordered.append(item)
		ordered.extend(walk_tree(item.children))
	return ordered

Notion sync utilities (convert / client / sync).

SyncOptions dataclass

Configuration for a Notion sync run (assembled by CLI / mkdocs.yml).

Source code in src/mkdocs_note/utils/notion/sync.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@dataclass
class SyncOptions:
	"""Configuration for a Notion sync run (assembled by CLI / mkdocs.yml)."""

	project_root: Path
	docs_dir: Path
	notes_root: Path
	nav_file: Path | None
	database_id: str
	data_source_id: str
	site_url: str
	state_path: Path
	delay: float
	title_property: str = "页面"
	tags_property: str = "标签"
	token: str | None = None
	allow_cursor_mcp_token: bool = False
	silence_mcp_token_warning: bool = False
	full: bool = False
	base: str | None = None
	paths: list[str] | None = None
	paths_file: Path | None = None
	section: list[str] | None = None
	rebuild_state: bool = False
	no_images: bool = False
	dry_run: bool = False
	continue_on_error: bool = False
	verbose: bool = False

run_sync(options)

Main sync entry used by the CLI. Returns a process exit code.

Source code in src/mkdocs_note/utils/notion/sync.py
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
def run_sync(options: SyncOptions) -> int:
	"""Main sync entry used by the CLI. Returns a process exit code."""
	options = _apply_env_overrides(options)
	if options.verbose:
		setup_logging(True)

	token = resolve_token(
		options.token,
		options.project_root,
		allow_cursor_mcp_token=options.allow_cursor_mcp_token,
		silence_mcp_token_warning=options.silence_mcp_token_warning,
	)
	if not token and not options.dry_run:
		log.error(
			"Notion token not found. Set NOTION_TOKEN, add a project .env / "
			".notion_token, or enable allow_cursor_mcp_token for developer use."
		)
		return 1

	if not options.database_id or not options.data_source_id:
		log.error(
			"database_id and data_source_id are required "
			"(mkdocs.yml notion_sync or NOTION_WIKI_DATABASE / "
			"NOTION_WIKI_DATA_SOURCE)."
		)
		return 1

	state_path = options.state_path
	sections = options.section
	docs_prefix = _docs_prefix(options.project_root, options.docs_dir)

	# Resolve what to sync.
	path_list: list[str] = []
	if options.paths:
		path_list.extend(options.paths)
	if options.paths_file is not None:
		raw = Path(options.paths_file).read_text(encoding="utf-8")
		path_list.extend(line.strip() for line in raw.splitlines() if line.strip())

	if path_list:
		normalized: set[str] = set()
		for p in path_list:
			normalized.add(_strip_docs_prefix(p, docs_prefix))
		diff = DiffSet(md_changed=normalized)
		base: str | None = "(paths)"
		full = False
	elif options.full:
		diff = DiffSet(nav_changed=True)
		base = None
		full = True
	else:
		base = resolve_git_base(options.project_root, options.base)
		full = base is None
		if full:
			log.info("no git base available → full sync")
			diff = DiffSet(nav_changed=True)
		else:
			log.info("incremental sync since %s", base)
			diff = git_diff(base, options.project_root, options.docs_dir)

	log.info(
		"diff: md=%d deleted=%d assets=%d nav_changed=%s full=%s",
		len(diff.md_changed),
		len(diff.md_deleted),
		len(diff.assets_changed),
		diff.nav_changed,
		full,
	)

	tree, tree_source = build_page_tree(
		nav_file=options.nav_file,
		notes_root=options.notes_root,
		docs_dir=options.docs_dir,
	)
	log.info("page tree source: %s", tree_source)
	nav_index = index_tree(tree)

	# Load / rebuild page map.
	state = load_state(state_path, default_title=options.title_property)
	need_rebuild = options.rebuild_state or not state.pages
	if need_rebuild:
		if options.dry_run and not token:
			log.warning("dry-run without token: empty state")
		else:
			assert token
			log.info("rebuilding page map from Notion wiki…")
			state = rebuild_state_from_wiki(
				token,
				data_source_id=options.data_source_id,
				database_id=options.database_id,
				title_property=options.title_property,
				tree=tree,
				sections=sections,
			)
			if not options.dry_run:
				save_state(state_path, state)
			log.info("mapped %d keys", len(state.pages))
	else:
		state.root_page_id = state.root_page_id or options.database_id
		state.data_source_id = state.data_source_id or options.data_source_id
		state.title_property = state.title_property or options.title_property

	targets, deleted = collect_targets(
		full=full,
		diff=diff,
		nav_index=nav_index,
		docs_dir=options.docs_dir,
		sections=sections,
	)

	if deleted:
		for rel in sorted(deleted):
			log.info("deleted locally (Notion page left intact): %s", rel)

	if not targets:
		log.info("nothing to sync")
		return 0

	tags_cache: TagsSchemaCache | None = None
	if not options.dry_run and token:
		tags_cache = TagsSchemaCache(
			data_source_id=state.data_source_id or options.data_source_id,
			property_name=options.tags_property,
		)

	log.info("syncing %d page(s)", len(targets))
	stats = {"created": 0, "updated": 0, "dry-run": 0, "missing": 0, "failed": 0}
	for item in targets:
		try:
			result = sync_one_page(
				token or "",
				state,
				state_path,
				nav_index,
				item,
				docs_dir=options.docs_dir,
				site_url=options.site_url,
				delay=options.delay,
				dry_run=options.dry_run,
				upload_images=not options.no_images,
				tags_property=options.tags_property,
				tags_cache=tags_cache,
			)
			stats[result] = stats.get(result, 0) + 1
		except urllib.error.HTTPError as exc:
			body = getattr(exc, "reason", "") or ""
			log.error("FAIL %s: %s %s", item.file_rel, exc.code, body)
			stats["failed"] += 1
			if not options.continue_on_error:
				return 1
		except Exception as exc:  # noqa: BLE001 - per-page catch-all for continue_on_error
			log.error("FAIL %s: %s", item.file_rel, exc)
			stats["failed"] += 1
			if not options.continue_on_error:
				return 1

	if not options.dry_run:
		save_state(state_path, state)
	log.info("done %s", json.dumps(stats, ensure_ascii=False))
	return 1 if stats["failed"] else 0

client

Notion HTTP API helpers for page sync.

Handles page create/update, tags schema, file uploads, and block operations. Wiki IDs are always passed by the caller — never hardcoded here.

TagsSchemaCache dataclass

Cached multi_select options for a wiki tags property.

Parameters:

Name Type Description Default
data_source_id str

Notion data source id (caller-supplied).

required
property_name str

Multi-select property name (default 标签).

'标签'
Source code in src/mkdocs_note/utils/notion/client.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
@dataclass
class TagsSchemaCache:
	"""Cached multi_select options for a wiki tags property.

	Args:
	    data_source_id: Notion data source id (caller-supplied).
	    property_name: Multi-select property name (default ``标签``).
	"""

	data_source_id: str
	property_name: str = "标签"
	option_names: set[str] = field(default_factory=set)
	loaded: bool = False

	def refresh(self, token: str) -> None:
		"""Load current multi_select option names from the data source schema."""
		ds = notion_request(token, "GET", f"data_sources/{self.data_source_id}")
		prop = (ds.get("properties") or {}).get(self.property_name) or {}
		if prop.get("type") != "multi_select":
			raise RuntimeError(
				f"Notion property {self.property_name!r} is not multi_select "
				f"(got {prop.get('type')!r})"
			)
		options = (prop.get("multi_select") or {}).get("options") or []
		self.option_names = {
			str(o.get("name", "")).strip() for o in options if o.get("name")
		}
		self.loaded = True

	def ensure_options(self, token: str, tags: list[str]) -> None:
		"""Merge missing tag names into the data source schema (preserving existing)."""
		if not tags:
			return
		if not self.loaded:
			self.refresh(token)
		missing = [t for t in tags if t not in self.option_names]
		if not missing:
			return

		ds = notion_request(token, "GET", f"data_sources/{self.data_source_id}")
		prop = (ds.get("properties") or {}).get(self.property_name) or {}
		existing = (prop.get("multi_select") or {}).get("options") or []
		# Keep id/name/color so Notion does not wipe prior options.
		merged: list[dict] = []
		seen: set[str] = set()
		for opt in existing:
			name = str(opt.get("name", "")).strip()
			if not name or name in seen:
				continue
			entry: dict = {"id": opt["id"], "name": name}
			if opt.get("color"):
				entry["color"] = opt["color"]
			merged.append(entry)
			seen.add(name)
		for name in tags:
			if name not in seen:
				merged.append({"name": name})
				seen.add(name)

		log.info(
			"adding %d tag option(s) to schema: %s",
			len(missing),
			", ".join(missing),
		)
		notion_request(
			token,
			"PATCH",
			f"data_sources/{self.data_source_id}",
			{
				"properties": {
					self.property_name: {
						"multi_select": {"options": merged},
					}
				}
			},
		)
		self.option_names = seen
		self.loaded = True

ensure_options(token, tags)

Merge missing tag names into the data source schema (preserving existing).

Source code in src/mkdocs_note/utils/notion/client.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def ensure_options(self, token: str, tags: list[str]) -> None:
	"""Merge missing tag names into the data source schema (preserving existing)."""
	if not tags:
		return
	if not self.loaded:
		self.refresh(token)
	missing = [t for t in tags if t not in self.option_names]
	if not missing:
		return

	ds = notion_request(token, "GET", f"data_sources/{self.data_source_id}")
	prop = (ds.get("properties") or {}).get(self.property_name) or {}
	existing = (prop.get("multi_select") or {}).get("options") or []
	# Keep id/name/color so Notion does not wipe prior options.
	merged: list[dict] = []
	seen: set[str] = set()
	for opt in existing:
		name = str(opt.get("name", "")).strip()
		if not name or name in seen:
			continue
		entry: dict = {"id": opt["id"], "name": name}
		if opt.get("color"):
			entry["color"] = opt["color"]
		merged.append(entry)
		seen.add(name)
	for name in tags:
		if name not in seen:
			merged.append({"name": name})
			seen.add(name)

	log.info(
		"adding %d tag option(s) to schema: %s",
		len(missing),
		", ".join(missing),
	)
	notion_request(
		token,
		"PATCH",
		f"data_sources/{self.data_source_id}",
		{
			"properties": {
				self.property_name: {
					"multi_select": {"options": merged},
				}
			}
		},
	)
	self.option_names = seen
	self.loaded = True

refresh(token)

Load current multi_select option names from the data source schema.

Source code in src/mkdocs_note/utils/notion/client.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def refresh(self, token: str) -> None:
	"""Load current multi_select option names from the data source schema."""
	ds = notion_request(token, "GET", f"data_sources/{self.data_source_id}")
	prop = (ds.get("properties") or {}).get(self.property_name) or {}
	if prop.get("type") != "multi_select":
		raise RuntimeError(
			f"Notion property {self.property_name!r} is not multi_select "
			f"(got {prop.get('type')!r})"
		)
	options = (prop.get("multi_select") or {}).get("options") or []
	self.option_names = {
		str(o.get("name", "")).strip() for o in options if o.get("name")
	}
	self.loaded = True

attach_placeholder_images(token, page_id, image_paths)

Replace ⟦LOCALIMG:N⟧ placeholders with uploaded image blocks.

Returns:

Type Description
int

Number of images successfully attached.

Source code in src/mkdocs_note/utils/notion/client.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def attach_placeholder_images(
	token: str,
	page_id: str,
	image_paths: list[Path],
) -> int:
	"""Replace ``⟦LOCALIMG:N⟧`` placeholders with uploaded image blocks.

	Returns:
	    Number of images successfully attached.
	"""
	upload_cache: dict[str, str] = {}
	attached = 0
	placeholder_re = re.compile(r"^⟦LOCALIMG:(\d+)⟧$")

	matches: list[tuple[dict, int]] = []
	for block in iter_blocks(token, page_id):
		if block.get("type") != "paragraph":
			continue
		text = rich_text_plain(block).strip()
		m = placeholder_re.match(text)
		if not m:
			continue
		matches.append((block, int(m.group(1))))

	for block, idx in matches:
		if idx < 0 or idx >= len(image_paths):
			log.warning("placeholder index out of range: %s", idx)
			continue
		path = image_paths[idx]
		source = upload_local_file(token, path, upload_cache)
		upload_id = source.split("://", 1)[1]
		parent = block.get("parent", {})
		parent_id = parent.get("page_id") or parent.get("block_id") or page_id
		insert_image_after(token, parent_id, block["id"], upload_id, caption=path.name)
		delete_block(token, block["id"])
		attached += 1
		time.sleep(0.15)
	return attached

create_page(token, parent_id, title, *, title_property, parent_kind)

Create a Notion page under a wiki data source or parent page.

Parameters:

Name Type Description Default
token str

Notion bearer token.

required
parent_id str

Parent data-source, database, or page id.

required
title str

Page title (truncated to 2000 chars).

required
title_property str

Title property name on the parent schema.

required
parent_kind str

One of data_source, database, or page.

required

Returns:

Type Description
dict[str, str]

Dict with id and url keys.

Source code in src/mkdocs_note/utils/notion/client.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def create_page(
	token: str,
	parent_id: str,
	title: str,
	*,
	title_property: str,
	parent_kind: str,
) -> dict[str, str]:
	"""Create a Notion page under a wiki data source or parent page.

	Args:
	    token: Notion bearer token.
	    parent_id: Parent data-source, database, or page id.
	    title: Page title (truncated to 2000 chars).
	    title_property: Title property name on the parent schema.
	    parent_kind: One of ``data_source``, ``database``, or ``page``.

	Returns:
	    Dict with ``id`` and ``url`` keys.
	"""
	if parent_kind == "data_source":
		parent: dict = {"type": "data_source_id", "data_source_id": parent_id}
	elif parent_kind == "database":
		parent = {"type": "database_id", "database_id": parent_id}
	else:
		parent = {"page_id": parent_id}

	# Wiki pages (including nested children) use the data-source title property.
	prop_name = title_property or "title"
	props = {
		prop_name: {
			"title": [{"type": "text", "text": {"content": title[:2000]}}],
		}
	}
	try:
		page = notion_request(
			token, "POST", "pages", {"parent": parent, "properties": props}
		)
	except urllib.error.HTTPError as exc:
		# Some parent page_id contexts only accept the generic "title" property.
		if prop_name != "title" and "title" in str(exc):
			props = {
				"title": {
					"title": [{"type": "text", "text": {"content": title[:2000]}}],
				}
			}
			page = notion_request(
				token, "POST", "pages", {"parent": parent, "properties": props}
			)
		else:
			raise
	return {"id": page["id"], "url": page.get("url", "")}

delete_block(token, block_id)

Delete (archive) a Notion block.

Source code in src/mkdocs_note/utils/notion/client.py
377
378
379
def delete_block(token: str, block_id: str) -> None:
	"""Delete (archive) a Notion block."""
	notion_request(token, "DELETE", f"blocks/{block_id}")

insert_image_after(token, parent_id, after_block_id, upload_id, caption='')

Insert an uploaded image block after after_block_id.

Source code in src/mkdocs_note/utils/notion/client.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
def insert_image_after(
	token: str,
	parent_id: str,
	after_block_id: str,
	upload_id: str,
	caption: str = "",
) -> None:
	"""Insert an uploaded image block after ``after_block_id``."""
	image: dict = {
		"type": "file_upload",
		"file_upload": {"id": upload_id},
	}
	if caption:
		image["caption"] = [{"type": "text", "text": {"content": caption[:2000]}}]
	notion_request(
		token,
		"PATCH",
		f"blocks/{parent_id}/children",
		{
			"after": after_block_id,
			"children": [
				{
					"object": "block",
					"type": "image",
					"image": image,
				}
			],
		},
	)

iter_blocks(token, block_id)

Recursively yield child blocks under block_id (depth-first).

Source code in src/mkdocs_note/utils/notion/client.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def iter_blocks(token: str, block_id: str) -> Iterator[dict[str, Any]]:
	"""Recursively yield child blocks under ``block_id`` (depth-first)."""
	cursor: str | None = None
	while True:
		path = f"blocks/{block_id}/children?page_size=100"
		if cursor:
			path += f"&start_cursor={cursor}"
		data = notion_request(token, "GET", path)
		for block in data.get("results", []):
			yield block
			btype = block.get("type")
			if block.get("has_children") and btype not in (
				"child_page",
				"child_database",
			):
				yield from iter_blocks(token, block["id"])
		if not data.get("has_more"):
			break
		cursor = data.get("next_cursor")

list_wiki_pages(token, data_source_id)

Query all pages in a wiki data source (paginated).

Source code in src/mkdocs_note/utils/notion/client.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def list_wiki_pages(token: str, data_source_id: str) -> list[dict]:
	"""Query all pages in a wiki data source (paginated)."""
	pages: list[dict] = []
	cursor: str | None = None
	while True:
		payload: dict = {"page_size": 100}
		if cursor:
			payload["start_cursor"] = cursor
		data = notion_request(
			token, "POST", f"data_sources/{data_source_id}/query", payload
		)
		pages.extend(data.get("results", []))
		if not data.get("has_more"):
			break
		cursor = data.get("next_cursor")
	return pages

notion_request(token, method, path, payload=None, notion_version=NOTION_VERSION_PAGES, retries=6)

Perform a Notion REST request with retries on transient failures.

Parameters:

Name Type Description Default
token str

Notion integration bearer token.

required
method str

HTTP method (GET, POST, PATCH, DELETE, …).

required
path str

API path under https://api.notion.com/v1/.

required
payload dict | None

Optional JSON body.

None
notion_version str

Notion-Version header value.

NOTION_VERSION_PAGES
retries int

Max attempts for rate-limit / network errors.

6

Returns:

Type Description
dict

Parsed JSON response dict (empty if body is empty).

Source code in src/mkdocs_note/utils/notion/client.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def notion_request(
	token: str,
	method: str,
	path: str,
	payload: dict | None = None,
	notion_version: str = NOTION_VERSION_PAGES,
	retries: int = 6,
) -> dict:
	"""Perform a Notion REST request with retries on transient failures.

	Args:
	    token: Notion integration bearer token.
	    method: HTTP method (GET, POST, PATCH, DELETE, …).
	    path: API path under ``https://api.notion.com/v1/``.
	    payload: Optional JSON body.
	    notion_version: Notion-Version header value.
	    retries: Max attempts for rate-limit / network errors.

	Returns:
	    Parsed JSON response dict (empty if body is empty).
	"""
	import http.client
	import ssl

	url = f"https://api.notion.com/v1/{path}"
	data = json.dumps(payload).encode("utf-8") if payload is not None else None
	last_error: Exception | None = None
	for attempt in range(retries):
		try:
			req = urllib.request.Request(
				url,
				data=data,
				method=method,
				headers={
					"Authorization": f"Bearer {token}",
					"Notion-Version": notion_version,
					"Content-Type": "application/json",
				},
			)
			with urllib.request.urlopen(req, timeout=180) as resp:
				body = resp.read().decode("utf-8")
				return json.loads(body) if body else {}
		except urllib.error.HTTPError as exc:
			last_error = exc
			err_body = exc.read().decode("utf-8", errors="replace")
			if exc.code in (429, 502, 503, 504):
				wait = 2.0 * (attempt + 1)
				log.warning("HTTP %s; retry in %.1fs", exc.code, wait)
				time.sleep(wait)
				continue
			raise urllib.error.HTTPError(
				url, exc.code, err_body, exc.headers, None
			) from exc
		except (
			urllib.error.URLError,
			TimeoutError,
			http.client.IncompleteRead,
			http.client.RemoteDisconnected,
			ssl.SSLError,
			ConnectionResetError,
			BrokenPipeError,
		) as exc:
			last_error = exc
			wait = 1.5 * (attempt + 1)
			log.warning(
				"transient network error (%s); retry in %.1fs",
				type(exc).__name__,
				wait,
			)
			time.sleep(wait)
	assert last_error is not None
	raise last_error

page_title(page, title_property='页面')

Extract the title plain text from a Notion page object.

Parameters:

Name Type Description Default
page dict

Notion page JSON.

required
title_property str

Preferred title property name to try first.

'页面'
Source code in src/mkdocs_note/utils/notion/client.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
def page_title(page: dict, title_property: str = "页面") -> str:
	"""Extract the title plain text from a Notion page object.

	Args:
	    page: Notion page JSON.
	    title_property: Preferred title property name to try first.
	"""
	props = page.get("properties", {})
	for key in (title_property, "页面", "title", "Title", "Name", "名称"):
		prop = props.get(key)
		if not prop or prop.get("type") != "title":
			continue
		parts = prop.get("title") or []
		return "".join(t.get("plain_text", "") for t in parts)
	for prop in props.values():
		if prop.get("type") == "title":
			parts = prop.get("title") or []
			return "".join(t.get("plain_text", "") for t in parts)
	return ""

rich_text_plain(block)

Concatenate plain_text from a block's rich_text array.

Source code in src/mkdocs_note/utils/notion/client.py
369
370
371
372
373
374
def rich_text_plain(block: dict) -> str:
	"""Concatenate plain_text from a block's rich_text array."""
	btype = block.get("type")
	payload = block.get(btype) or {}
	parts = payload.get("rich_text") or []
	return "".join(p.get("plain_text", "") for p in parts)

update_page_markdown(token, page_id, markdown)

Replace page content via the Notion markdown PATCH endpoint.

Source code in src/mkdocs_note/utils/notion/client.py
157
158
159
160
161
162
163
164
165
166
167
168
def update_page_markdown(token: str, page_id: str, markdown: str) -> None:
	"""Replace page content via the Notion markdown PATCH endpoint."""
	notion_request(
		token,
		"PATCH",
		f"pages/{page_id}/markdown",
		{
			"type": "replace_content",
			"replace_content": {"new_str": markdown},
		},
		notion_version=NOTION_VERSION_MARKDOWN,
	)

update_page_tags(token, page_id, tags, *, property_name='标签', tags_cache=None)

Write frontmatter tags into a Notion multi_select property; return applied names.

Wiki quirk: assigning a multi_select name that is not yet in the data-source schema returns HTTP 200 but leaves the property empty. Ensure options first.

Source code in src/mkdocs_note/utils/notion/client.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def update_page_tags(
	token: str,
	page_id: str,
	tags: list[str],
	*,
	property_name: str = "标签",
	tags_cache: TagsSchemaCache | None = None,
) -> list[str]:
	"""Write frontmatter tags into a Notion multi_select property; return applied names.

	Wiki quirk: assigning a multi_select name that is not yet in the data-source
	schema returns HTTP 200 but leaves the property empty. Ensure options first.
	"""
	if tags_cache is not None:
		tags_cache.ensure_options(token, tags)

	page = notion_request(
		token,
		"PATCH",
		f"pages/{page_id}",
		{
			"properties": {
				property_name: {
					"multi_select": [{"name": t} for t in tags],
				}
			}
		},
	)
	prop = (page.get("properties") or {}).get(property_name) or {}
	applied = [
		str(o.get("name", "")).strip()
		for o in (prop.get("multi_select") or [])
		if o.get("name")
	]
	if set(applied) != set(tags):
		raise RuntimeError(
			f"tags write mismatch on {page_id}: wanted {tags}, got {applied}"
		)
	return applied

upload_local_file(token, path, cache)

Upload a local file to Notion and return a file-upload:// source URI.

Source code in src/mkdocs_note/utils/notion/client.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def upload_local_file(token: str, path: Path, cache: dict[str, str]) -> str:
	"""Upload a local file to Notion and return a ``file-upload://`` source URI."""
	key = str(path.resolve())
	if key in cache:
		return cache[key]

	suffix = path.suffix.lower()
	filename = path.name
	if suffix == ".awebp":
		filename = path.stem + ".webp"
		suffix = ".webp"

	content_type = (
		CONTENT_TYPES.get(suffix)
		or mimetypes.guess_type(filename)[0]
		or "application/octet-stream"
	)
	created = notion_request(
		token,
		"POST",
		"file_uploads",
		{"filename": filename, "content_type": content_type},
	)
	upload_id = created["id"]

	boundary = f"----NotionBoundary{int(time.time() * 1000)}"
	file_bytes = path.read_bytes()
	body = (
		(
			f"--{boundary}\r\n"
			f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
			f"Content-Type: {content_type}\r\n\r\n"
		).encode()
		+ file_bytes
		+ f"\r\n--{boundary}--\r\n".encode()
	)

	url = f"https://api.notion.com/v1/file_uploads/{upload_id}/send"
	req = urllib.request.Request(
		url,
		data=body,
		method="POST",
		headers={
			"Authorization": f"Bearer {token}",
			"Notion-Version": NOTION_VERSION_PAGES,
			"Content-Type": f"multipart/form-data; boundary={boundary}",
		},
	)
	with urllib.request.urlopen(req, timeout=180) as resp:
		json.loads(resp.read().decode("utf-8"))

	source = f"file-upload://{upload_id}"
	cache[key] = source
	return source

convert

Markdown / notebook → Notion Enhanced Markdown conversion.

Pure conversion helpers with no network I/O. Callers pass docs_root for path resolution instead of relying on a global docs directory.

collect_indented_block(lines, start, indent)

Collect consecutive lines indented at least indent spaces.

Source code in src/mkdocs_note/utils/notion/convert.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def collect_indented_block(
	lines: list[str], start: int, indent: int
) -> tuple[list[str], int]:
	"""Collect consecutive lines indented at least ``indent`` spaces."""
	collected: list[str] = []
	i = start
	while i < len(lines):
		line = lines[i]
		if line.strip() == "":
			collected.append("")
			i += 1
			continue
		leading = len(line) - len(line.lstrip(" "))
		# Also accept tabs as indent units (rare in these notes).
		if leading < indent and not line.startswith("\t" * (indent // 4 or 1)):
			# Allow tab-indented content roughly equivalent to spaces.
			if line.startswith("\t"):
				tab_count = len(line) - len(line.lstrip("\t"))
				if tab_count * 4 < indent:
					break
				collected.append(line[tab_count:])
				i += 1
				continue
			break
		collected.append(line[indent:] if leading >= indent else line.lstrip(" "))
		i += 1
	return collected, i

convert_admonitions_and_tabs(text)

Convert Material admonitions and content tabs to Notion markup.

Source code in src/mkdocs_note/utils/notion/convert.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def convert_admonitions_and_tabs(text: str) -> str:
	"""Convert Material admonitions and content tabs to Notion markup."""
	lines = text.splitlines()
	out: list[str] = []
	i = 0
	tab_re = re.compile(r'^([ \t]*)===\s+"([^"]+)"\s*$')

	while i < len(lines):
		line = lines[i]
		ad_match = _ADMON_LINE_RE.match(line)
		if ad_match:
			indent_ws = ad_match.group("indent") or ""
			indent = len(indent_ws.replace("\t", "    "))
			markers = ad_match.group("markers")
			ad_type = ad_match.group("type")
			title, _inline = _parse_admonition_rest(ad_match.group("rest") or "")
			# Material body is indented 4 spaces beyond the admonition marker line.
			body_indent = indent + 4
			block_lines, i = collect_indented_block(lines, i + 1, body_indent)
			inner = convert_admonitions_and_tabs("\n".join(block_lines).strip("\n"))
			if markers.startswith("?"):
				summary = title or ad_type.capitalize()
				block = (
					f"<details>\n<summary>{summary}</summary>\n"
					f"{notion_indent_block(inner)}\n</details>"
				)
			else:
				color, icon = ADMONITION_STYLES.get(ad_type, ("gray_bg", "📌"))
				# Inline admonitions have no Notion float equivalent → normal callout.
				header = f"**{title}**\n" if title else ""
				block = (
					f'<callout icon="{icon}" color="{color}">\n'
					f"{notion_indent_block(header + inner)}\n"
					"</callout>"
				)
			out.append(block)
			continue

		tab_match = tab_re.match(line)
		if tab_match:
			label = tab_match.group(2)
			indent_ws = tab_match.group(1) or ""
			indent = len(indent_ws.replace("\t", "    "))
			block_lines, i = collect_indented_block(lines, i + 1, indent + 4)
			inner = convert_admonitions_and_tabs("\n".join(block_lines).strip("\n"))
			out.append(f"### {label}")
			out.append(inner)
			continue

		out.append(line)
		i += 1

	return "\n".join(out)

convert_html_blocks(text)

Convert known HTML embeds and strip HTML comments.

Source code in src/mkdocs_note/utils/notion/convert.py
207
208
209
210
211
212
213
214
215
216
217
def convert_html_blocks(text: str) -> str:
	"""Convert known HTML embeds and strip HTML comments."""
	text = re.sub(
		r'<div\s+class="responsive-video-container">\s*'
		r'<iframe[^>]+src="([^"]+)"[^>]*>\s*</iframe>\s*</div>',
		lambda m: f'<video src="{m.group(1)}">Video</video>',
		text,
		flags=re.IGNORECASE | re.DOTALL,
	)
	text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
	return text

convert_images(text, source_file, site_url, docs_root, upload_local=None)

Rewrite image markdown; optionally upload local files via upload_local.

Source code in src/mkdocs_note/utils/notion/convert.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
def convert_images(
	text: str,
	source_file: Path,
	site_url: str,
	docs_root: Path,
	upload_local: Callable[[Path], Any] | None = None,
) -> str:
	"""Rewrite image markdown; optionally upload local files via ``upload_local``."""

	def repl(match: re.Match[str]) -> str:
		alt = match.group(1) or ""
		src = match.group(2).strip()
		if upload_local is not None and not src.startswith(("http://", "https://")):
			local = resolve_local_asset_path(src, source_file, docs_root)
			if local is not None:
				uploaded = upload_local(local)
				if uploaded:
					if str(uploaded).startswith("file-upload://"):
						return f'<image src="{uploaded}">{alt}</image>'
					return f"\n\n{uploaded}\n\n"
		url = resolve_image_url(src, source_file, site_url, docs_root)
		return f"![{alt}]({url})"

	return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", repl, text)

convert_inline_math(text)

Adapt KaTeX-style math delimiters for Notion.

Source code in src/mkdocs_note/utils/notion/convert.py
196
197
198
199
200
201
202
203
204
def convert_inline_math(text: str) -> str:
	"""Adapt KaTeX-style math delimiters for Notion."""

	def repl_block(match: re.Match[str]) -> str:
		return f"$`{match.group(1).strip()}`$"

	text = re.sub(r"\$\$([\s\S]+?)\$\$", lambda m: f"$${m.group(1)}$$", text)
	text = re.sub(r"(?<!\$)\$(?!\$)([^$\n]+?)\$(?!\$)", repl_block, text)
	return text

Rewrite internal links to Notion mention-page when mapped.

Source code in src/mkdocs_note/utils/notion/convert.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def convert_links(
	text: str,
	source_file: Path,
	page_map: dict[str, dict[str, str]],
	docs_root: Path,
) -> str:
	"""Rewrite internal links to Notion ``mention-page`` when mapped."""

	def repl(match: re.Match[str]) -> str:
		label = match.group(1)
		raw = match.group(2)
		if raw.startswith(("http://", "https://")):
			return match.group(0)
		anchor = ""
		if "#" in raw:
			path_part, anchor = raw.split("#", 1)
		else:
			path_part = raw
		if not path_part:
			return match.group(0)
		rel = resolve_internal_target(path_part, source_file, docs_root)
		if rel in page_map and page_map[rel].get("url"):
			url = page_map[rel]["url"]
			if anchor:
				url = f"{url}#{anchor}"
			return f'<mention-page url="{url}">{label}</mention-page>'
		return f"[{label}]({raw})"

	return re.sub(r"\[([^\]]+)\]\(([^)]+)\)", repl, text)

convert_markdown_file(file_path, site_url, page_map, docs_root, upload_local=None)

Convert a markdown or notebook file to Notion-ready markdown.

Parameters:

Name Type Description Default
file_path Path

Source .md or .ipynb path.

required
site_url str

Public site base URL for absolute image links.

required
page_map dict[str, dict[str, str]]

Docs-relative path → {"id", "url"} for mention links.

required
docs_root Path

Documentation root used for path resolution.

required
upload_local Callable[[Path], Any] | None

Optional callback that accepts a local Path and returns an upload token / placeholder string.

None

Returns:

Type Description
tuple[str, str, dict[str, Any]]

Tuple of (title, body, frontmatter_meta).

Source code in src/mkdocs_note/utils/notion/convert.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
def convert_markdown_file(
	file_path: Path,
	site_url: str,
	page_map: dict[str, dict[str, str]],
	docs_root: Path,
	upload_local: Callable[[Path], Any] | None = None,
) -> tuple[str, str, dict[str, Any]]:
	"""Convert a markdown or notebook file to Notion-ready markdown.

	Args:
	    file_path: Source ``.md`` or ``.ipynb`` path.
	    site_url: Public site base URL for absolute image links.
	    page_map: Docs-relative path → ``{"id", "url"}`` for mention links.
	    docs_root: Documentation root used for path resolution.
	    upload_local: Optional callback that accepts a local ``Path`` and returns
	        an upload token / placeholder string.

	Returns:
	    Tuple of ``(title, body, frontmatter_meta)``.
	"""
	if file_path.suffix.lower() == ".ipynb":
		meta, body = ipynb_to_markdown(file_path)
		title = str(meta.get("title") or title_from_path(file_path)).strip()
	else:
		raw = file_path.read_text(encoding="utf-8")
		meta, body = parse_frontmatter(raw)
		title = str(meta.get("title") or title_from_path(file_path)).strip()
		body = strip_duplicate_h1(title, body)

	body = convert_html_blocks(body)
	body = normalize_blockquotes_for_notion(body)
	body = convert_admonitions_and_tabs(body)
	body = convert_inline_math(body)
	body = convert_images(
		body, file_path, site_url, docs_root, upload_local=upload_local
	)
	body = convert_links(body, file_path, page_map, docs_root)
	body = strip_gfm_table_separators(body)
	body = re.sub(r"\n{3,}", "\n\n", body).strip()
	return title, body, meta

extract_tags(meta_dict)

Normalize frontmatter tags / tag into a list of non-empty strings.

Parameters:

Name Type Description Default
meta_dict dict[str, Any]

Parsed frontmatter mapping.

required

Returns:

Type Description
list[str]

List of tag strings.

Source code in src/mkdocs_note/utils/meta.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def extract_tags(meta_dict: dict[str, Any]) -> list[str]:
	"""Normalize frontmatter ``tags`` / ``tag`` into a list of non-empty strings.

	Args:
	    meta_dict: Parsed frontmatter mapping.

	Returns:
	    List of tag strings.
	"""
	raw = meta_dict.get("tags", meta_dict.get("tag", None))
	if raw is None:
		return []
	if isinstance(raw, str):
		parts = [p.strip() for p in re.split(r"[,;]", raw)]
		return [p for p in parts if p]
	if isinstance(raw, (list, tuple)):
		out: list[str] = []
		for item in raw:
			if item is None:
				continue
			s = str(item).strip()
			if s:
				out.append(s)
		return out
	s = str(raw).strip()
	return [s] if s else []

ipynb_to_markdown(path)

Convert a Jupyter notebook to markdown (cells only; no raw JSON).

Source code in src/mkdocs_note/utils/notion/convert.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
def ipynb_to_markdown(path: Path) -> tuple[dict[str, Any], str]:
	"""Convert a Jupyter notebook to markdown (cells only; no raw JSON)."""
	data = json.loads(path.read_text(encoding="utf-8"))
	meta: dict[str, Any] = {}
	nb_meta = data.get("metadata") or {}
	# Prefer explicit title from notebook metadata when present.
	if isinstance(nb_meta.get("title"), str):
		meta["title"] = nb_meta["title"]

	parts: list[str] = []
	for cell in data.get("cells") or []:
		ctype = cell.get("cell_type")
		source = cell.get("source") or []
		if isinstance(source, list):
			text = "".join(source)
		else:
			text = str(source)
		text = text.rstrip("\n")
		if not text.strip():
			continue
		if ctype == "markdown":
			parts.append(text)
		elif ctype == "code":
			lang = ""
			kernelspec = nb_meta.get("kernelspec") or {}
			language = kernelspec.get("language") or ""
			if language:
				lang = str(language)
			else:
				lang = "python"
			parts.append(f"```{lang}\n{text}\n```")
			# Include plain-text / stream outputs when useful.
			outputs = cell.get("outputs") or []
			out_chunks: list[str] = []
			for out in outputs:
				otype = out.get("output_type")
				if otype == "stream":
					text_out = out.get("text") or ""
					if isinstance(text_out, list):
						text_out = "".join(text_out)
					if str(text_out).strip():
						out_chunks.append(str(text_out).rstrip())
				elif otype in ("execute_result", "display_data"):
					data_out = out.get("data") or {}
					if "text/plain" in data_out:
						plain = data_out["text/plain"]
						if isinstance(plain, list):
							plain = "".join(plain)
						if str(plain).strip():
							out_chunks.append(str(plain).rstrip())
					elif "text/markdown" in data_out:
						md = data_out["text/markdown"]
						if isinstance(md, list):
							md = "".join(md)
						if str(md).strip():
							out_chunks.append(str(md).rstrip())
			if out_chunks:
				parts.append("```\n" + "\n".join(out_chunks) + "\n```")
		# skip raw cells
	body = "\n\n".join(parts).strip() + "\n"
	return meta, body

is_gfm_table_separator_row(line)

True for GFM alignment rows like |:-:|, |:-|, |-:|, | --- | :---: |.

Source code in src/mkdocs_note/utils/notion/convert.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def is_gfm_table_separator_row(line: str) -> bool:
	"""True for GFM alignment rows like ``|:-:|``, ``|:-|``, ``|-:|``, ``| --- | :---: |``."""
	s = line.strip()
	if "|" not in s:
		return False
	core = s.removeprefix("|")
	core = core.removesuffix("|")
	cells = core.split("|")
	if not cells:
		return False
	for cell in cells:
		if not _TABLE_SEP_CELL_RE.fullmatch(cell.strip()):
			return False
	return True

md_references_asset(source, asset_rel, docs_root)

Cheap check: whether markdown likely references a docs-relative asset.

Source code in src/mkdocs_note/utils/notion/convert.py
573
574
575
576
577
578
579
580
581
582
583
584
585
def md_references_asset(source: Path, asset_rel: str, docs_root: Path) -> bool:
	"""Cheap check: whether markdown likely references a docs-relative asset."""
	text = source.read_text(encoding="utf-8", errors="ignore")
	name = Path(asset_rel).name
	if name not in text:
		return False
	for _, src in re.findall(r"!\[([^\]]*)\]\(([^)]+)\)", text):
		local = resolve_local_asset_path(src.strip(), source, docs_root)
		if local is None:
			continue
		if docs_rel(str(local.relative_to(docs_root))) == asset_rel:
			return True
	return False

normalize_blockquotes_for_notion(text)

Drop MkDocs blank quote markers (> alone) and collapse quote runs for Notion.

In MkDocs/Material, a lone > between quote lines acts as a paragraph/line break and does not render as an empty citation. Notion treats that line as an empty quote block. Convert each contiguous > run into a single Notion multi-line quote using <br>, omitting blank quote lines.

Source code in src/mkdocs_note/utils/notion/convert.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def normalize_blockquotes_for_notion(text: str) -> str:
	"""Drop MkDocs blank quote markers (`>` alone) and collapse quote runs for Notion.

	In MkDocs/Material, a lone `>` between quote lines acts as a paragraph/line break
	and does not render as an empty citation. Notion treats that line as an empty
	quote block. Convert each contiguous `>` run into a single Notion multi-line
	quote using ``<br>``, omitting blank quote lines.
	"""
	lines = text.splitlines()
	out: list[str] = []
	i = 0
	quote_re = re.compile(r"^([ \t]*)>([ \t]?)(.*)$")
	fence_re = re.compile(r"^([ \t]*)(`{3,}|~{3,})(.*)$")

	while i < len(lines):
		line = lines[i]
		fence = fence_re.match(line)
		if fence:
			marker = fence.group(2)
			ch = marker[0]
			n = len(marker)
			out.append(line)
			i += 1
			while i < len(lines):
				out.append(lines[i])
				close = fence_re.match(lines[i])
				if (
					close
					and close.group(2)[0] == ch
					and len(close.group(2)) >= n
					and close.group(3).strip() == ""
				):
					i += 1
					break
				i += 1
			continue

		m = quote_re.match(line)
		if not m:
			out.append(line)
			i += 1
			continue

		indent = m.group(1)
		parts: list[str] = []
		while i < len(lines):
			qm = quote_re.match(lines[i])
			if not qm or qm.group(1) != indent:
				break
			body = qm.group(3)
			# Lone `>` / `> ` → MkDocs line break; skip for Notion.
			if body.strip() == "":
				i += 1
				continue
			parts.append(body)
			i += 1

		if not parts:
			continue
		# Notion multi-line quote: one `>` line with <br> separators.
		out.append(f"{indent}> {'<br>'.join(parts)}")

	return "\n".join(out)

notion_indent_block(text, tabs=1)

Indent block children for Notion; keep blank lines indented so nesting holds.

Source code in src/mkdocs_note/utils/notion/convert.py
105
106
107
108
109
110
111
112
113
114
def notion_indent_block(text: str, tabs: int = 1) -> str:
	"""Indent block children for Notion; keep blank lines indented so nesting holds."""
	prefix = "\t" * tabs
	out: list[str] = []
	for line in text.splitlines():
		if line.strip() == "":
			out.append(f"{prefix}<empty-block/>")
		else:
			out.append(prefix + line)
	return "\n".join(out)

page_has_local_images(source)

Return True if the file contains any non-http(s) image references.

Source code in src/mkdocs_note/utils/notion/convert.py
563
564
565
566
567
568
569
570
def page_has_local_images(source: Path) -> bool:
	"""Return True if the file contains any non-http(s) image references."""
	text = source.read_text(encoding="utf-8", errors="ignore")
	for _, src in re.findall(r"!\[([^\]]*)\]\(([^)]+)\)", text):
		raw = src.strip()
		if not raw.startswith(("http://", "https://")):
			return True
	return False

resolve_image_url(raw, source_file, site_url, docs_root)

Build an absolute site URL for an image reference.

Source code in src/mkdocs_note/utils/notion/convert.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def resolve_image_url(
	raw: str, source_file: Path, site_url: str, docs_root: Path
) -> str:
	"""Build an absolute site URL for an image reference."""
	raw = raw.strip()
	if raw.startswith(("http://", "https://")):
		return raw
	if raw.startswith("/"):
		rel = docs_rel(raw.lstrip("/"))
	else:
		candidate = resolve_local_asset_path(raw, source_file, docs_root)
		if candidate is not None:
			rel = docs_rel(str(candidate.relative_to(docs_root)))
		else:
			rel = docs_rel(
				str((source_file.parent / raw).resolve().relative_to(docs_root))
			)
	encoded = "/".join(quote(part, safe="") for part in rel.split("/"))
	return f"{site_url.rstrip('/')}/{encoded}"

resolve_internal_target(raw, source_file, docs_root)

Resolve an internal markdown link to a docs-relative path.

Source code in src/mkdocs_note/utils/notion/convert.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def resolve_internal_target(raw: str, source_file: Path, docs_root: Path) -> str:
	"""Resolve an internal markdown link to a docs-relative path."""
	target = raw.split("#", 1)[0].strip()
	if not target:
		return ""
	if target.startswith("/"):
		resolved = (docs_root / target.lstrip("/")).resolve()
	else:
		resolved = (source_file.parent / target).resolve()
	try:
		rel = docs_rel(str(resolved.relative_to(docs_root)))
	except ValueError:
		return target
	if rel.endswith(".ipynb"):
		rel = rel[:-6] + ".md"
	return rel

resolve_local_asset_path(raw, source_file, docs_root)

Resolve a relative or site-absolute asset path under docs_root.

Source code in src/mkdocs_note/utils/notion/convert.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def resolve_local_asset_path(
	raw: str, source_file: Path, docs_root: Path
) -> Path | None:
	"""Resolve a relative or site-absolute asset path under ``docs_root``."""
	raw = raw.strip()
	if not raw or raw.startswith(("http://", "https://")):
		return None
	if raw.startswith("/"):
		candidate = (docs_root / raw.lstrip("/")).resolve()
	else:
		candidate = (source_file.parent / raw).resolve()
	try:
		candidate.relative_to(docs_root.resolve())
	except ValueError:
		return None
	return candidate if candidate.exists() else None

strip_duplicate_h1(title, body)

Remove a leading H1 that duplicates the page title.

Source code in src/mkdocs_note/utils/notion/convert.py
63
64
65
66
67
68
69
70
71
72
73
def strip_duplicate_h1(title: str, body: str) -> str:
	"""Remove a leading H1 that duplicates the page title."""
	lines = body.splitlines()
	if not lines:
		return body
	first = lines[0].strip()
	if first.startswith("# "):
		h1 = first[2:].strip()
		if h1 == title or h1 in title or title in h1:
			return "\n".join(lines[1:]).lstrip("\n")
	return body

strip_gfm_table_separators(text)

Drop GFM table alignment rows so Notion does not insert them as data rows.

Markdown renderers ignore |:-:| / |:-| / |-:| separator lines; Notion's markdown ingest treats them as ordinary table rows.

Source code in src/mkdocs_note/utils/notion/convert.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def strip_gfm_table_separators(text: str) -> str:
	"""Drop GFM table alignment rows so Notion does not insert them as data rows.

	Markdown renderers ignore ``|:-:|`` / ``|:-|`` / ``|-:|`` separator lines; Notion's
	markdown ingest treats them as ordinary table rows.
	"""
	lines = text.splitlines()
	out: list[str] = []
	i = 0
	while i < len(lines):
		line = lines[i]
		fence = _FENCE_LINE_RE.match(line)
		if fence:
			marker = fence.group(2)
			ch = marker[0]
			n = len(marker)
			out.append(line)
			i += 1
			while i < len(lines):
				out.append(lines[i])
				close = _FENCE_LINE_RE.match(lines[i])
				if (
					close
					and close.group(2)[0] == ch
					and len(close.group(2)) >= n
					and close.group(3).strip() == ""
				):
					i += 1
					break
				i += 1
			continue
		if is_gfm_table_separator_row(line):
			i += 1
			continue
		out.append(line)
		i += 1
	return "\n".join(out)

sync

Notion wiki sync orchestration.

Token resolution, git incremental diff, page-map state, section ensure, and run_sync — the CLI entrypoint. Wiki IDs and site URL come from SyncOptions (or env fallbacks); nothing is hardcoded here.

DiffSet dataclass

Incremental change set relative to docs_dir.

Source code in src/mkdocs_note/utils/notion/sync.py
 95
 96
 97
 98
 99
100
101
102
@dataclass
class DiffSet:
	"""Incremental change set relative to ``docs_dir``."""

	md_changed: set[str] = field(default_factory=set)
	md_deleted: set[str] = field(default_factory=set)
	assets_changed: set[str] = field(default_factory=set)
	nav_changed: bool = False

MigrationState dataclass

Local map of docs-relative keys → Notion page id/url.

Source code in src/mkdocs_note/utils/notion/sync.py
85
86
87
88
89
90
91
92
@dataclass
class MigrationState:
	"""Local map of docs-relative keys → Notion page id/url."""

	root_page_id: str = ""
	data_source_id: str = ""
	title_property: str = "页面"
	pages: dict[str, dict[str, str]] = field(default_factory=dict)

SyncOptions dataclass

Configuration for a Notion sync run (assembled by CLI / mkdocs.yml).

Source code in src/mkdocs_note/utils/notion/sync.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@dataclass
class SyncOptions:
	"""Configuration for a Notion sync run (assembled by CLI / mkdocs.yml)."""

	project_root: Path
	docs_dir: Path
	notes_root: Path
	nav_file: Path | None
	database_id: str
	data_source_id: str
	site_url: str
	state_path: Path
	delay: float
	title_property: str = "页面"
	tags_property: str = "标签"
	token: str | None = None
	allow_cursor_mcp_token: bool = False
	silence_mcp_token_warning: bool = False
	full: bool = False
	base: str | None = None
	paths: list[str] | None = None
	paths_file: Path | None = None
	section: list[str] | None = None
	rebuild_state: bool = False
	no_images: bool = False
	dry_run: bool = False
	continue_on_error: bool = False
	verbose: bool = False

collect_targets(*, full, diff, nav_index, docs_dir, sections)

Return (pages to sync, deleted rel paths).

Source code in src/mkdocs_note/utils/notion/sync.py
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
def collect_targets(
	*,
	full: bool,
	diff: DiffSet,
	nav_index: dict[str, NavItem],
	docs_dir: Path,
	sections: list[str] | None,
) -> tuple[list[NavItem], set[str]]:
	"""Return ``(pages to sync, deleted rel paths)``."""
	deleted = {
		p
		for p in diff.md_deleted
		if filter_sections(p, sections) and not is_index_doc(p)
	}

	def _include(rel: str) -> bool:
		if is_index_doc(rel):
			return False
		return filter_sections(rel, sections)

	if full:
		items = [
			item
			for item in nav_index.values()
			if item.file_rel
			and _include(item.file_rel)
			and (docs_dir / item.file_rel).exists()
		]
		return items, deleted

	wanted = set(diff.md_changed)
	if diff.assets_changed:
		wanted |= expand_asset_dependents(
			diff.assets_changed, nav_index, docs_dir, sections
		)

	items: list[NavItem] = []
	for rel in sorted(wanted):
		if is_index_doc(rel):
			log.info("skip index.md: %s", rel)
			continue
		if not filter_sections(rel, sections):
			continue
		item = nav_index.get(rel)
		if item is None or not item.file_rel:
			log.warning("changed file not in nav, skip: %s", rel)
			continue
		items.append(item)
	return items, deleted

ensure_section(token, state, state_path, nav_index, key, delay, dry_run)

Ensure a section (or Notebook root) exists; return Notion page id.

Source code in src/mkdocs_note/utils/notion/sync.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def ensure_section(
	token: str,
	state: MigrationState,
	state_path: Path,
	nav_index: dict[str, NavItem],
	key: str,
	delay: float,
	dry_run: bool,
) -> str:
	"""Ensure a section (or Notebook root) exists; return Notion page id."""
	if key in state.pages:
		return state.pages[key]["id"]

	item = nav_index.get(key)
	if item is None:
		raise KeyError(f"nav key not found: {key}")

	# Notebook root maps to the wiki database itself.
	if item.title == "Notebook" and not item.parent_key:
		state.pages[key] = {
			"id": state.root_page_id,
			"url": f"https://www.notion.so/{state.root_page_id.replace('-', '')}",
		}
		if not dry_run:
			save_state(state_path, state)
		return state.root_page_id

	if item.parent_key:
		parent_id = ensure_section(
			token, state, state_path, nav_index, item.parent_key, delay, dry_run
		)
		parent_kind = "page"
	else:
		parent_id = state.data_source_id or state.root_page_id
		parent_kind = "data_source" if state.data_source_id else "database"

	log.info("create section %s (%s)", item.title, key)
	if dry_run:
		fake = f"dry-run-{key}"
		state.pages[key] = {"id": fake, "url": f"https://www.notion.so/{fake}"}
		return fake

	info = create_page(
		token,
		parent_id,
		item.title,
		title_property=state.title_property,
		parent_kind=parent_kind,
	)
	state.pages[key] = info
	save_state(state_path, state)
	time.sleep(delay)
	return info["id"]

expand_asset_dependents(assets, nav_index, docs_dir, sections)

Find nav-listed markdown files that reference changed assets (scoped).

Source code in src/mkdocs_note/utils/notion/sync.py
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def expand_asset_dependents(
	assets: set[str],
	nav_index: dict[str, NavItem],
	docs_dir: Path,
	sections: list[str] | None,
) -> set[str]:
	"""Find nav-listed markdown files that reference changed assets (scoped)."""
	if not assets:
		return set()
	candidates: list[Path] = []
	for item in nav_index.values():
		if not item.file_rel or is_index_doc(item.file_rel):
			continue
		if sections and not any(item.file_rel.startswith(s) for s in sections):
			continue
		source = docs_dir / item.file_rel
		if source.exists():
			candidates.append(source)

	affected: set[str] = set()
	for source in candidates:
		rel = docs_rel(str(source.relative_to(docs_dir)))
		for asset in assets:
			if md_references_asset(source, asset, docs_dir):
				affected.add(rel)
				break
	return affected

filter_sections(rel, sections)

True if rel is under any of the section prefixes (or no filter).

Source code in src/mkdocs_note/utils/notion/sync.py
562
563
564
565
566
def filter_sections(rel: str, sections: list[str] | None) -> bool:
	"""True if *rel* is under any of the section prefixes (or no filter)."""
	if not sections:
		return True
	return any(rel.startswith(s) for s in sections)

git_diff(base, project_root, docs_dir)

Collect docs changes between base...HEAD. If base is None → full.

Source code in src/mkdocs_note/utils/notion/sync.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
def git_diff(
	base: str | None,
	project_root: Path,
	docs_dir: Path,
) -> DiffSet:
	"""Collect docs changes between ``base...HEAD``. If *base* is None → full."""
	if base is None:
		# Caller will expand to all pages when nav_changed + full mode.
		return DiffSet(nav_changed=True)

	docs_prefix = _docs_prefix(project_root, docs_dir)
	pathspec = docs_prefix.rstrip("/")
	out = _run_git(
		project_root,
		"diff",
		"--name-status",
		"--find-renames",
		f"{base}...HEAD",
		"--",
		pathspec,
	)
	diff = DiffSet()
	for line in out.splitlines():
		if not line.strip():
			continue
		parts = line.split("\t")
		status = parts[0]
		paths = parts[1:]
		# Renames: R100\told\tnew
		if status.startswith("R") and len(paths) == 2:
			old, new = paths
			_classify_path(diff, old, docs_prefix, deleted=True)
			_classify_path(diff, new, docs_prefix, deleted=False)
			continue
		path = paths[0]
		deleted = status.startswith("D")
		_classify_path(diff, path, docs_prefix, deleted=deleted)
	return diff

load_state(path, default_title='页面')

Load migration state JSON, or return an empty state.

Source code in src/mkdocs_note/utils/notion/sync.py
287
288
289
290
291
292
293
294
295
296
297
def load_state(path: Path, default_title: str = "页面") -> MigrationState:
	"""Load migration state JSON, or return an empty state."""
	if not path.exists():
		return MigrationState(title_property=default_title)
	data = json.loads(path.read_text(encoding="utf-8"))
	return MigrationState(
		root_page_id=data.get("root_page_id", ""),
		data_source_id=data.get("data_source_id", ""),
		title_property=data.get("title_property", default_title),
		pages=data.get("pages", {}),
	)

rebuild_state_from_wiki(token, *, data_source_id, database_id, title_property='页面', tree=None, nav_file=None, notes_root=None, docs_dir=None, sections=None)

Match existing wiki pages to nav / directory tree keys (recovery).

Pass tree when already built; otherwise call build_page_tree using nav_file / notes_root / docs_dir.

Source code in src/mkdocs_note/utils/notion/sync.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
def rebuild_state_from_wiki(
	token: str,
	*,
	data_source_id: str,
	database_id: str,
	title_property: str = "页面",
	tree: list[TreeNode] | None = None,
	nav_file: Path | None = None,
	notes_root: Path | None = None,
	docs_dir: Path | None = None,
	sections: list[str] | None = None,
) -> MigrationState:
	"""Match existing wiki pages to nav / directory tree keys (recovery).

	Pass *tree* when already built; otherwise call ``build_page_tree`` using
	*nav_file* / *notes_root* / *docs_dir*.
	"""
	state = MigrationState(
		root_page_id=database_id,
		data_source_id=data_source_id,
		title_property=title_property,
	)
	pages = list_wiki_pages(token, data_source_id)
	log.info("fetched %d pages from wiki for state rebuild", len(pages))

	by_parent: dict[str, list[dict[str, str]]] = {}
	for page in pages:
		title = page_title(page, title_property=title_property)
		parent = page.get("parent", {})
		if parent.get("type") == "page_id":
			parent_key = parent["page_id"]
		elif parent.get("type") == "database_id":
			parent_key = parent["database_id"]
		elif parent.get("type") == "data_source_id":
			parent_key = parent["data_source_id"]
		else:
			parent_key = database_id
		by_parent.setdefault(parent_key, []).append(
			{
				"id": page["id"],
				"title": title,
				"url": page.get(
					"url",
					f"https://www.notion.so/{page['id'].replace('-', '')}",
				),
			}
		)

	if tree is None:
		if notes_root is None or docs_dir is None:
			raise ValueError(
				"rebuild_state_from_wiki requires tree=… or notes_root=… and docs_dir=…"
			)
		tree, source = build_page_tree(
			nav_file=nav_file, notes_root=notes_root, docs_dir=docs_dir
		)
		log.info("page tree source for rebuild: %s", source)

	notebook = next((item for item in tree if item.title == "Notebook"), None)
	start_items = notebook.children if notebook else tree
	if notebook:
		state.pages[notebook.key] = {
			"id": database_id,
			"url": f"https://www.notion.so/{database_id.replace('-', '')}",
		}

	def section_allowed(item: NavItem) -> bool:
		if not sections:
			return True
		if item.file_rel:
			return any(item.file_rel.startswith(s) for s in sections)
		return any(section_allowed(c) for c in item.children)

	def match_items(items: list[NavItem], parent_page_id: str) -> None:
		unused = list(by_parent.get(parent_page_id, []))
		for item in items:
			if not section_allowed(item):
				continue
			match = next((c for c in unused if c["title"] == item.title), None)
			if match is None:
				log.warning("missing Notion page for %r title=%r", item.key, item.title)
				continue
			unused.remove(match)
			state.pages[item.key] = {"id": match["id"], "url": match["url"]}
			if item.children:
				match_items(item.children, match["id"])

	match_items(start_items, database_id)
	if data_source_id != database_id:
		match_items(
			[i for i in start_items if i.key not in state.pages],
			data_source_id,
		)
	return state

resolve_git_base(project_root, explicit)

Pick a base ref for incremental sync.

Priority: explicit → GITHUB_EVENT_BEFORENOTION_SYNC_BASEHEAD~1. Returns None when a full sync is required.

Source code in src/mkdocs_note/utils/notion/sync.py
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
def resolve_git_base(project_root: Path, explicit: str | None) -> str | None:
	"""Pick a base ref for incremental sync.

	Priority: explicit → ``GITHUB_EVENT_BEFORE`` → ``NOTION_SYNC_BASE`` →
	``HEAD~1``. Returns ``None`` when a full sync is required.
	"""
	if explicit:
		if explicit in ("", "0" * 40, "full"):
			return None
		return explicit

	before = os.environ.get("GITHUB_EVENT_BEFORE") or os.environ.get("NOTION_SYNC_BASE")
	if before:
		if before in ("", "0" * 40):
			return None
		return before

	try:
		_run_git(project_root, "rev-parse", "--verify", "HEAD~1")
		return "HEAD~1"
	except RuntimeError:
		return None

resolve_parent_id(token, state, state_path, nav_index, parent_key, delay, dry_run)

Resolve parent Notion id and kind for a content page.

Source code in src/mkdocs_note/utils/notion/sync.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
def resolve_parent_id(
	token: str,
	state: MigrationState,
	state_path: Path,
	nav_index: dict[str, NavItem],
	parent_key: str,
	delay: float,
	dry_run: bool,
) -> tuple[str, str]:
	"""Resolve parent Notion id and kind for a content page."""
	if not parent_key:
		parent_id = state.data_source_id or state.root_page_id
		kind = "data_source" if state.data_source_id else "database"
		return parent_id, kind
	# Notebook root → wiki database / data source for children.
	parent_item = nav_index.get(parent_key)
	if parent_item and parent_item.title == "Notebook" and not parent_item.parent_key:
		state.pages.setdefault(
			parent_key,
			{
				"id": state.root_page_id,
				"url": (f"https://www.notion.so/{state.root_page_id.replace('-', '')}"),
			},
		)
		parent_id = state.data_source_id or state.root_page_id
		kind = "data_source" if state.data_source_id else "database"
		return parent_id, kind
	page_id = ensure_section(
		token, state, state_path, nav_index, parent_key, delay, dry_run
	)
	return page_id, "page"

resolve_token(explicit, project_root, allow_cursor_mcp_token=False, silence_mcp_token_warning=False)

Resolve Notion token without requiring --token every run.

Order: explicit → load .env from project_root (no override) → NOTION_TOKEN / NOTION_API_KEYproject_root/.notion_token~/.config/notion/token → (only if allow_cursor_mcp_token) Cursor mcp.json.

Source code in src/mkdocs_note/utils/notion/sync.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def resolve_token(
	explicit: str | None,
	project_root: Path,
	allow_cursor_mcp_token: bool = False,
	silence_mcp_token_warning: bool = False,
) -> str | None:
	"""Resolve Notion token without requiring ``--token`` every run.

	Order: explicit → load ``.env`` from *project_root* (no override) →
	``NOTION_TOKEN`` / ``NOTION_API_KEY`` → ``project_root/.notion_token`` →
	``~/.config/notion/token`` → (only if *allow_cursor_mcp_token*) Cursor
	``mcp.json``.
	"""
	if explicit and explicit.strip():
		return explicit.strip()

	_load_dotenv(Path(project_root) / ".env")
	for key in ("NOTION_TOKEN", "NOTION_API_KEY"):
		val = os.environ.get(key)
		if val and val.strip():
			return val.strip()

	for path in (
		Path(project_root) / ".notion_token",
		Path.home() / ".config" / "notion" / "token",
	):
		if path.is_file():
			val = path.read_text(encoding="utf-8").strip()
			if val:
				return val

	if allow_cursor_mcp_token:
		token = _token_from_cursor_mcp()
		if token:
			if not silence_mcp_token_warning:
				log.warning(
					"Notion token loaded from Cursor MCP config "
					"(~/.cursor/mcp.json). This is not recommended unless you "
					"are a developer or have special needs. Set "
					"silence_mcp_token_warning: true to disable this warning."
				)
			return token
	return None

run_sync(options)

Main sync entry used by the CLI. Returns a process exit code.

Source code in src/mkdocs_note/utils/notion/sync.py
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
def run_sync(options: SyncOptions) -> int:
	"""Main sync entry used by the CLI. Returns a process exit code."""
	options = _apply_env_overrides(options)
	if options.verbose:
		setup_logging(True)

	token = resolve_token(
		options.token,
		options.project_root,
		allow_cursor_mcp_token=options.allow_cursor_mcp_token,
		silence_mcp_token_warning=options.silence_mcp_token_warning,
	)
	if not token and not options.dry_run:
		log.error(
			"Notion token not found. Set NOTION_TOKEN, add a project .env / "
			".notion_token, or enable allow_cursor_mcp_token for developer use."
		)
		return 1

	if not options.database_id or not options.data_source_id:
		log.error(
			"database_id and data_source_id are required "
			"(mkdocs.yml notion_sync or NOTION_WIKI_DATABASE / "
			"NOTION_WIKI_DATA_SOURCE)."
		)
		return 1

	state_path = options.state_path
	sections = options.section
	docs_prefix = _docs_prefix(options.project_root, options.docs_dir)

	# Resolve what to sync.
	path_list: list[str] = []
	if options.paths:
		path_list.extend(options.paths)
	if options.paths_file is not None:
		raw = Path(options.paths_file).read_text(encoding="utf-8")
		path_list.extend(line.strip() for line in raw.splitlines() if line.strip())

	if path_list:
		normalized: set[str] = set()
		for p in path_list:
			normalized.add(_strip_docs_prefix(p, docs_prefix))
		diff = DiffSet(md_changed=normalized)
		base: str | None = "(paths)"
		full = False
	elif options.full:
		diff = DiffSet(nav_changed=True)
		base = None
		full = True
	else:
		base = resolve_git_base(options.project_root, options.base)
		full = base is None
		if full:
			log.info("no git base available → full sync")
			diff = DiffSet(nav_changed=True)
		else:
			log.info("incremental sync since %s", base)
			diff = git_diff(base, options.project_root, options.docs_dir)

	log.info(
		"diff: md=%d deleted=%d assets=%d nav_changed=%s full=%s",
		len(diff.md_changed),
		len(diff.md_deleted),
		len(diff.assets_changed),
		diff.nav_changed,
		full,
	)

	tree, tree_source = build_page_tree(
		nav_file=options.nav_file,
		notes_root=options.notes_root,
		docs_dir=options.docs_dir,
	)
	log.info("page tree source: %s", tree_source)
	nav_index = index_tree(tree)

	# Load / rebuild page map.
	state = load_state(state_path, default_title=options.title_property)
	need_rebuild = options.rebuild_state or not state.pages
	if need_rebuild:
		if options.dry_run and not token:
			log.warning("dry-run without token: empty state")
		else:
			assert token
			log.info("rebuilding page map from Notion wiki…")
			state = rebuild_state_from_wiki(
				token,
				data_source_id=options.data_source_id,
				database_id=options.database_id,
				title_property=options.title_property,
				tree=tree,
				sections=sections,
			)
			if not options.dry_run:
				save_state(state_path, state)
			log.info("mapped %d keys", len(state.pages))
	else:
		state.root_page_id = state.root_page_id or options.database_id
		state.data_source_id = state.data_source_id or options.data_source_id
		state.title_property = state.title_property or options.title_property

	targets, deleted = collect_targets(
		full=full,
		diff=diff,
		nav_index=nav_index,
		docs_dir=options.docs_dir,
		sections=sections,
	)

	if deleted:
		for rel in sorted(deleted):
			log.info("deleted locally (Notion page left intact): %s", rel)

	if not targets:
		log.info("nothing to sync")
		return 0

	tags_cache: TagsSchemaCache | None = None
	if not options.dry_run and token:
		tags_cache = TagsSchemaCache(
			data_source_id=state.data_source_id or options.data_source_id,
			property_name=options.tags_property,
		)

	log.info("syncing %d page(s)", len(targets))
	stats = {"created": 0, "updated": 0, "dry-run": 0, "missing": 0, "failed": 0}
	for item in targets:
		try:
			result = sync_one_page(
				token or "",
				state,
				state_path,
				nav_index,
				item,
				docs_dir=options.docs_dir,
				site_url=options.site_url,
				delay=options.delay,
				dry_run=options.dry_run,
				upload_images=not options.no_images,
				tags_property=options.tags_property,
				tags_cache=tags_cache,
			)
			stats[result] = stats.get(result, 0) + 1
		except urllib.error.HTTPError as exc:
			body = getattr(exc, "reason", "") or ""
			log.error("FAIL %s: %s %s", item.file_rel, exc.code, body)
			stats["failed"] += 1
			if not options.continue_on_error:
				return 1
		except Exception as exc:  # noqa: BLE001 - per-page catch-all for continue_on_error
			log.error("FAIL %s: %s", item.file_rel, exc)
			stats["failed"] += 1
			if not options.continue_on_error:
				return 1

	if not options.dry_run:
		save_state(state_path, state)
	log.info("done %s", json.dumps(stats, ensure_ascii=False))
	return 1 if stats["failed"] else 0

save_state(path, state)

Persist migration state JSON.

Source code in src/mkdocs_note/utils/notion/sync.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def save_state(path: Path, state: MigrationState) -> None:
	"""Persist migration state JSON."""
	path.parent.mkdir(parents=True, exist_ok=True)
	path.write_text(
		json.dumps(
			{
				"root_page_id": state.root_page_id,
				"data_source_id": state.data_source_id,
				"title_property": state.title_property,
				"pages": state.pages,
			},
			ensure_ascii=False,
			indent=2,
		),
		encoding="utf-8",
	)

setup_logging(verbose=False)

Configure root logging for CLI runs (stdout, no log files).

Source code in src/mkdocs_note/utils/notion/sync.py
110
111
112
113
114
115
116
117
118
119
def setup_logging(verbose: bool = False) -> None:
	"""Configure root logging for CLI runs (stdout, no log files)."""
	level = logging.DEBUG if verbose else logging.INFO
	handler = logging.StreamHandler(sys.stdout)
	handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
	root = logging.getLogger()
	root.handlers.clear()
	root.addHandler(handler)
	root.setLevel(level)
	logging.getLogger("urllib3").setLevel(logging.WARNING)

sync_one_page(token, state, state_path, nav_index, item, *, docs_dir, site_url, delay, dry_run, upload_images, tags_property='标签', tags_cache=None)

Create or update one Notion page from a nav tree leaf.

Source code in src/mkdocs_note/utils/notion/sync.py
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
def sync_one_page(
	token: str,
	state: MigrationState,
	state_path: Path,
	nav_index: dict[str, NavItem],
	item: NavItem,
	*,
	docs_dir: Path,
	site_url: str,
	delay: float,
	dry_run: bool,
	upload_images: bool,
	tags_property: str = "标签",
	tags_cache: TagsSchemaCache | None = None,
) -> str:
	"""Create or update one Notion page from a nav tree leaf."""
	assert item.file_rel
	source = docs_dir / item.file_rel
	if not source.exists():
		log.warning("skip missing file %s", item.file_rel)
		return "missing"

	parent_id, parent_kind = resolve_parent_id(
		token,
		state,
		state_path,
		nav_index,
		item.parent_key,
		delay,
		dry_run,
	)

	created = item.file_rel not in state.pages
	if created:
		log.info("create page %s", item.file_rel)
		if dry_run:
			state.pages[item.file_rel] = {
				"id": f"dry-run-{item.file_rel}",
				"url": "https://www.notion.so/dry-run",
			}
		else:
			info = create_page(
				token,
				parent_id,
				item.title,
				title_property=state.title_property,
				parent_kind=parent_kind,
			)
			state.pages[item.file_rel] = info
			save_state(state_path, state)
			time.sleep(delay)
	else:
		log.info("update page %s", item.file_rel)

	image_paths: list[Path] = []

	def upload_local(path: Path) -> str:
		if not upload_images:
			return ""
		image_paths.append(path)
		return f"⟦LOCALIMG:{len(image_paths) - 1}⟧"

	title_conv, content, meta = convert_markdown_file(
		source,
		site_url,
		state.pages,
		docs_dir,
		upload_local=upload_local if upload_images else None,
	)
	tags = extract_tags(meta)
	_ = title_conv

	if dry_run:
		log.info(
			"dry-run %s content=%d chars images=%d tags=%s",
			item.file_rel,
			len(content),
			len(image_paths),
			tags,
		)
		return "dry-run"

	page_id = state.pages[item.file_rel]["id"]
	update_page_markdown(token, page_id, content)
	applied_tags: list[str] = []
	try:
		applied_tags = update_page_tags(
			token,
			page_id,
			tags,
			property_name=tags_property,
			tags_cache=tags_cache,
		)
	except urllib.error.HTTPError as exc:
		body = getattr(exc, "reason", "") or ""
		log.warning("tags update failed for %s: %s %s", item.file_rel, exc.code, body)
	except RuntimeError as exc:
		log.warning("tags update failed for %s: %s", item.file_rel, exc)
	attached = 0
	if upload_images and image_paths:
		attached = attach_placeholder_images(token, page_id, image_paths)
	if set(applied_tags) == set(tags):
		tags_display: Any = applied_tags or "—"
	else:
		tags_display = f"FAILED wanted={tags} applied={applied_tags}"
	log.info(
		"ok %s (%s, images=%d/%d, tags=%s)",
		item.file_rel,
		"created" if created else "updated",
		attached,
		len(image_paths),
		tags_display,
	)
	time.sleep(delay)
	return "created" if created else "updated"

commands

CleanCommand

Command to clean up orphaned asset directories.

Source code in src/mkdocs_note/utils/cli/commands.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
class CleanCommand:
	"""Command to clean up orphaned asset directories."""

	def _scan_note_files(self, root_dir: Path) -> list[Path]:
		"""Scan directory for note files.

		Args:
			root_dir (Path): Root directory to scan

		Returns:
			list[Path]: List of note file paths
		"""
		note_files = []

		try:
			for file_path in root_dir.rglob("*"):
				if file_path.is_file() and file_path.suffix.lower() in [
					".md",
					".ipynb",
				]:
					note_files.append(file_path)
		except Exception as e:
			log.error(f"Error scanning note files: {e}")

		return note_files

	def _find_orphaned_assets(self, note_files: list[Path]) -> list[Path]:
		"""Find orphaned asset directories.

		Args:
			note_files (list[Path]): List of note file paths

		Returns:
			list[Path]: List of orphaned asset directory paths
		"""
		root_dir = Path(common.get_plugin_config()["notes_root"])
		# Build a set of expected asset directory paths
		expected_asset_dirs: set[str] = set()
		for note_file in note_files:
			# Try to get permalink from file first
			permalink = common.get_permalink_from_file(note_file)
			if permalink:
				# Use permalink-based asset directory
				asset_dir = common.get_asset_directory_by_permalink(
					note_file, permalink
				)
			else:
				# Fallback to filename-based asset directory
				asset_dir = common.get_asset_directory(note_file)
			expected_asset_dirs.add(str(asset_dir.resolve()))

		# Find all actual asset directories by scanning root_dir
		orphaned_dirs: list[Path] = []
		try:
			# Scan for 'assets' directories within root_dir
			for asset_dir in root_dir.rglob("assets"):
				if not asset_dir.is_dir():
					continue
				# Check all subdirectories within each assets directory
				for item in asset_dir.iterdir():
					if item.is_dir():
						# Check if this is a leaf directory (no subdirectories)
						has_subdirs = any(child.is_dir() for child in item.iterdir())
						if not has_subdirs:
							# Check if this is a leaf directory that corresponds to a note
							item_resolved = str(item.resolve())
							if item_resolved not in expected_asset_dirs:
								orphaned_dirs.append(item)
		except Exception as e:
			log.error(f"Error finding orphaned assets: {e}")

		return orphaned_dirs

	def execute(self, dry_run: bool = False) -> None:
		"""Execute the clean command.

		Args:
			dry_run (bool): If True, only report what would be removed without actually removing
		"""
		try:
			root_dir = Path(common.get_plugin_config()["notes_root"])
			note_files = self._scan_note_files(root_dir)
			orphaned_dirs = self._find_orphaned_assets(note_files)
			if not orphaned_dirs:
				log.info("No orphaned asset directories found")

			log.info(f"Found {len(orphaned_dirs)} orphaned asset directory(ies)")

			removed_dirs: list[Path] = []

			for asset_dir in orphaned_dirs:
				if dry_run:
					log.info(f"[DRY RUN] Would remove: {asset_dir}")
					removed_dirs.append(asset_dir)
				else:
					shutil.rmtree(asset_dir)
					removed_dirs.append(asset_dir)
					log.info(
						f"Removed {len(removed_dirs)} orphaned asset directory(ies)"
					)
					# Clean up empty parent directories in source
					common.cleanup_empty_directories(asset_dir.parent, root_dir)
		except Exception as e:
			log.error(f"Error executing clean command: {e}")
			return

execute(dry_run=False)

Execute the clean command.

Parameters:

Name Type Description Default
dry_run bool

If True, only report what would be removed without actually removing

False
Source code in src/mkdocs_note/utils/cli/commands.py
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def execute(self, dry_run: bool = False) -> None:
	"""Execute the clean command.

	Args:
		dry_run (bool): If True, only report what would be removed without actually removing
	"""
	try:
		root_dir = Path(common.get_plugin_config()["notes_root"])
		note_files = self._scan_note_files(root_dir)
		orphaned_dirs = self._find_orphaned_assets(note_files)
		if not orphaned_dirs:
			log.info("No orphaned asset directories found")

		log.info(f"Found {len(orphaned_dirs)} orphaned asset directory(ies)")

		removed_dirs: list[Path] = []

		for asset_dir in orphaned_dirs:
			if dry_run:
				log.info(f"[DRY RUN] Would remove: {asset_dir}")
				removed_dirs.append(asset_dir)
			else:
				shutil.rmtree(asset_dir)
				removed_dirs.append(asset_dir)
				log.info(
					f"Removed {len(removed_dirs)} orphaned asset directory(ies)"
				)
				# Clean up empty parent directories in source
				common.cleanup_empty_directories(asset_dir.parent, root_dir)
	except Exception as e:
		log.error(f"Error executing clean command: {e}")
		return

MoveCommand

Command to move a note(s) and its(their) corresponding asset directory(ies) like mv.

Source code in src/mkdocs_note/utils/cli/commands.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
class MoveCommand:
	"""Command to move a note(s) and its(their)
	corresponding asset directory(ies) like `mv`.
	"""

	def _validate_before_execution(self, source: Path, destination: Path) -> int:
		"""Validate before executing the move command.

		Args:
			source (Path): The path to the source note file(s) to move
			destination (Path): The path to the destination note file(s) to move

		Returns:
			int: The signal marking the result of the validation:
				0: Failed
				1: Single file move request
				2: Multiple files that refer to a directory move request
		"""
		try:
			# Check if source exists
			if not source.exists():
				log.error(f"Source does not exist: {source}")
				return 0
			# Check if source is a directory
			elif source.is_dir():
				return 2
			# Check if source is a file
			elif source.is_file():
				# If destination exists and is a file (not a directory), it's an error
				# If destination exists and is a directory, that's OK (file will be moved into it)
				# If destination doesn't exist, it will be created
				if destination.exists() and destination.is_file():
					log.error(f"Destination already exists: {destination}")
					return 0
				return 1
		except Exception as e:
			log.error(f"Error validating before execution: {e}")
			return 0

	def _move_single_document(self, source: Path, destination: Path) -> None:
		"""Move a single document.

		Args:
			source (Path): The path to the source note file to move
			destination (Path): The path to the destination note file or directory to move to
		"""
		try:
			# If destination is a directory (exists and is a directory), construct the final destination path
			# (shutil.move will move source to destination/source.name)
			# If destination doesn't exist but its parent does, treat it as a file path
			if destination.exists() and destination.is_dir():
				final_destination = destination / source.name
			else:
				final_destination = destination

			# Ensure parent directory exists
			common.ensure_parent_directory(final_destination)

			# Read permalink from source document before moving it
			permalink = common.get_permalink_from_file(source)

			# Determine source asset directory based on permalink
			if permalink:
				# Use permalink-based asset directory
				source_asset_dir = common.get_asset_directory_by_permalink(
					source, permalink
				)
				# Resolve to absolute path to avoid issues with relative paths
				source_asset_dir = source_asset_dir.resolve()
				# Destination asset directory should also use permalink
				# (permalink stays the same after move)
				# Use final_destination to correctly calculate the asset directory
				dest_asset_dir = common.get_asset_directory_by_permalink(
					final_destination, permalink
				)
				dest_asset_dir = dest_asset_dir.resolve()
				log.debug(
					f"Using permalink-based asset directories: permalink={permalink}, source={source_asset_dir}, dest={dest_asset_dir}"
				)
			else:
				# Fallback to filename-based asset directory for backwards compatibility
				source_asset_dir = common.get_asset_directory(source)
				source_asset_dir = source_asset_dir.resolve()
				dest_asset_dir = common.get_asset_directory(final_destination)
				dest_asset_dir = dest_asset_dir.resolve()
				log.debug(
					f"Using filename-based asset directories (no permalink found): source={source.stem}, dest={final_destination.stem}, source_dir={source_asset_dir}, dest_dir={dest_asset_dir}"
				)

			# Move the document
			# Note: shutil.move handles both file and directory destinations correctly
			shutil.move(source, destination)
			log.info(f"Successfully moved document: {source}{final_destination}")

			# Move the asset directory if it exists and source/dest are in different locations
			# Note: If source and dest are in the same directory, their asset directories
			# based on permalink will be the same, so no move is needed.
			if source_asset_dir != dest_asset_dir:
				if source_asset_dir.exists():
					# Ensure destination asset parent's parent directory exists
					# (e.g., for /tmp/assets/dest, ensure /tmp/assets/ exists)
					dest_asset_dir.parent.mkdir(parents=True, exist_ok=True)

					# If destination asset dir already exists, remove it first
					if dest_asset_dir.exists():
						shutil.rmtree(dest_asset_dir)

					shutil.move(str(source_asset_dir), str(dest_asset_dir))
					log.info(
						f"Successfully moved asset directory: {source_asset_dir}{dest_asset_dir}"
					)
					# Clean up empty parent directories in source
					root_dir = Path(common.get_plugin_config()["notes_root"])
					common.cleanup_empty_directories(source_asset_dir.parent, root_dir)
				else:
					# If source asset dir doesn't exist, log a debug message
					log.debug(
						f"Source asset directory does not exist: {source_asset_dir}, skipping move"
					)
			else:
				# Source and dest are in the same directory, asset dir stays in place
				log.debug(
					f"Source and destination in same directory, asset directory unchanged: {source_asset_dir}"
				)
		except Exception as e:
			log.error(f"Error moving single document: {e}")
			# Try to rollback if possible
			try:
				if destination.exists():
					log.info("Attempting to rollback changes...")
					if not source.exists():
						shutil.move(str(destination), str(source))
					# Note: rollback asset directory only if it was moved
					# This is complex, so we'll just log the error
					log.warning(
						"Asset directory rollback not fully implemented. Manual cleanup may be required."
					)
					log.info("Rollback completed")
			except Exception as rollback_error:
				log.error(f"Rollback failed: {rollback_error}")

	def _move_docs_directory(self, source: Path, destination: Path) -> None:
		"""Move a directory of documents.

		Args:
			source (Path): The path to the source directory of documents to move
			destination (Path): The path to the destination directory of documents to move
		"""
		try:
			# Get all note files in the source directory
			source_dir_resolved = source.resolve()
			all_note_files = []

			for file_path in source_dir_resolved.rglob("*"):
				if file_path.is_file() and file_path.suffix.lower() in [
					".md",
					".ipynb",
				]:
					all_note_files.append(file_path)

			if not all_note_files:
				log.warning(f"No note files found in directory: {source}")

			log.info(f"Found {len(all_note_files)} note file(s) to move")

			# Move each note file
			for note_file in all_note_files:
				self._move_single_document(
					note_file, destination / note_file.relative_to(source_dir_resolved)
				)
		except Exception as e:
			log.error(f"Error moving directory of documents: {e}")

	def _rename_permalink(self, file_path: Path, new_permalink: str) -> None:
		"""Rename permalink value in a note file and its asset directory.

		Args:
			file_path (Path): The path to the note file
			new_permalink (str): The new permalink value
		"""
		try:
			# Validate file exists
			if not file_path.exists():
				log.error(f"File does not exist: {file_path}")
				return

			if not file_path.is_file():
				log.error(f"Path is not a file: {file_path}")
				return

			# Validate new permalink
			if not new_permalink or not new_permalink.strip():
				log.error("New permalink cannot be empty")
				return

			new_permalink = new_permalink.strip()

			# Get current permalink
			old_permalink = common.get_permalink_from_file(file_path)

			if not old_permalink:
				log.warning(
					f"No permalink found in {file_path}. Creating new permalink: {new_permalink}"
				)

			# Determine asset directories based on permalink
			if old_permalink:
				old_asset_dir = common.get_asset_directory_by_permalink(
					file_path, old_permalink
				)
				old_asset_dir = old_asset_dir.resolve()
			else:
				# Fallback to filename-based for backwards compatibility
				old_asset_dir = common.get_asset_directory(file_path)
				old_asset_dir = old_asset_dir.resolve()
				log.debug(
					f"No permalink found, using filename-based asset directory: {old_asset_dir}"
				)

			new_asset_dir = common.get_asset_directory_by_permalink(
				file_path, new_permalink
			)
			new_asset_dir = new_asset_dir.resolve()

			# Update permalink in file
			if common.update_permalink_in_file(file_path, new_permalink):
				log.info(
					f"Successfully updated permalink in {file_path}: {old_permalink or '(none)'}{new_permalink}"
				)
			else:
				log.error(f"Failed to update permalink in {file_path}")
				return

			# Rename asset directory if it exists and name changed
			if old_asset_dir != new_asset_dir:
				if old_asset_dir.exists():
					# Ensure destination asset parent directory exists
					new_asset_dir.parent.mkdir(parents=True, exist_ok=True)

					# If destination asset dir already exists, remove it first
					if new_asset_dir.exists():
						shutil.rmtree(new_asset_dir)

					shutil.move(str(old_asset_dir), str(new_asset_dir))
					log.info(
						f"Successfully renamed asset directory: {old_asset_dir}{new_asset_dir}"
					)
					# Clean up empty parent directories
					root_dir = Path(common.get_plugin_config()["notes_root"])
					common.cleanup_empty_directories(old_asset_dir.parent, root_dir)
				else:
					# Create new asset directory if old one doesn't exist
					if not new_asset_dir.exists():
						new_asset_dir.mkdir(parents=True, exist_ok=True)
						log.debug(f"Created new asset directory: {new_asset_dir}")
			else:
				# Permalink changed but asset directory name is the same (shouldn't happen, but handle it)
				log.debug(
					f"Permalink changed but asset directory unchanged: {new_asset_dir}"
				)
		except Exception as e:
			log.error(f"Error renaming permalink: {e}")

	def execute(
		self,
		source: Path,
		destination: Path | None = None,
		permalink: str | None = None,
	) -> None:
		"""Execute the move command.

		Args:
			source (Path): The path to the source note file(s) to move, or file to rename permalink
			destination (Path | None): The path to the destination note file(s) to move (ignored if permalink is provided)
			permalink (str | None): If provided, rename permalink instead of moving file
		"""
		try:
			if permalink:
				# Permalink rename mode: source is the file path, destination is ignored
				if not source.exists():
					log.error(f"Source does not exist: {source}")
					return
				if source.is_file():
					self._rename_permalink(source, permalink)
				else:
					log.error(
						f"Permalink rename only works on files, not directories: {source}"
					)
					return
			else:
				# File move mode: original behavior
				if destination is None:
					log.error("Destination is required in file move mode")
					return
				pre_check = self._validate_before_execution(source, destination)
				if pre_check == 0:
					log.error(f"Validation failed for: {source}")
				elif pre_check == 1:
					self._move_single_document(source, destination)
				elif pre_check == 2:
					self._move_docs_directory(source, destination)
		except Exception as e:
			log.error(f"Error executing move command: {e}")
			return

execute(source, destination=None, permalink=None)

Execute the move command.

Parameters:

Name Type Description Default
source Path

The path to the source note file(s) to move, or file to rename permalink

required
destination Path | None

The path to the destination note file(s) to move (ignored if permalink is provided)

None
permalink str | None

If provided, rename permalink instead of moving file

None
Source code in src/mkdocs_note/utils/cli/commands.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
def execute(
	self,
	source: Path,
	destination: Path | None = None,
	permalink: str | None = None,
) -> None:
	"""Execute the move command.

	Args:
		source (Path): The path to the source note file(s) to move, or file to rename permalink
		destination (Path | None): The path to the destination note file(s) to move (ignored if permalink is provided)
		permalink (str | None): If provided, rename permalink instead of moving file
	"""
	try:
		if permalink:
			# Permalink rename mode: source is the file path, destination is ignored
			if not source.exists():
				log.error(f"Source does not exist: {source}")
				return
			if source.is_file():
				self._rename_permalink(source, permalink)
			else:
				log.error(
					f"Permalink rename only works on files, not directories: {source}"
				)
				return
		else:
			# File move mode: original behavior
			if destination is None:
				log.error("Destination is required in file move mode")
				return
			pre_check = self._validate_before_execution(source, destination)
			if pre_check == 0:
				log.error(f"Validation failed for: {source}")
			elif pre_check == 1:
				self._move_single_document(source, destination)
			elif pre_check == 2:
				self._move_docs_directory(source, destination)
	except Exception as e:
		log.error(f"Error executing move command: {e}")
		return

NewCommand

Command to create a new note.

Source code in src/mkdocs_note/utils/cli/commands.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class NewCommand:
	"""Command to create a new note."""

	timestamp_format: str = "%Y-%m-%d %H:%M:%S"

	def _generate_note_basic_meta(self, file_path: Path, permalink: str) -> str:
		"""Generate the note meta.

		Args:
			file_path (Path): The path to the new note file
			permalink (str): The permalink value to use

		Returns:
			str: The generated frontmatter content
		"""
		log.debug(f"Generating note meta for: {file_path} with permalink: {permalink}")

		return f"""---
date: {datetime.now(UTC).strftime(self.timestamp_format)}
title: {file_path.stem.replace("-", " ").replace("_", " ").title()}
permalink: {permalink}
publish: false
tags:
  - 
---
"""

	def _validate_before_execution(self, file_path: Path, permalink: str) -> bool:
		"""Validate before executing the new command.

		Args:
			file_path (Path): The path to the new note file
			permalink (str): The permalink value

		Returns:
			bool: True if the validation is successful, False otherwise
		"""
		try:
			# Check if file already exists
			if file_path.exists():
				log.error(f"File already exists: {file_path}")
				return False

			# Check if permalink is empty or None
			if not permalink or not permalink.strip():
				log.error("Permalink cannot be empty")
				return False

			return True
		except Exception as e:
			log.error(f"Error validating before execution: {e}")
			return False

	def execute(self, permalink: str, file_path: Path) -> None:
		"""Execute the new command.

		Args:
			permalink (str): The permalink value to use for frontmatter and asset directory
			file_path (Path): The path to the new note file
		"""
		try:
			permalink = permalink.strip()
			if self._validate_before_execution(file_path, permalink):
				# Ensure parent directory exists
				common.ensure_parent_directory(file_path)

				# Generate note meta with permalink
				note_meta = self._generate_note_basic_meta(file_path, permalink)

				# Create note file
				file_path.write_text(note_meta, encoding="utf-8")

				# Create corresponding asset directory using permalink
				asset_dir = common.get_asset_directory_by_permalink(
					file_path, permalink
				)
				asset_dir.mkdir(parents=True, exist_ok=True)
			else:
				log.error(f"Validation failed for: {file_path}")
				return

		except Exception as e:
			log.error(f"Error executing new command: {e}")
			return

execute(permalink, file_path)

Execute the new command.

Parameters:

Name Type Description Default
permalink str

The permalink value to use for frontmatter and asset directory

required
file_path Path

The path to the new note file

required
Source code in src/mkdocs_note/utils/cli/commands.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def execute(self, permalink: str, file_path: Path) -> None:
	"""Execute the new command.

	Args:
		permalink (str): The permalink value to use for frontmatter and asset directory
		file_path (Path): The path to the new note file
	"""
	try:
		permalink = permalink.strip()
		if self._validate_before_execution(file_path, permalink):
			# Ensure parent directory exists
			common.ensure_parent_directory(file_path)

			# Generate note meta with permalink
			note_meta = self._generate_note_basic_meta(file_path, permalink)

			# Create note file
			file_path.write_text(note_meta, encoding="utf-8")

			# Create corresponding asset directory using permalink
			asset_dir = common.get_asset_directory_by_permalink(
				file_path, permalink
			)
			asset_dir.mkdir(parents=True, exist_ok=True)
		else:
			log.error(f"Validation failed for: {file_path}")
			return

	except Exception as e:
		log.error(f"Error executing new command: {e}")
		return

RemoveCommand

Command to remove a note(s) and its(their) corresponding asset directory(ies) like rm -rf.

Source code in src/mkdocs_note/utils/cli/commands.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
class RemoveCommand:
	"""Command to remove a note(s) and its(their)
	corresponding asset directory(ies) like `rm -rf`.
	"""

	def _validate_before_execution(self, path: Path) -> int:
		"""Validate before executing the remove command.

		Args:
			path (Path): The path to the note file(s) to remove

		Returns:
			int: The signal marking the result of the validation:
				0: Failed
				1: Single file remove request
				2: Multiple files that refer to a directory remove request
		"""
		try:
			# Check if path exist
			if not path.exists():
				log.error(f"Path does not exist: {path}")
				return 0
			# Check if path is a directory
			elif path.is_dir():
				return 2
			# Check if path is a file
			elif path.is_file():
				return 1
		except Exception as e:
			log.error(f"Error validating before execution: {e}")
			return 0

	def _remove_single_document(self, path: Path, remove_assets: bool = True) -> None:
		"""Remove a single document.

		Args:
			path (Path): The path to the note file to remove
			remove_assets (bool): Whether to remove the asset directory
		"""
		try:
			# Read permalink from document before deleting it
			permalink = common.get_permalink_from_file(path)

			# Determine asset directory based on permalink
			if permalink:
				# Use permalink-based asset directory
				asset_dir = common.get_asset_directory_by_permalink(path, permalink)
				log.debug(
					f"Using permalink-based asset directory: {asset_dir} (permalink: {permalink})"
				)
			else:
				# Fallback to filename-based asset directory for backwards compatibility
				asset_dir = common.get_asset_directory(path)
				log.debug(
					f"Using filename-based asset directory: {asset_dir} (no permalink found)"
				)

			# Remove the document
			path.unlink()
			log.info(f"Successfully removed document: {path}")

			# Remove the asset directory if requested and exists
			if remove_assets and asset_dir.exists():
				shutil.rmtree(asset_dir)
				log.info(f"Successfully removed asset directory: {asset_dir}")
				# Clean up empty parent directories in source
				root_dir = Path(common.get_plugin_config()["notes_root"])
				common.cleanup_empty_directories(asset_dir.parent, root_dir)
			elif remove_assets:
				log.warning(
					f"Asset directory does not exist: {asset_dir}, skipping removal"
				)
		except Exception as e:
			log.error(f"Error removing single document: {e}")

	def _remove_docs_directory(
		self, directory: Path, remove_assets: bool = True
	) -> None:
		"""Remove a directory of documents.

		Args:
			directory (Path): The path to the directory of documents to remove
			remove_assets (bool): Whether to remove the asset directories
		"""
		try:
			# Get the list of documents in the directory
			documents = [
				p
				for p in directory.iterdir()
				if p.is_file() and (p.suffix == ".md" or p.suffix == ".ipynb")
			]

			# Remove each document
			for document in documents:
				self._remove_single_document(document, remove_assets)
		except Exception as e:
			log.error(f"Error removing directory of documents: {e}")

	def execute(self, path: Path, remove_assets: bool = True) -> None:
		"""Execute the remove command.

		Args:
			path (Path): The path to the note file to remove
		"""
		try:
			# Validate before execution
			pre_check = self._validate_before_execution(path)
			if pre_check == 0:
				log.error(f"Validation failed for: {path}")
			elif pre_check == 1:
				self._remove_single_document(path, remove_assets)
			elif pre_check == 2:
				self._remove_docs_directory(path, remove_assets)
		except Exception as e:
			log.error(f"Error executing remove command: {e}")
			return

execute(path, remove_assets=True)

Execute the remove command.

Parameters:

Name Type Description Default
path Path

The path to the note file to remove

required
Source code in src/mkdocs_note/utils/cli/commands.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def execute(self, path: Path, remove_assets: bool = True) -> None:
	"""Execute the remove command.

	Args:
		path (Path): The path to the note file to remove
	"""
	try:
		# Validate before execution
		pre_check = self._validate_before_execution(path)
		if pre_check == 0:
			log.error(f"Validation failed for: {path}")
		elif pre_check == 1:
			self._remove_single_document(path, remove_assets)
		elif pre_check == 2:
			self._remove_docs_directory(path, remove_assets)
	except Exception as e:
		log.error(f"Error executing remove command: {e}")
		return

common

Common utilities and data structures for CLI operations.

cleanup_empty_directories(start_dir, stop_at)

Recursively remove empty parent directories up to a stop point.

Parameters:

Name Type Description Default
start_dir Path

Directory to start cleanup from

required
stop_at Path

Directory to stop at (won't be removed)

required
Source code in src/mkdocs_note/utils/cli/common.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def cleanup_empty_directories(start_dir: Path, stop_at: Path) -> None:
	"""Recursively remove empty parent directories up to a stop point.

	Args:
	    start_dir: Directory to start cleanup from
	    stop_at: Directory to stop at (won't be removed)
	"""
	try:
		current = start_dir.resolve()
		stop = stop_at.resolve()

		# Don't remove directories outside or at the stop point
		if not current.is_relative_to(stop) or current == stop:
			return

		# Check if directory exists and is empty
		if current.exists() and current.is_dir():
			try:
				if not any(current.iterdir()):
					log.debug(f"Removing empty directory: {current}")
					current.rmdir()
					# Recursively clean up parent
					cleanup_empty_directories(current.parent, stop)
			except OSError:
				# Directory not empty or other error, stop cleanup
				pass
	except Exception as e:
		log.error(f"Error during directory cleanup: {e}")

ensure_parent_directory(path)

Ensure the parent directory of a path exists.

Parameters:

Name Type Description Default
path Path

File path whose parent should be created

required

Raises:

Type Description
OSError

If directory creation fails

Source code in src/mkdocs_note/utils/cli/common.py
194
195
196
197
198
199
200
201
202
203
def ensure_parent_directory(path: Path) -> None:
	"""Ensure the parent directory of a path exists.

	Args:
	    path: File path whose parent should be created

	Raises:
	    OSError: If directory creation fails
	"""
	path.parent.mkdir(parents=True, exist_ok=True)

get_asset_directory(note_path)

Get the asset directory path for a note file based on filename.

Uses co-located asset structure: note_file.parent / "assets" / note_file.stem This is the legacy method based on filename stem.

Parameters:

Name Type Description Default
note_path Path

Path to the note file

required

Returns:

Name Type Description
Path Path

The asset directory path

Examples:

>>> get_asset_directory(Path("docs/usage/contributing.md"))
PosixPath('docs/usage/assets/contributing')
>>> get_asset_directory(Path("docs/notes/python/intro.md"))
PosixPath('docs/notes/python/assets/intro')
Source code in src/mkdocs_note/utils/cli/common.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def get_asset_directory(note_path: Path) -> Path:
	"""Get the asset directory path for a note file based on filename.

	Uses co-located asset structure: note_file.parent / "assets" / note_file.stem
	__This is the legacy method based on filename stem__.

	Args:
	    note_path: Path to the note file

	Returns:
	    Path: The asset directory path

	Examples:
	    >>> get_asset_directory(Path("docs/usage/contributing.md"))
	    PosixPath('docs/usage/assets/contributing')

	    >>> get_asset_directory(Path("docs/notes/python/intro.md"))
	    PosixPath('docs/notes/python/assets/intro')
	"""
	return note_path.parent / "assets" / note_path.stem

Get the asset directory path for a note file based on permalink.

Uses co-located asset structure: note_file.parent / "assets" / permalink

Parameters:

Name Type Description Default
note_path Path

Path to the note file

required
permalink str

The permalink value from frontmatter

required

Returns:

Name Type Description
Path Path

The asset directory path

Examples:

>>> get_asset_directory_by_permalink(Path("docs/notes/my-note.md"), "my-permalink")
PosixPath('docs/notes/assets/my-permalink')
Source code in src/mkdocs_note/utils/cli/common.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def get_asset_directory_by_permalink(note_path: Path, permalink: str) -> Path:
	"""Get the asset directory path for a note file based on permalink.

	Uses co-located asset structure: note_file.parent / "assets" / permalink

	Args:
	    note_path: Path to the note file
	    permalink: The permalink value from frontmatter

	Returns:
	    Path: The asset directory path

	Examples:
	    >>> get_asset_directory_by_permalink(Path("docs/notes/my-note.md"), "my-permalink")
	    PosixPath('docs/notes/assets/my-permalink')
	"""
	return note_path.parent / "assets" / permalink

Extract permalink value from note file's frontmatter.

Parameters:

Name Type Description Default
note_path Path

Path to the note file

required

Returns:

Type Description
str | None

Optional[str]: The permalink value if found, None otherwise

Examples:

>>> get_permalink_from_file(Path("docs/notes/my-note.md"))
'my-permalink'
Source code in src/mkdocs_note/utils/cli/common.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def get_permalink_from_file(note_path: Path) -> str | None:
	"""Extract permalink value from note file's frontmatter.

	Args:
	    note_path: Path to the note file

	Returns:
	    Optional[str]: The permalink value if found, None otherwise

	Examples:
	    >>> get_permalink_from_file(Path("docs/notes/my-note.md"))
	    'my-permalink'
	"""
	try:
		content = note_path.read_text(encoding="utf-8")
		_, frontmatter = meta.get_data(content)
		permalink = frontmatter.get("permalink")
		if permalink and isinstance(permalink, str) and permalink.strip():
			return permalink.strip()
		return None
	except Exception as e:
		log.error(f"Error reading permalink from {note_path}: {e}")
		return None

get_plugin_config()

Get the plugin configuration.

Returns:

Name Type Description
MkdocsNoteConfig MkDocsConfig

The plugin configuration

Source code in src/mkdocs_note/utils/cli/common.py
16
17
18
19
20
21
22
def get_plugin_config() -> MkDocsConfig:
	"""Get the plugin configuration.

	Returns:
		MkdocsNoteConfig: The plugin configuration
	"""
	return plugin.config

is_excluded_name(name, exclude_patterns)

Check if a filename matches any exclude pattern.

Parameters:

Name Type Description Default
name str

Filename to check

required
exclude_patterns list[str]

List of patterns to exclude (e.g., ["index.md", "README.md"])

required

Returns:

Name Type Description
bool bool

True if name should be excluded

Source code in src/mkdocs_note/utils/cli/common.py
181
182
183
184
185
186
187
188
189
190
191
def is_excluded_name(name: str, exclude_patterns: list[str]) -> bool:
	"""Check if a filename matches any exclude pattern.

	Args:
	    name: Filename to check
	    exclude_patterns: List of patterns to exclude (e.g., ["index.md", "README.md"])

	Returns:
	    bool: True if name should be excluded
	"""
	return name in exclude_patterns

Update permalink value in note file's frontmatter.

This function preserves the original frontmatter format as much as possible.

Parameters:

Name Type Description Default
note_path Path

Path to the note file

required
new_permalink str

New permalink value to set

required

Returns:

Name Type Description
bool bool

True if update was successful, False otherwise

Examples:

>>> update_permalink_in_file(Path("docs/notes/my-note.md"), "new-permalink")
True
Source code in src/mkdocs_note/utils/cli/common.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def update_permalink_in_file(note_path: Path, new_permalink: str) -> bool:
	"""Update permalink value in note file's frontmatter.

	This function preserves the original frontmatter format as much as possible.

	Args:
	    note_path: Path to the note file
	    new_permalink: New permalink value to set

	Returns:
	    bool: True if update was successful, False otherwise

	Examples:
	    >>> update_permalink_in_file(Path("docs/notes/my-note.md"), "new-permalink")
	    True
	"""
	try:
		content = note_path.read_text(encoding="utf-8")

		# Check if file has frontmatter
		if not content.startswith("---\n"):
			log.error(f"File {note_path} does not have frontmatter")
			return False

		# Find frontmatter end marker
		frontmatter_end = content.find("\n---\n", 4)
		if frontmatter_end == -1:
			log.error(f"File {note_path} has invalid frontmatter format")
			return False

		frontmatter_section = content[4:frontmatter_end]  # Skip initial "---\n"
		markdown_content = content[frontmatter_end + 5 :]  # Skip "\n---\n"

		# Parse frontmatter to get current values
		_, frontmatter = meta.get_data(content)

		# Update permalink in frontmatter dict
		frontmatter["permalink"] = new_permalink.strip()

		# Reconstruct frontmatter section
		# Try to preserve original format by updating only the permalink line
		lines = frontmatter_section.split("\n")
		updated = False
		new_lines = []

		for line in lines:
			# Match permalink line (with or without value, with various spacing)
			if line.strip().startswith("permalink:"):
				# Preserve indentation
				indent = len(line) - len(line.lstrip())
				new_lines.append(" " * indent + f"permalink: {new_permalink.strip()}")
				updated = True
			else:
				new_lines.append(line)

		# If permalink line wasn't found, add it (at a reasonable position)
		if not updated:
			# Find where to insert permalink (after date, before publish if exists)
			insert_pos = len(new_lines)
			for i, line in enumerate(new_lines):
				if line.strip().startswith("publish:"):
					insert_pos = i
					break
				elif line.strip().startswith("title:"):
					# Insert after title
					insert_pos = i + 1

			# Use same indentation as surrounding lines
			indent = 0
			if insert_pos > 0 and insert_pos < len(new_lines):
				indent = len(new_lines[insert_pos - 1]) - len(
					new_lines[insert_pos - 1].lstrip()
				)

			new_lines.insert(
				insert_pos, " " * indent + f"permalink: {new_permalink.strip()}"
			)

		# Reconstruct full content
		new_content = "---\n" + "\n".join(new_lines) + "\n---\n" + markdown_content

		# Write back to file
		note_path.write_text(new_content, encoding="utf-8")
		log.debug(f"Updated permalink in {note_path} to: {new_permalink}")
		return True
	except Exception as e:
		log.error(f"Error updating permalink in {note_path}: {e}")
		return False