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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -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/6830751f5fb6_merge_multiple_heads.py +24 -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/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- 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 +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- 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 +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -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 +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -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 +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- 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 +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- 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 +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/file_utils.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
89
|
+
content: Content that may start with BOM
|
|
55
90
|
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
Returns:
|
|
92
|
+
Content with BOM removed if present
|
|
58
93
|
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
updates: Dict of frontmatter fields to update
|
|
409
|
+
post: frontmatter.Post object to serialize
|
|
184
410
|
|
|
185
411
|
Returns:
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
464
|
+
if sanitized.startswith("./"):
|
|
465
|
+
sanitized = sanitized[2:]
|
|
204
466
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
472
|
+
# compress multiple, repeated instances of path separators
|
|
473
|
+
sanitized = re.sub(r"[\\/]+", "/", sanitized)
|
|
211
474
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
475
|
+
# trim any leading/trailing path separators
|
|
476
|
+
sanitized = sanitized.strip("\\/")
|
|
477
|
+
|
|
478
|
+
return sanitized
|