diff --git a/src/basic_memory/api/routers/resource_router.py b/src/basic_memory/api/routers/resource_router.py index 59cc9273..d20a92ba 100644 --- a/src/basic_memory/api/routers/resource_router.py +++ b/src/basic_memory/api/routers/resource_router.py @@ -16,6 +16,7 @@ from basic_memory.deps import ( FileServiceDep, EntityRepositoryDep, ) +from basic_memory.file_utils import normalize_path_separators from basic_memory.repository.search_repository import SearchIndexRow from basic_memory.schemas.memory import normalize_memory_url from basic_memory.schemas.search import SearchQuery, SearchItemType @@ -149,6 +150,9 @@ async def write_resource( JSON response with file information """ try: + # Normalize file_path to prevent double slashes that cause DB conflicts (issue #431) + file_path = normalize_path_separators(file_path) + # Get content from request body # Defensive type checking: ensure content is a string diff --git a/src/basic_memory/file_utils.py b/src/basic_memory/file_utils.py index ff03c0aa..9f90bb08 100644 --- a/src/basic_memory/file_utils.py +++ b/src/basic_memory/file_utils.py @@ -137,7 +137,32 @@ def parse_frontmatter(content: str) -> Dict[str, Any]: return frontmatter except yaml.YAMLError as e: - raise ParseError(f"Invalid YAML in frontmatter: {e}") + # Provide helpful error messages for common YAML mistakes (issue #431) + error_msg = str(e) + suggestions = [] + + # Check for common formatting errors + yaml_content = parts[1] + if "could not find expected ':'" in error_msg: + suggestions.append( + "Missing space after colon - YAML requires 'key: value' not 'key:value'" + ) + # Show specific line with the problem if available + for line in yaml_content.split("\n"): + if ":" in line and not ": " in line: + # Found a line with colon but no space after + suggestions.append(f"Problem line: '{line.strip()}'") + fixed_line = line.replace(":", ": ", 1) + suggestions.append(f"Try: '{fixed_line.strip()}'") + break + + if suggestions: + suggestion_text = "\n ".join(suggestions) + raise ParseError( + f"Invalid YAML in frontmatter: {error_msg}\n\nSuggestions:\n {suggestion_text}" + ) + else: + raise ParseError(f"Invalid YAML in frontmatter: {error_msg}") except Exception as e: # pragma: no cover if not isinstance(e, ParseError): @@ -241,6 +266,47 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str: return text.strip(replacement) +def normalize_path_separators(path: str) -> str: + """ + Normalize path separators to prevent double slashes and database conflicts. + + This function addresses issue #431 where double slashes in paths like + 'L3_design/emotion//biorhythm.md' cause UNIQUE constraint violations + in the database. + + Args: + path: File path that may contain double slashes or mixed separators + + Returns: + Path with normalized separators (single forward slashes) + + Examples: + >>> normalize_path_separators("folder//file.md") + 'folder/file.md' + >>> normalize_path_separators("path\\\\to\\\\file.md") + 'path/to/file.md' + >>> normalize_path_separators("./folder/./file.md") + 'folder/file.md' + """ + if not path: + return "" + + # Replace backslashes with forward slashes + normalized = path.replace("\\", "/") + + # Remove leading ./ if present + if normalized.startswith("./"): + normalized = normalized[2:] + + # Compress multiple slashes into single slashes + normalized = re.sub(r"/+", "/", normalized) + + # Remove trailing slashes + normalized = normalized.rstrip("/") + + return normalized + + def sanitize_for_folder(folder: str) -> str: """ Sanitize folder path to be safe for use in file system paths. diff --git a/src/basic_memory/importers/memory_json_importer.py b/src/basic_memory/importers/memory_json_importer.py index c6d58acb..087cdf17 100644 --- a/src/basic_memory/importers/memory_json_importer.py +++ b/src/basic_memory/importers/memory_json_importer.py @@ -42,6 +42,20 @@ class MemoryJsonImporter(Importer[EntityImportResult]): # First pass - collect entities and relations for line in source_data: data = line + + # Validate data structure (issue #431) + if not isinstance(data, dict): + logger.warning(f"Skipping invalid data (not a dict): {data}") + skipped_entities += 1 + continue + + if "type" not in data: + logger.warning( + f"Skipping data missing 'type' field. Available fields: {list(data.keys())}" + ) + skipped_entities += 1 + continue + if data["type"] == "entity": # Handle different possible name keys entity_name = data.get("name") or data.get("entityName") or data.get("id") diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index 142a23ab..00d74c68 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -24,7 +24,11 @@ from dateparser import parse from pydantic import BaseModel, BeforeValidator, Field, model_validator from basic_memory.config import ConfigManager -from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder +from basic_memory.file_utils import ( + sanitize_for_filename, + sanitize_for_folder, + normalize_path_separators, +) from basic_memory.utils import generate_permalink @@ -238,14 +242,19 @@ class Entity(BaseModel): @property def file_path(self): - """Get the file path for this entity based on its permalink.""" + """Get the file path for this entity based on its permalink. + + Returns normalized path to prevent double slashes (issue #431). + """ safe_title = self.safe_title if self.content_type == "text/markdown": - return ( + path = ( os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md" ) else: - return os.path.join(self.folder, safe_title) if self.folder else safe_title + path = os.path.join(self.folder, safe_title) if self.folder else safe_title + # Normalize to prevent double slashes that cause DB conflicts (issue #431) + return normalize_path_separators(path) @property def permalink(self) -> Optional[Permalink]: diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 21a1d784..2d15ac37 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -588,7 +588,17 @@ class EntityService(BaseService[EntityModel]): # Validate count matches expected if actual_count != expected_replacements: if actual_count == 0: - raise ValueError(f"Text to replace not found: '{find_text}'") + # Provide helpful context for debugging (issue #431) + # Show a snippet of the content to help diagnose the mismatch + content_preview = current_content[:500] + if len(current_content) > 500: + content_preview += "..." + + raise ValueError( + f"Text to replace not found: '{find_text}'\n\n" + f"Content preview (first 500 chars):\n{content_preview}\n\n" + f"Suggestion: Use read_note() to view the full content and verify the exact text to find." + ) else: raise ValueError( f"Expected {expected_replacements} occurrences of '{find_text}', " diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 15078e1f..7c629891 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -272,6 +272,17 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]: if tags is None: return [] + # Handle dict objects (issue #431) - PyYAML can produce dicts for malformed YAML + # e.g., `tags:{key:value}` gets parsed as dict instead of failing + if isinstance(tags, dict): + logger.warning( + f"Tags provided as dict instead of list/string. " + f"Converting dict keys to tag list: {tags}. " + f"Expected format: 'tags: [tag1, tag2]' or 'tags: tag1, tag2'" + ) + # Use dict keys as tags, ignore values + return [str(key).strip().lstrip("#") for key in tags.keys() if key] + # Process list of tags if isinstance(tags, list): # First strip whitespace, then strip leading '#' characters to prevent accumulation