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.

extract_date(f)

Extract date from docs file Args: f (File): The file to extract date from

Returns:

Type Description
Optional[datetime]

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

Source code in src/mkdocs_note/utils/meta.py
60
61
62
63
64
65
66
67
68
69
70
71
def extract_date(f: File) -> Optional[datetime]:
	"""Extract date from docs file
	Args:
	    f (File): The file to extract date from

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

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
Optional[str]

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

Source code in src/mkdocs_note/utils/meta.py
74
75
76
77
78
79
80
81
82
83
84
85
86
def extract_title(f: File) -> Optional[str]:
	"""Extract title from docs file

	Args:
	    f (File): The file to extract title from

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

validate_frontmatter(f)

Validate the frontmatter of the file

Parameters:

Name Type Description Default
file 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
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
def validate_frontmatter(f: File) -> bool:
	"""Validate the frontmatter of the file

	Args:
	    file (File): The file to validate

	Returns:
	    bool: True if the frontmatter is valid, False otherwise
	"""
	try:
		_, frontmatter = 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

		setattr(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

		setattr(f, "note_title", title)
		return True

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

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
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
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 e

	return notes, invalid_files

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().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
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
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
196
197
198
199
200
201
202
203
204
205
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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
Optional[str]

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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def get_permalink_from_file(note_path: Path) -> Optional[str]:
	"""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
18
19
20
21
22
23
24
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
183
184
185
186
187
188
189
190
191
192
193
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
 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
179
180
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