basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  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 +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  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 +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -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/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,38 @@
1
1
  """Utilities for file operations."""
2
2
 
3
+ import asyncio
3
4
  import hashlib
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
4
8
  from pathlib import Path
5
- from typing import Dict, Any
9
+ import re
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Union
6
11
 
12
+ import aiofiles
7
13
  import yaml
14
+ import frontmatter
8
15
  from loguru import logger
9
16
 
17
+ from basic_memory.utils import FilePath
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
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
+
10
36
 
11
37
  class FileError(Exception):
12
38
  """Base exception for file operations."""
@@ -26,12 +52,12 @@ class ParseError(FileError):
26
52
  pass
27
53
 
28
54
 
29
- async def compute_checksum(content: str) -> str:
55
+ async def compute_checksum(content: Union[str, bytes]) -> str:
30
56
  """
31
57
  Compute SHA-256 checksum of content.
32
58
 
33
59
  Args:
34
- content: Text content to hash
60
+ content: Content to hash (either text string or bytes)
35
61
 
36
62
  Returns:
37
63
  SHA-256 hex digest
@@ -40,51 +66,229 @@ async def compute_checksum(content: str) -> str:
40
66
  FileError: If checksum computation fails
41
67
  """
42
68
  try:
43
- return hashlib.sha256(content.encode()).hexdigest()
69
+ if isinstance(content, str):
70
+ content = content.encode()
71
+ return hashlib.sha256(content).hexdigest()
44
72
  except Exception as e: # pragma: no cover
45
73
  logger.error(f"Failed to compute checksum: {e}")
46
74
  raise FileError(f"Failed to compute checksum: {e}")
47
75
 
48
76
 
49
- async def ensure_directory(path: Path) -> None:
50
- """
51
- Ensure directory exists, creating if necessary.
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.
52
87
 
53
88
  Args:
54
- path: Directory path to ensure
89
+ content: Content that may start with BOM
55
90
 
56
- Raises:
57
- FileWriteError: If directory creation fails
91
+ Returns:
92
+ Content with BOM removed if present
58
93
  """
59
- try:
60
- path.mkdir(parents=True, exist_ok=True)
61
- except Exception as e: # pragma: no cover
62
- logger.error(f"Failed to create directory: {path}: {e}")
63
- raise FileWriteError(f"Failed to create directory {path}: {e}")
94
+ if content and content.startswith(UTF8_BOM):
95
+ return content[1:]
96
+ return content
64
97
 
65
98
 
66
- async def write_file_atomic(path: Path, content: str) -> None:
99
+ async def write_file_atomic(path: FilePath, content: str) -> None:
67
100
  """
68
101
  Write file with atomic operation using temporary file.
69
102
 
103
+ Uses aiofiles for true async I/O (non-blocking).
104
+
70
105
  Args:
71
- path: Target file path
106
+ path: Target file path (Path or string)
72
107
  content: Content to write
73
108
 
74
109
  Raises:
75
110
  FileWriteError: If write operation fails
76
111
  """
77
- temp_path = path.with_suffix(".tmp")
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
+
78
116
  try:
79
- temp_path.write_text(content)
80
- temp_path.replace(path)
81
- logger.debug(f"wrote file: {path}")
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))
82
124
  except Exception as e: # pragma: no cover
83
125
  temp_path.unlink(missing_ok=True)
84
- logger.error(f"Failed to write file: {path}: {e}")
126
+ logger.error("Failed to write file", path=str(path_obj), error=str(e))
85
127
  raise FileWriteError(f"Failed to write file {path}: {e}")
86
128
 
87
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: # pragma: no cover
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: # pragma: no cover
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: # pragma: no cover
284
+ logger.warning(
285
+ "Formatter failed",
286
+ path=str(path),
287
+ error=str(e),
288
+ )
289
+ return None
290
+
291
+
88
292
  def has_frontmatter(content: str) -> bool:
89
293
  """
90
294
  Check if content contains valid YAML frontmatter.
@@ -95,7 +299,11 @@ def has_frontmatter(content: str) -> bool:
95
299
  Returns:
96
300
  True if content has valid frontmatter markers (---), False otherwise
97
301
  """
98
- content = content.strip()
302
+ if not content:
303
+ return False
304
+
305
+ # Strip BOM before checking for frontmatter markers
306
+ content = strip_bom(content).strip()
99
307
  if not content.startswith("---"):
100
308
  return False
101
309
 
@@ -116,6 +324,8 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
116
324
  ParseError: If frontmatter is invalid or parsing fails
117
325
  """
118
326
  try:
327
+ # Strip BOM before parsing frontmatter
328
+ content = strip_bom(content)
119
329
  if not content.strip().startswith("---"):
120
330
  raise ParseError("Content has no frontmatter")
121
331
 
@@ -157,7 +367,8 @@ def remove_frontmatter(content: str) -> str:
157
367
  Raises:
158
368
  ParseError: If content starts with frontmatter marker but is malformed
159
369
  """
160
- content = content.strip()
370
+ # Strip BOM before processing
371
+ content = strip_bom(content).strip()
161
372
 
162
373
  # Return as-is if no frontmatter marker
163
374
  if not content.startswith("---"):
@@ -171,44 +382,97 @@ def remove_frontmatter(content: str) -> str:
171
382
  return parts[2].strip()
172
383
 
173
384
 
174
- async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
175
- """Update frontmatter fields in a file while preserving all content.
176
-
177
- Only modifies the frontmatter section, leaving all content untouched.
178
- Creates frontmatter section if none exists.
179
- Returns checksum of updated file.
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
+ ---
180
407
 
181
408
  Args:
182
- path: Path to markdown file
183
- updates: Dict of frontmatter fields to update
409
+ post: frontmatter.Post object to serialize
184
410
 
185
411
  Returns:
186
- Checksum of updated file
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)
187
443
 
188
- Raises:
189
- FileError: If file operations fail
190
- ParseError: If frontmatter parsing fails
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:
191
454
  """
192
- try:
193
- # Read current content
194
- content = path.read_text()
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 ""
195
461
 
196
- # Parse current frontmatter
197
- current_fm = {}
198
- if has_frontmatter(content):
199
- current_fm = parse_frontmatter(content)
200
- content = remove_frontmatter(content)
462
+ sanitized = folder.strip()
201
463
 
202
- # Update frontmatter
203
- new_fm = {**current_fm, **updates}
464
+ if sanitized.startswith("./"):
465
+ sanitized = sanitized[2:]
204
466
 
205
- # Write new file with updated frontmatter
206
- yaml_fm = yaml.dump(new_fm, sort_keys=False)
207
- final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
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()
208
471
 
209
- await write_file_atomic(path, final_content)
210
- return await compute_checksum(final_content)
472
+ # compress multiple, repeated instances of path separators
473
+ sanitized = re.sub(r"[\\/]+", "/", sanitized)
211
474
 
212
- except Exception as e: # pragma: no cover
213
- logger.error(f"Failed to update frontmatter in {path}: {e}")
214
- raise FileError(f"Failed to update frontmatter: {e}")
475
+ # trim any leading/trailing path separators
476
+ sanitized = sanitized.strip("\\/")
477
+
478
+ return sanitized