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:
Paul Hernandez
2025-06-03 01:00:40 -05:00
committed by GitHub
parent 5ec4087f7c
commit 634486107b
116 changed files with 13122 additions and 2147 deletions
+79 -1
View File
@@ -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
)
+377 -2
View File
@@ -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
+10 -10
View File
@@ -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)
+14 -6
View File
@@ -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:
+35 -25
View File
@@ -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)