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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- 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
|