mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: v0.13.0 pre (#122)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"""Directory service for managing file directories and tree structure."""
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
@@ -87,3 +88,80 @@ class DirectoryService:
|
||||
|
||||
# Return the root node with its children
|
||||
return root_node
|
||||
|
||||
async def list_directory(
|
||||
self,
|
||||
dir_name: str = "/",
|
||||
depth: int = 1,
|
||||
file_name_glob: Optional[str] = None,
|
||||
) -> List[DirectoryNode]:
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1 = immediate children only)
|
||||
file_name_glob: Glob pattern for filtering file names
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Normalize directory path
|
||||
if not dir_name.startswith("/"):
|
||||
dir_name = f"/{dir_name}"
|
||||
if dir_name != "/" and dir_name.endswith("/"):
|
||||
dir_name = dir_name.rstrip("/")
|
||||
|
||||
# Get the full directory tree
|
||||
root_tree = await self.get_directory_tree()
|
||||
|
||||
# Find the target directory node
|
||||
target_node = self._find_directory_node(root_tree, dir_name)
|
||||
if not target_node:
|
||||
return []
|
||||
|
||||
# Collect nodes with depth and glob filtering
|
||||
result = []
|
||||
self._collect_nodes_recursive(target_node, result, depth, file_name_glob, 0)
|
||||
|
||||
return result
|
||||
|
||||
def _find_directory_node(
|
||||
self, root: DirectoryNode, target_path: str
|
||||
) -> Optional[DirectoryNode]:
|
||||
"""Find a directory node by path in the tree."""
|
||||
if root.directory_path == target_path:
|
||||
return root
|
||||
|
||||
for child in root.children:
|
||||
if child.type == "directory":
|
||||
found = self._find_directory_node(child, target_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
def _collect_nodes_recursive(
|
||||
self,
|
||||
node: DirectoryNode,
|
||||
result: List[DirectoryNode],
|
||||
max_depth: int,
|
||||
file_name_glob: Optional[str],
|
||||
current_depth: int,
|
||||
) -> None:
|
||||
"""Recursively collect nodes with depth and glob filtering."""
|
||||
if current_depth >= max_depth:
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
# Apply glob filtering
|
||||
if file_name_glob and not fnmatch.fnmatch(child.name, file_name_glob):
|
||||
continue
|
||||
|
||||
# Add the child to results
|
||||
result.append(child)
|
||||
|
||||
# Recurse into subdirectories if we haven't reached max depth
|
||||
if child.type == "directory" and current_depth < max_depth:
|
||||
self._collect_nodes_recursive(
|
||||
child, result, max_depth, file_name_glob, current_depth + 1
|
||||
)
|
||||
|
||||
@@ -4,9 +4,12 @@ from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import frontmatter
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig
|
||||
from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter
|
||||
from basic_memory.markdown import EntityMarkdown
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
|
||||
@@ -114,8 +117,29 @@ class EntityService(BaseService[EntityModel]):
|
||||
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
|
||||
)
|
||||
|
||||
# Get unique permalink
|
||||
permalink = await self.resolve_permalink(schema.permalink or file_path)
|
||||
# Parse content frontmatter to check for user-specified permalink
|
||||
content_markdown = None
|
||||
if schema.content and has_frontmatter(schema.content):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
if "permalink" in content_frontmatter:
|
||||
# Create a minimal EntityMarkdown object for permalink resolution
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": schema.title,
|
||||
"type": schema.entity_type,
|
||||
"permalink": content_frontmatter["permalink"],
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
content_markdown = EntityMarkdown(
|
||||
frontmatter=frontmatter_obj,
|
||||
content="", # content not needed for permalink resolution
|
||||
observations=[],
|
||||
relations=[],
|
||||
)
|
||||
|
||||
# Get unique permalink (prioritizing content frontmatter)
|
||||
permalink = await self.resolve_permalink(file_path, content_markdown)
|
||||
schema._permalink = permalink
|
||||
|
||||
post = await schema_to_markdown(schema)
|
||||
@@ -148,12 +172,47 @@ class EntityService(BaseService[EntityModel]):
|
||||
# Read existing frontmatter from the file if it exists
|
||||
existing_markdown = await self.entity_parser.parse_file(file_path)
|
||||
|
||||
# Parse content frontmatter to check for user-specified permalink
|
||||
content_markdown = None
|
||||
if schema.content and has_frontmatter(schema.content):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
if "permalink" in content_frontmatter:
|
||||
# Create a minimal EntityMarkdown object for permalink resolution
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": schema.title,
|
||||
"type": schema.entity_type,
|
||||
"permalink": content_frontmatter["permalink"],
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
content_markdown = EntityMarkdown(
|
||||
frontmatter=frontmatter_obj,
|
||||
content="", # content not needed for permalink resolution
|
||||
observations=[],
|
||||
relations=[],
|
||||
)
|
||||
|
||||
# Check if we need to update the permalink based on content frontmatter
|
||||
new_permalink = entity.permalink # Default to existing
|
||||
if content_markdown and content_markdown.frontmatter.permalink:
|
||||
# Resolve permalink with the new content frontmatter
|
||||
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
|
||||
if resolved_permalink != entity.permalink:
|
||||
new_permalink = resolved_permalink
|
||||
# Update the schema to use the new permalink
|
||||
schema._permalink = new_permalink
|
||||
|
||||
# Create post with new content from schema
|
||||
post = await schema_to_markdown(schema)
|
||||
|
||||
# Merge new metadata with existing metadata
|
||||
existing_markdown.frontmatter.metadata.update(post.metadata)
|
||||
|
||||
# Ensure the permalink in the metadata is the resolved one
|
||||
if new_permalink != entity.permalink:
|
||||
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
|
||||
|
||||
# Create a new post with merged metadata
|
||||
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
|
||||
|
||||
@@ -325,3 +384,319 @@ class EntityService(BaseService[EntityModel]):
|
||||
continue
|
||||
|
||||
return await self.repository.get_by_file_path(path)
|
||||
|
||||
async def edit_entity(
|
||||
self,
|
||||
identifier: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
) -> EntityModel:
|
||||
"""Edit an existing entity's content using various operations.
|
||||
|
||||
Args:
|
||||
identifier: Entity identifier (permalink, title, etc.)
|
||||
operation: The editing operation (append, prepend, find_replace, replace_section)
|
||||
content: The content to add or use for replacement
|
||||
section: For replace_section operation - the markdown header
|
||||
find_text: For find_replace operation - the text to find and replace
|
||||
expected_replacements: For find_replace operation - expected number of replacements (default: 1)
|
||||
|
||||
Returns:
|
||||
The updated entity model
|
||||
|
||||
Raises:
|
||||
EntityNotFoundError: If the entity cannot be found
|
||||
ValueError: If required parameters are missing for the operation or replacement count doesn't match expected
|
||||
"""
|
||||
logger.debug(f"Editing entity: {identifier}, operation: {operation}")
|
||||
|
||||
# Find the entity using the link resolver
|
||||
entity = await self.link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
||||
|
||||
# Read the current file content
|
||||
file_path = Path(entity.file_path)
|
||||
current_content, _ = await self.file_service.read_file(file_path)
|
||||
|
||||
# Apply the edit operation
|
||||
new_content = self.apply_edit_operation(
|
||||
current_content, operation, content, section, find_text, expected_replacements
|
||||
)
|
||||
|
||||
# Write the updated content back to the file
|
||||
checksum = await self.file_service.write_file(file_path, new_content)
|
||||
|
||||
# Parse the updated file to get new observations/relations
|
||||
entity_markdown = await self.entity_parser.parse_file(file_path)
|
||||
|
||||
# Update entity and its relationships
|
||||
entity = await self.update_entity_and_observations(file_path, entity_markdown)
|
||||
await self.update_entity_relations(str(file_path), entity_markdown)
|
||||
|
||||
# Set final checksum to match file
|
||||
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
return entity
|
||||
|
||||
def apply_edit_operation(
|
||||
self,
|
||||
current_content: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
) -> str:
|
||||
"""Apply the specified edit operation to the current content."""
|
||||
|
||||
if operation == "append":
|
||||
# Ensure proper spacing
|
||||
if current_content and not current_content.endswith("\n"):
|
||||
return current_content + "\n" + content
|
||||
return current_content + content # pragma: no cover
|
||||
|
||||
elif operation == "prepend":
|
||||
# Handle frontmatter-aware prepending
|
||||
return self._prepend_after_frontmatter(current_content, content)
|
||||
|
||||
elif operation == "find_replace":
|
||||
if not find_text:
|
||||
raise ValueError("find_text is required for find_replace operation")
|
||||
if not find_text.strip():
|
||||
raise ValueError("find_text cannot be empty or whitespace only")
|
||||
|
||||
# Count actual occurrences
|
||||
actual_count = current_content.count(find_text)
|
||||
|
||||
# Validate count matches expected
|
||||
if actual_count != expected_replacements:
|
||||
if actual_count == 0:
|
||||
raise ValueError(f"Text to replace not found: '{find_text}'")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Expected {expected_replacements} occurrences of '{find_text}', "
|
||||
f"but found {actual_count}"
|
||||
)
|
||||
|
||||
return current_content.replace(find_text, content)
|
||||
|
||||
elif operation == "replace_section":
|
||||
if not section:
|
||||
raise ValueError("section is required for replace_section operation")
|
||||
if not section.strip():
|
||||
raise ValueError("section cannot be empty or whitespace only")
|
||||
return self.replace_section_content(current_content, section, content)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported operation: {operation}")
|
||||
|
||||
def replace_section_content(
|
||||
self, current_content: str, section_header: str, new_content: str
|
||||
) -> str:
|
||||
"""Replace content under a specific markdown section header.
|
||||
|
||||
This method uses a simple, safe approach: when replacing a section, it only
|
||||
replaces the immediate content under that header until it encounters the next
|
||||
header of ANY level. This means:
|
||||
|
||||
- Replacing "# Header" replaces content until "## Subsection" (preserves subsections)
|
||||
- Replacing "## Section" replaces content until "### Subsection" (preserves subsections)
|
||||
- More predictable and safer than trying to consume entire hierarchies
|
||||
|
||||
Args:
|
||||
current_content: The current markdown content
|
||||
section_header: The section header to find and replace (e.g., "## Section Name")
|
||||
new_content: The new content to replace the section with
|
||||
|
||||
Returns:
|
||||
The updated content with the section replaced
|
||||
|
||||
Raises:
|
||||
ValueError: If multiple sections with the same header are found
|
||||
"""
|
||||
# Normalize the section header (ensure it starts with #)
|
||||
if not section_header.startswith("#"):
|
||||
section_header = "## " + section_header
|
||||
|
||||
# First pass: count matching sections to check for duplicates
|
||||
lines = current_content.split("\n")
|
||||
matching_sections = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == section_header.strip():
|
||||
matching_sections.append(i)
|
||||
|
||||
# Handle multiple sections error
|
||||
if len(matching_sections) > 1:
|
||||
raise ValueError(
|
||||
f"Multiple sections found with header '{section_header}'. "
|
||||
f"Section replacement requires unique headers."
|
||||
)
|
||||
|
||||
# If no section found, append it
|
||||
if len(matching_sections) == 0:
|
||||
logger.info(f"Section '{section_header}' not found, appending to end of document")
|
||||
separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
|
||||
return current_content + separator + section_header + "\n" + new_content
|
||||
|
||||
# Replace the single matching section
|
||||
result_lines = []
|
||||
section_line_idx = matching_sections[0]
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Check if this is our target section header
|
||||
if i == section_line_idx:
|
||||
# Add the section header and new content
|
||||
result_lines.append(line)
|
||||
result_lines.append(new_content)
|
||||
i += 1
|
||||
|
||||
# Skip the original section content until next header or end
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
# Stop consuming when we hit any header (preserve subsections)
|
||||
if next_line.startswith("#"):
|
||||
# We found another header - continue processing from here
|
||||
break
|
||||
i += 1
|
||||
# Continue processing from the next header (don't increment i again)
|
||||
continue
|
||||
|
||||
# Add all other lines (including subsequent sections)
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
|
||||
"""Prepend content after frontmatter, preserving frontmatter structure."""
|
||||
|
||||
# Check if file has frontmatter
|
||||
if has_frontmatter(current_content):
|
||||
try:
|
||||
# Parse and separate frontmatter from body
|
||||
frontmatter_data = parse_frontmatter(current_content)
|
||||
body_content = remove_frontmatter(current_content)
|
||||
|
||||
# Prepend content to the body
|
||||
if content and not content.endswith("\n"):
|
||||
new_body = content + "\n" + body_content
|
||||
else:
|
||||
new_body = content + body_content
|
||||
|
||||
# Reconstruct file with frontmatter + prepended body
|
||||
yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
|
||||
return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(
|
||||
f"Failed to parse frontmatter during prepend: {e}"
|
||||
) # pragma: no cover
|
||||
# Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
|
||||
|
||||
# No frontmatter or parsing failed - do simple prepend # pragma: no cover
|
||||
if content and not content.endswith("\n"): # pragma: no cover
|
||||
return content + "\n" + current_content # pragma: no cover
|
||||
return content + current_content # pragma: no cover
|
||||
|
||||
async def move_entity(
|
||||
self,
|
||||
identifier: str,
|
||||
destination_path: str,
|
||||
project_config: ProjectConfig,
|
||||
app_config: BasicMemoryConfig,
|
||||
) -> EntityModel:
|
||||
"""Move entity to new location with database consistency.
|
||||
|
||||
Args:
|
||||
identifier: Entity identifier (title, permalink, or memory:// URL)
|
||||
destination_path: New path relative to project root
|
||||
project_config: Project configuration for file operations
|
||||
app_config: App configuration for permalink update settings
|
||||
|
||||
Returns:
|
||||
Success message with move details
|
||||
|
||||
Raises:
|
||||
EntityNotFoundError: If the entity cannot be found
|
||||
ValueError: If move operation fails due to validation or filesystem errors
|
||||
"""
|
||||
logger.debug(f"Moving entity: {identifier} to {destination_path}")
|
||||
|
||||
# 1. Resolve identifier to entity
|
||||
entity = await self.link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
||||
|
||||
current_path = entity.file_path
|
||||
old_permalink = entity.permalink
|
||||
|
||||
# 2. Validate destination path format first
|
||||
if not destination_path or destination_path.startswith("/") or not destination_path.strip():
|
||||
raise ValueError(f"Invalid destination path: {destination_path}")
|
||||
|
||||
# 3. Validate paths
|
||||
source_file = project_config.home / current_path
|
||||
destination_file = project_config.home / destination_path
|
||||
|
||||
# Validate source exists
|
||||
if not source_file.exists():
|
||||
raise ValueError(f"Source file not found: {current_path}")
|
||||
|
||||
# Check if destination already exists
|
||||
if destination_file.exists():
|
||||
raise ValueError(f"Destination already exists: {destination_path}")
|
||||
|
||||
try:
|
||||
# 4. Create destination directory if needed
|
||||
destination_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 5. Move physical file
|
||||
source_file.rename(destination_file)
|
||||
logger.info(f"Moved file: {current_path} -> {destination_path}")
|
||||
|
||||
# 6. Prepare database updates
|
||||
updates = {"file_path": destination_path}
|
||||
|
||||
# 7. Update permalink if configured
|
||||
if app_config.update_permalinks_on_move:
|
||||
# Generate new permalink from destination path
|
||||
new_permalink = await self.resolve_permalink(destination_path)
|
||||
|
||||
# Update frontmatter with new permalink
|
||||
await self.file_service.update_frontmatter(
|
||||
destination_path, {"permalink": new_permalink}
|
||||
)
|
||||
|
||||
updates["permalink"] = new_permalink
|
||||
logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
|
||||
|
||||
# 8. Recalculate checksum
|
||||
new_checksum = await self.file_service.compute_checksum(destination_path)
|
||||
updates["checksum"] = new_checksum
|
||||
|
||||
# 9. Update database
|
||||
updated_entity = await self.repository.update(entity.id, updates)
|
||||
if not updated_entity:
|
||||
raise ValueError(f"Failed to update entity in database: {entity.id}")
|
||||
|
||||
return updated_entity
|
||||
|
||||
except Exception as e:
|
||||
# Rollback: try to restore original file location if move succeeded
|
||||
if destination_file.exists() and not source_file.exists():
|
||||
try:
|
||||
destination_file.rename(source_file)
|
||||
logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
|
||||
except Exception as rollback_error: # pragma: no cover
|
||||
logger.error(f"Failed to rollback file move: {rollback_error}")
|
||||
|
||||
# Re-raise the original error with context
|
||||
raise ValueError(f"Move failed: {str(e)}") from e
|
||||
|
||||
@@ -94,8 +94,8 @@ class FileService:
|
||||
"""
|
||||
try:
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
logger.debug(f"Checking file existence: path={path_obj}")
|
||||
if path_obj.is_absolute():
|
||||
return path_obj.exists()
|
||||
else:
|
||||
@@ -121,7 +121,7 @@ class FileService:
|
||||
FileOperationError: If write fails
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
|
||||
try:
|
||||
@@ -140,7 +140,7 @@ class FileService:
|
||||
|
||||
# Compute and return checksum
|
||||
checksum = await file_utils.compute_checksum(content)
|
||||
logger.debug("File write completed", path=str(full_path), checksum=checksum)
|
||||
logger.debug(f"File write completed path={full_path}, {checksum=}")
|
||||
return checksum
|
||||
|
||||
except Exception as e:
|
||||
@@ -164,7 +164,7 @@ class FileService:
|
||||
FileOperationError: If read fails
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
|
||||
try:
|
||||
@@ -194,7 +194,7 @@ class FileService:
|
||||
path: Path to delete (Path or string)
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
full_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -210,7 +210,7 @@ class FileService:
|
||||
Checksum of updated file
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
return await file_utils.update_frontmatter(full_path, updates)
|
||||
|
||||
@@ -227,7 +227,7 @@ class FileService:
|
||||
FileError: If checksum computation fails
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
|
||||
try:
|
||||
@@ -253,7 +253,7 @@ class FileService:
|
||||
File statistics
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
# get file timestamps
|
||||
return full_path.stat()
|
||||
@@ -268,7 +268,7 @@ class FileService:
|
||||
MIME type of the file
|
||||
"""
|
||||
# Convert string to Path if needed
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
path_obj = self.base_path / path if isinstance(path, str) else path
|
||||
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
||||
# get file timestamps
|
||||
mime_type, _ = mimetypes.guess_type(full_path.name)
|
||||
|
||||
@@ -15,10 +15,10 @@ class LinkResolver:
|
||||
|
||||
Uses a combination of exact matching and search-based resolution:
|
||||
1. Try exact permalink match (fastest)
|
||||
2. Try permalink pattern match (for wildcards)
|
||||
3. Try exact title match
|
||||
4. Fall back to search for fuzzy matching
|
||||
5. Generate new permalink if no match found
|
||||
2. Try exact title match
|
||||
3. Try exact file path match
|
||||
4. Try file path with .md extension (for folder/title patterns)
|
||||
5. Fall back to search for fuzzy matching
|
||||
"""
|
||||
|
||||
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
|
||||
@@ -52,11 +52,19 @@ class LinkResolver:
|
||||
logger.debug(f"Found entity with path: {found_path.file_path}")
|
||||
return found_path
|
||||
|
||||
# 4. Try file path with .md extension if not already present
|
||||
if not clean_text.endswith(".md") and "/" in clean_text:
|
||||
file_path_with_md = f"{clean_text}.md"
|
||||
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
|
||||
if found_path_md:
|
||||
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
||||
return found_path_md
|
||||
|
||||
# search if indicated
|
||||
if use_search and "*" not in clean_text:
|
||||
# 3. Fall back to search for fuzzy matching on title
|
||||
# 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
|
||||
if results:
|
||||
|
||||
@@ -4,12 +4,13 @@ import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, Sequence
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory.config import ConfigManager, config, app_config
|
||||
from basic_memory.config import config, app_config
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.schemas import (
|
||||
ActivityMetrics,
|
||||
@@ -18,15 +19,17 @@ from basic_memory.schemas import (
|
||||
SystemStatus,
|
||||
)
|
||||
from basic_memory.config import WATCH_STATUS_JSON
|
||||
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.config import config_manager
|
||||
|
||||
class ProjectService:
|
||||
"""Service for managing Basic Memory projects."""
|
||||
|
||||
def __init__(self, repository: Optional[ProjectRepository] = None):
|
||||
repository: ProjectRepository
|
||||
|
||||
def __init__(self, repository: ProjectRepository):
|
||||
"""Initialize the project service."""
|
||||
super().__init__()
|
||||
self.config_manager = ConfigManager()
|
||||
self.repository = repository
|
||||
|
||||
@property
|
||||
@@ -36,7 +39,7 @@ class ProjectService:
|
||||
Returns:
|
||||
Dict mapping project names to their file paths
|
||||
"""
|
||||
return self.config_manager.projects
|
||||
return config_manager.projects
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
@@ -45,7 +48,7 @@ class ProjectService:
|
||||
Returns:
|
||||
The name of the default project
|
||||
"""
|
||||
return self.config_manager.default_project
|
||||
return config_manager.default_project
|
||||
|
||||
@property
|
||||
def current_project(self) -> str:
|
||||
@@ -54,7 +57,14 @@ class ProjectService:
|
||||
Returns:
|
||||
The name of the current project
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
|
||||
|
||||
async def list_projects(self) -> Sequence[Project]:
|
||||
return await self.repository.find_all()
|
||||
|
||||
async def get_project(self, name: str) -> Optional[Project]:
|
||||
"""Get the file path for a project by name."""
|
||||
return await self.repository.get_by_name(name)
|
||||
|
||||
async def add_project(self, name: str, path: str) -> None:
|
||||
"""Add a new project to the configuration and database.
|
||||
@@ -73,13 +83,13 @@ class ProjectService:
|
||||
resolved_path = os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
# First add to config file (this will validate the project doesn't exist)
|
||||
self.config_manager.add_project(name, resolved_path)
|
||||
project_config = config_manager.add_project(name, resolved_path)
|
||||
|
||||
# Then add to database
|
||||
project_data = {
|
||||
"name": name,
|
||||
"path": resolved_path,
|
||||
"permalink": name.lower().replace(" ", "-"),
|
||||
"permalink": generate_permalink(project_config.name),
|
||||
"is_active": True,
|
||||
"is_default": False,
|
||||
}
|
||||
@@ -100,7 +110,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for remove_project")
|
||||
|
||||
# First remove from config (this will validate the project exists and is not default)
|
||||
self.config_manager.remove_project(name)
|
||||
config_manager.remove_project(name)
|
||||
|
||||
# Then remove from database
|
||||
project = await self.repository.get_by_name(name)
|
||||
@@ -122,7 +132,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for set_default_project")
|
||||
|
||||
# First update config file (this will validate the project exists)
|
||||
self.config_manager.set_default_project(name)
|
||||
config_manager.set_default_project(name)
|
||||
|
||||
# Then update database
|
||||
project = await self.repository.get_by_name(name)
|
||||
@@ -150,7 +160,7 @@ class ProjectService:
|
||||
db_projects_by_name = {p.name: p for p in db_projects}
|
||||
|
||||
# Get all projects from configuration
|
||||
config_projects = self.config_manager.projects
|
||||
config_projects = config_manager.projects
|
||||
|
||||
# Add projects that exist in config but not in DB
|
||||
for name, path in config_projects.items():
|
||||
@@ -161,7 +171,7 @@ class ProjectService:
|
||||
"path": path,
|
||||
"permalink": name.lower().replace(" ", "-"),
|
||||
"is_active": True,
|
||||
"is_default": (name == self.config_manager.default_project),
|
||||
"is_default": (name == config_manager.default_project),
|
||||
}
|
||||
await self.repository.create(project_data)
|
||||
|
||||
@@ -169,16 +179,16 @@ class ProjectService:
|
||||
for name, project in db_projects_by_name.items():
|
||||
if name not in config_projects:
|
||||
logger.info(f"Adding project '{name}' to configuration")
|
||||
self.config_manager.add_project(name, project.path)
|
||||
config_manager.add_project(name, project.path)
|
||||
|
||||
# Make sure default project is synchronized
|
||||
db_default = next((p for p in db_projects if p.is_default), None)
|
||||
config_default = self.config_manager.default_project
|
||||
config_default = config_manager.default_project
|
||||
|
||||
if db_default and db_default.name != config_default:
|
||||
# Update config to match DB default
|
||||
logger.info(f"Updating default project in config to '{db_default.name}'")
|
||||
self.config_manager.set_default_project(db_default.name)
|
||||
config_manager.set_default_project(db_default.name)
|
||||
elif not db_default and config_default in db_projects_by_name:
|
||||
# Update DB to match config default
|
||||
logger.info(f"Updating default project in database to '{config_default}'")
|
||||
@@ -204,7 +214,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for update_project")
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in self.config_manager.projects:
|
||||
if name not in config_manager.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
# Get project from database
|
||||
@@ -218,10 +228,10 @@ class ProjectService:
|
||||
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
|
||||
|
||||
# Update in config
|
||||
projects = self.config_manager.config.projects.copy()
|
||||
projects = config_manager.config.projects.copy()
|
||||
projects[name] = resolved_path
|
||||
self.config_manager.config.projects = projects
|
||||
self.config_manager.save_config(self.config_manager.config)
|
||||
config_manager.config.projects = projects
|
||||
config_manager.save_config(config_manager.config)
|
||||
|
||||
# Update in database
|
||||
project.path = resolved_path
|
||||
@@ -242,7 +252,7 @@ class ProjectService:
|
||||
if active_projects:
|
||||
new_default = active_projects[0]
|
||||
await self.repository.set_as_default(new_default.id)
|
||||
self.config_manager.set_default_project(new_default.name)
|
||||
config_manager.set_default_project(new_default.name)
|
||||
logger.info(
|
||||
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
|
||||
)
|
||||
@@ -274,11 +284,11 @@ class ProjectService:
|
||||
db_projects_by_name = {p.name: p for p in db_projects}
|
||||
|
||||
# Get default project info
|
||||
default_project = self.config_manager.default_project
|
||||
default_project = config_manager.default_project
|
||||
|
||||
# Convert config projects to include database info
|
||||
enhanced_projects = {}
|
||||
for name, path in self.config_manager.projects.items():
|
||||
for name, path in config_manager.projects.items():
|
||||
db_project = db_projects_by_name.get(name)
|
||||
enhanced_projects[name] = {
|
||||
"path": path,
|
||||
@@ -535,4 +545,4 @@ class ProjectService:
|
||||
database_size=db_size_readable,
|
||||
watch_status=watch_status,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Service for search operations."""
|
||||
|
||||
import ast
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Set
|
||||
|
||||
@@ -117,6 +118,38 @@ class SearchService:
|
||||
|
||||
return variants
|
||||
|
||||
def _extract_entity_tags(self, entity: Entity) -> List[str]:
|
||||
"""Extract tags from entity metadata for search indexing.
|
||||
|
||||
Handles multiple tag formats:
|
||||
- List format: ["tag1", "tag2"]
|
||||
- String format: "['tag1', 'tag2']" or "[tag1, tag2]"
|
||||
- Empty: [] or "[]"
|
||||
|
||||
Returns a list of tag strings for search indexing.
|
||||
"""
|
||||
if not entity.entity_metadata or "tags" not in entity.entity_metadata:
|
||||
return []
|
||||
|
||||
tags = entity.entity_metadata["tags"]
|
||||
|
||||
# Handle list format (preferred)
|
||||
if isinstance(tags, list):
|
||||
return [str(tag) for tag in tags if tag]
|
||||
|
||||
# Handle string format (legacy)
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
# Parse string representation of list
|
||||
parsed_tags = ast.literal_eval(tags)
|
||||
if isinstance(parsed_tags, list):
|
||||
return [str(tag) for tag in parsed_tags if tag]
|
||||
except (ValueError, SyntaxError):
|
||||
# If parsing fails, treat as single tag
|
||||
return [tags] if tags.strip() else []
|
||||
|
||||
return [] # pragma: no cover
|
||||
|
||||
async def index_entity(
|
||||
self,
|
||||
entity: Entity,
|
||||
@@ -201,6 +234,11 @@ class SearchService:
|
||||
|
||||
content_stems.extend(self._generate_variants(entity.file_path))
|
||||
|
||||
# Add entity tags from frontmatter to search content
|
||||
entity_tags = self._extract_entity_tags(entity)
|
||||
if entity_tags:
|
||||
content_stems.extend(entity_tags)
|
||||
|
||||
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
|
||||
|
||||
# Index entity
|
||||
@@ -286,3 +324,32 @@ class SearchService:
|
||||
async def delete_by_entity_id(self, entity_id: int):
|
||||
"""Delete an item from the search index."""
|
||||
await self.repository.delete_by_entity_id(entity_id)
|
||||
|
||||
async def handle_delete(self, entity: Entity):
|
||||
"""Handle complete entity deletion from search index including observations and relations.
|
||||
|
||||
This replicates the logic from sync_service.handle_delete() to properly clean up
|
||||
all search index entries for an entity and its related data.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
|
||||
f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
|
||||
)
|
||||
|
||||
# Clean up search index - same logic as sync_service.handle_delete()
|
||||
permalinks = (
|
||||
[entity.permalink]
|
||||
+ [o.permalink for o in entity.observations]
|
||||
+ [r.permalink for r in entity.outgoing_relations]
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Deleting search index entries for entity_id={entity.id}, "
|
||||
f"index_entries={len(permalinks)}"
|
||||
)
|
||||
|
||||
for permalink in permalinks:
|
||||
if permalink:
|
||||
await self.delete_by_permalink(permalink)
|
||||
else:
|
||||
await self.delete_by_entity_id(entity.id)
|
||||
|
||||
Reference in New Issue
Block a user