basic-memory 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,478 @@
1
+ """Utilities for file operations."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ import re
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Union
11
+
12
+ import aiofiles
13
+ import yaml
14
+ import frontmatter
15
+ from loguru import logger
16
+
17
+ from basic_memory.utils import FilePath
18
+
19
+ if TYPE_CHECKING:
20
+ from basic_memory.config import BasicMemoryConfig
21
+
22
+
23
+ @dataclass
24
+ class FileMetadata:
25
+ """File metadata for cloud-compatible file operations.
26
+
27
+ This dataclass provides a cloud-agnostic way to represent file metadata,
28
+ enabling S3FileService to return metadata from head_object responses
29
+ instead of mock stat_result with zeros.
30
+ """
31
+
32
+ size: int
33
+ created_at: datetime
34
+ modified_at: datetime
35
+
36
+
37
+ class FileError(Exception):
38
+ """Base exception for file operations."""
39
+
40
+ pass
41
+
42
+
43
+ class FileWriteError(FileError):
44
+ """Raised when file operations fail."""
45
+
46
+ pass
47
+
48
+
49
+ class ParseError(FileError):
50
+ """Raised when parsing file content fails."""
51
+
52
+ pass
53
+
54
+
55
+ async def compute_checksum(content: Union[str, bytes]) -> str:
56
+ """
57
+ Compute SHA-256 checksum of content.
58
+
59
+ Args:
60
+ content: Content to hash (either text string or bytes)
61
+
62
+ Returns:
63
+ SHA-256 hex digest
64
+
65
+ Raises:
66
+ FileError: If checksum computation fails
67
+ """
68
+ try:
69
+ if isinstance(content, str):
70
+ content = content.encode()
71
+ return hashlib.sha256(content).hexdigest()
72
+ except Exception as e: # pragma: no cover
73
+ logger.error(f"Failed to compute checksum: {e}")
74
+ raise FileError(f"Failed to compute checksum: {e}")
75
+
76
+
77
+ # UTF-8 BOM character that can appear at the start of files
78
+ UTF8_BOM = "\ufeff"
79
+
80
+
81
+ def strip_bom(content: str) -> str:
82
+ """Strip UTF-8 BOM from the start of content if present.
83
+
84
+ BOM (Byte Order Mark) characters can be present in files created on Windows
85
+ or copied from certain sources. They should be stripped before processing
86
+ frontmatter. See issue #452.
87
+
88
+ Args:
89
+ content: Content that may start with BOM
90
+
91
+ Returns:
92
+ Content with BOM removed if present
93
+ """
94
+ if content and content.startswith(UTF8_BOM):
95
+ return content[1:]
96
+ return content
97
+
98
+
99
+ async def write_file_atomic(path: FilePath, content: str) -> None:
100
+ """
101
+ Write file with atomic operation using temporary file.
102
+
103
+ Uses aiofiles for true async I/O (non-blocking).
104
+
105
+ Args:
106
+ path: Target file path (Path or string)
107
+ content: Content to write
108
+
109
+ Raises:
110
+ FileWriteError: If write operation fails
111
+ """
112
+ # Convert string to Path if needed
113
+ path_obj = Path(path) if isinstance(path, str) else path
114
+ temp_path = path_obj.with_suffix(".tmp")
115
+
116
+ try:
117
+ # Use aiofiles for non-blocking write
118
+ async with aiofiles.open(temp_path, mode="w", encoding="utf-8") as f:
119
+ await f.write(content)
120
+
121
+ # Atomic rename (this is fast, doesn't need async)
122
+ temp_path.replace(path_obj)
123
+ logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
124
+ except Exception as e: # pragma: no cover
125
+ temp_path.unlink(missing_ok=True)
126
+ logger.error("Failed to write file", path=str(path_obj), error=str(e))
127
+ raise FileWriteError(f"Failed to write file {path}: {e}")
128
+
129
+
130
+ async def format_markdown_builtin(path: Path) -> Optional[str]:
131
+ """
132
+ Format a markdown file using the built-in mdformat formatter.
133
+
134
+ Uses mdformat with GFM (GitHub Flavored Markdown) support for consistent
135
+ formatting without requiring Node.js or external tools.
136
+
137
+ Args:
138
+ path: Path to the markdown file to format
139
+
140
+ Returns:
141
+ Formatted content if successful, None if formatting failed.
142
+ """
143
+ try:
144
+ import mdformat
145
+ except ImportError:
146
+ logger.warning(
147
+ "mdformat not installed, skipping built-in formatting",
148
+ path=str(path),
149
+ )
150
+ return None
151
+
152
+ try:
153
+ # Read original content
154
+ async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
155
+ content = await f.read()
156
+
157
+ # Format using mdformat with GFM and frontmatter extensions
158
+ # mdformat is synchronous, so we run it in a thread executor
159
+ loop = asyncio.get_event_loop()
160
+ formatted_content = await loop.run_in_executor(
161
+ None,
162
+ lambda: mdformat.text(
163
+ content,
164
+ extensions={"gfm", "frontmatter"}, # GFM + YAML frontmatter support
165
+ options={"wrap": "no"}, # Don't wrap lines
166
+ ),
167
+ )
168
+
169
+ # Only write if content changed
170
+ if formatted_content != content:
171
+ async with aiofiles.open(path, mode="w", encoding="utf-8") as f:
172
+ await f.write(formatted_content)
173
+
174
+ logger.debug(
175
+ "Formatted file with mdformat",
176
+ path=str(path),
177
+ changed=formatted_content != content,
178
+ )
179
+ return formatted_content
180
+
181
+ except Exception as e:
182
+ logger.warning(
183
+ "mdformat formatting failed",
184
+ path=str(path),
185
+ error=str(e),
186
+ )
187
+ return None
188
+
189
+
190
+ async def format_file(
191
+ path: Path,
192
+ config: "BasicMemoryConfig",
193
+ is_markdown: bool = False,
194
+ ) -> Optional[str]:
195
+ """
196
+ Format a file using configured formatter.
197
+
198
+ By default, uses the built-in mdformat formatter for markdown files (pure Python,
199
+ no Node.js required). External formatters like Prettier can be configured via
200
+ formatter_command or per-extension formatters.
201
+
202
+ Args:
203
+ path: File to format
204
+ config: Configuration with formatter settings
205
+ is_markdown: Whether this is a markdown file (caller should use FileService.is_markdown)
206
+
207
+ Returns:
208
+ Formatted content if successful, None if formatting was skipped or failed.
209
+ Failures are logged as warnings but don't raise exceptions.
210
+ """
211
+ if not config.format_on_save:
212
+ return None
213
+
214
+ extension = path.suffix.lstrip(".")
215
+ formatter = config.formatters.get(extension) or config.formatter_command
216
+
217
+ # Use built-in mdformat for markdown files when no external formatter configured
218
+ if not formatter:
219
+ if is_markdown:
220
+ return await format_markdown_builtin(path)
221
+ else:
222
+ logger.debug("No formatter configured for extension", extension=extension)
223
+ return None
224
+
225
+ # Use external formatter
226
+ # Replace {file} placeholder with the actual path
227
+ cmd = formatter.replace("{file}", str(path))
228
+
229
+ try:
230
+ # Parse command into args list for safer execution (no shell=True)
231
+ args = shlex.split(cmd)
232
+
233
+ proc = await asyncio.create_subprocess_exec(
234
+ *args,
235
+ stdout=asyncio.subprocess.PIPE,
236
+ stderr=asyncio.subprocess.PIPE,
237
+ )
238
+
239
+ try:
240
+ stdout, stderr = await asyncio.wait_for(
241
+ proc.communicate(),
242
+ timeout=config.formatter_timeout,
243
+ )
244
+ except asyncio.TimeoutError:
245
+ proc.kill()
246
+ await proc.wait()
247
+ logger.warning(
248
+ "Formatter timed out",
249
+ path=str(path),
250
+ timeout=config.formatter_timeout,
251
+ )
252
+ return None
253
+
254
+ if proc.returncode != 0:
255
+ logger.warning(
256
+ "Formatter exited with non-zero status",
257
+ path=str(path),
258
+ returncode=proc.returncode,
259
+ stderr=stderr.decode("utf-8", errors="replace") if stderr else "",
260
+ )
261
+ # Still try to read the file - formatter may have partially worked
262
+ # or the file may be unchanged
263
+
264
+ # Read formatted content
265
+ async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
266
+ formatted_content = await f.read()
267
+
268
+ logger.debug(
269
+ "Formatted file successfully",
270
+ path=str(path),
271
+ formatter=args[0] if args else formatter,
272
+ )
273
+ return formatted_content
274
+
275
+ except FileNotFoundError:
276
+ # Formatter executable not found
277
+ logger.warning(
278
+ "Formatter executable not found",
279
+ command=cmd.split()[0] if cmd else "",
280
+ path=str(path),
281
+ )
282
+ return None
283
+ except Exception as e:
284
+ logger.warning(
285
+ "Formatter failed",
286
+ path=str(path),
287
+ error=str(e),
288
+ )
289
+ return None
290
+
291
+
292
+ def has_frontmatter(content: str) -> bool:
293
+ """
294
+ Check if content contains valid YAML frontmatter.
295
+
296
+ Args:
297
+ content: Content to check
298
+
299
+ Returns:
300
+ True if content has valid frontmatter markers (---), False otherwise
301
+ """
302
+ if not content:
303
+ return False
304
+
305
+ # Strip BOM before checking for frontmatter markers
306
+ content = strip_bom(content).strip()
307
+ if not content.startswith("---"):
308
+ return False
309
+
310
+ return "---" in content[3:]
311
+
312
+
313
+ def parse_frontmatter(content: str) -> Dict[str, Any]:
314
+ """
315
+ Parse YAML frontmatter from content.
316
+
317
+ Args:
318
+ content: Content with YAML frontmatter
319
+
320
+ Returns:
321
+ Dictionary of frontmatter values
322
+
323
+ Raises:
324
+ ParseError: If frontmatter is invalid or parsing fails
325
+ """
326
+ try:
327
+ # Strip BOM before parsing frontmatter
328
+ content = strip_bom(content)
329
+ if not content.strip().startswith("---"):
330
+ raise ParseError("Content has no frontmatter")
331
+
332
+ # Split on first two occurrences of ---
333
+ parts = content.split("---", 2)
334
+ if len(parts) < 3:
335
+ raise ParseError("Invalid frontmatter format")
336
+
337
+ # Parse YAML
338
+ try:
339
+ frontmatter = yaml.safe_load(parts[1])
340
+ # Handle empty frontmatter (None from yaml.safe_load)
341
+ if frontmatter is None:
342
+ return {}
343
+ if not isinstance(frontmatter, dict):
344
+ raise ParseError("Frontmatter must be a YAML dictionary")
345
+ return frontmatter
346
+
347
+ except yaml.YAMLError as e:
348
+ raise ParseError(f"Invalid YAML in frontmatter: {e}")
349
+
350
+ except Exception as e: # pragma: no cover
351
+ if not isinstance(e, ParseError):
352
+ logger.error(f"Failed to parse frontmatter: {e}")
353
+ raise ParseError(f"Failed to parse frontmatter: {e}")
354
+ raise
355
+
356
+
357
+ def remove_frontmatter(content: str) -> str:
358
+ """
359
+ Remove YAML frontmatter from content.
360
+
361
+ Args:
362
+ content: Content with frontmatter
363
+
364
+ Returns:
365
+ Content with frontmatter removed, or original content if no frontmatter
366
+
367
+ Raises:
368
+ ParseError: If content starts with frontmatter marker but is malformed
369
+ """
370
+ # Strip BOM before processing
371
+ content = strip_bom(content).strip()
372
+
373
+ # Return as-is if no frontmatter marker
374
+ if not content.startswith("---"):
375
+ return content
376
+
377
+ # Split on first two occurrences of ---
378
+ parts = content.split("---", 2)
379
+ if len(parts) < 3:
380
+ raise ParseError("Invalid frontmatter format")
381
+
382
+ return parts[2].strip()
383
+
384
+
385
+ def dump_frontmatter(post: frontmatter.Post) -> str:
386
+ """
387
+ Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
388
+
389
+ This function ensures that:
390
+ 1. Tags are formatted as YAML lists instead of JSON arrays
391
+ 2. String values are properly quoted to handle special characters (colons, etc.)
392
+
393
+ Good (Obsidian compatible):
394
+ ---
395
+ title: "L2 Governance Core (Split: Core)"
396
+ tags:
397
+ - system
398
+ - overview
399
+ - reference
400
+ ---
401
+
402
+ Bad (causes parsing errors):
403
+ ---
404
+ title: L2 Governance Core (Split: Core) # Unquoted colon breaks YAML
405
+ tags: ["system", "overview", "reference"]
406
+ ---
407
+
408
+ Args:
409
+ post: frontmatter.Post object to serialize
410
+
411
+ Returns:
412
+ String containing markdown with properly formatted YAML frontmatter
413
+ """
414
+ if not post.metadata:
415
+ # No frontmatter, just return content
416
+ return post.content
417
+
418
+ # Serialize YAML with block style for lists
419
+ # SafeDumper automatically quotes values with special characters (colons, etc.)
420
+ yaml_str = yaml.dump(
421
+ post.metadata,
422
+ sort_keys=False,
423
+ allow_unicode=True,
424
+ default_flow_style=False,
425
+ Dumper=yaml.SafeDumper,
426
+ )
427
+
428
+ # Construct the final markdown with frontmatter
429
+ if post.content:
430
+ return f"---\n{yaml_str}---\n\n{post.content}"
431
+ else:
432
+ return f"---\n{yaml_str}---\n"
433
+
434
+
435
+ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
436
+ """
437
+ Sanitize string to be safe for use as a note title
438
+ Replaces path separators and other problematic characters
439
+ with hyphens.
440
+ """
441
+ # replace both POSIX and Windows path separators
442
+ text = re.sub(r"[/\\]", replacement, text)
443
+
444
+ # replace some other problematic chars
445
+ text = re.sub(r'[<>:"|?*]', replacement, text)
446
+
447
+ # compress multiple, repeated replacements
448
+ text = re.sub(f"{re.escape(replacement)}+", replacement, text)
449
+
450
+ return text.strip(replacement)
451
+
452
+
453
+ def sanitize_for_folder(folder: str) -> str:
454
+ """
455
+ Sanitize folder path to be safe for use in file system paths.
456
+ Removes leading/trailing whitespace, compresses multiple slashes,
457
+ and removes special characters except for /, -, and _.
458
+ """
459
+ if not folder:
460
+ return ""
461
+
462
+ sanitized = folder.strip()
463
+
464
+ if sanitized.startswith("./"):
465
+ sanitized = sanitized[2:]
466
+
467
+ # ensure no special characters (except for a few that are allowed)
468
+ sanitized = "".join(
469
+ c for c in sanitized if c.isalnum() or c in (".", " ", "-", "_", "\\", "/")
470
+ ).rstrip()
471
+
472
+ # compress multiple, repeated instances of path separators
473
+ sanitized = re.sub(r"[\\/]+", "/", sanitized)
474
+
475
+ # trim any leading/trailing path separators
476
+ sanitized = sanitized.strip("\\/")
477
+
478
+ return sanitized