Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] c919576cce fix: Add underscore folder resolution to read_note tool
Fixes #416 where read_note fails to find notes when given permalinks
from underscored folder names (e.g., _archive/, _drafts/).

The issue occurs because permalink generation strips underscores from
folder names, but the link resolver wasn't trying underscore variants
when looking up files.

Changes:
- Added _generate_underscored_variants() method to LinkResolver
- Integrated variant lookup into resolve_link() resolution chain
- Added comprehensive tests for underscored folder resolution

The fix tries multiple file path variants with underscores prepended
to different segments, allowing read_note to find files in folders
like _archive/, _drafts/, etc. when given their normalized permalinks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 22:49:51 +00:00
2 changed files with 145 additions and 1 deletions
+57 -1
View File
@@ -69,11 +69,26 @@ class LinkResolver:
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md
# 5. Try permalink variations with underscored folders (fixes #416)
# This handles cases where the permalink was generated from a path like
# "_archive/note.md" which becomes "archive/note" in the permalink,
# but the user provides "archive/note" and we need to find the original file
underscored_variants = self._generate_underscored_variants(clean_text)
for variant in underscored_variants:
logger.trace(f"Trying underscored variant: {variant}")
# Try as file path with .md extension
variant_with_md = f"{variant}.md"
found_variant = await self.entity_repository.get_by_file_path(variant_with_md)
if found_variant:
logger.debug(f"Found entity with underscored path variant: {found_variant.file_path}")
return found_variant
# In strict mode, don't try fuzzy search - return None if no exact match found
if strict:
return None
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
# 6. Fall back to search for fuzzy matching (only if not in strict mode)
if use_search and "*" not in clean_text:
results = await self.search_service.search(
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
@@ -91,6 +106,47 @@ class LinkResolver:
# if we couldn't find anything then return None
return None
def _generate_underscored_variants(self, path: str) -> list[str]:
"""Generate permalink variants with underscored folder prefixes.
This handles cases where folder names start with underscores (e.g., _archive, _drafts)
but the permalink has them stripped. We generate variants to try and find the original file.
Args:
path: The permalink path (e.g., "archive/articles/example-note")
Returns:
List of path variants with underscores prepended to various segments
(e.g., ["_archive/articles/example-note", "archive/_articles/example-note", etc.])
"""
if "/" not in path:
# Single segment, just try with underscore prefix
return [f"_{path}"]
segments = path.split("/")
variants = []
# Generate variants by prepending underscore to each segment
# For path "archive/articles/note", generate:
# - "_archive/articles/note"
# - "archive/_articles/note"
# - "_archive/_articles/note"
for i in range(len(segments)):
# Try prepending underscore to this segment
variant_segments = segments.copy()
variant_segments[i] = f"_{segments[i]}"
variants.append("/".join(variant_segments))
# For paths with 2+ folders, also try combinations of underscored first segments
if len(segments) >= 2:
# Try first two segments underscored
variant_segments = segments.copy()
variant_segments[0] = f"_{segments[0]}"
variant_segments[1] = f"_{segments[1]}"
variants.append("/".join(variant_segments))
return variants
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
"""Normalize link text and extract alias if present.
+88
View File
@@ -356,3 +356,91 @@ async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entit
assert result is not None
# Should return the first match (components/core-service based on test fixture order)
assert result.permalink == "components/core-service"
@pytest.mark.asyncio
async def test_underscored_folder_resolution(entity_service, entity_repository, search_service):
"""Test resolving permalinks from files in underscored folders.
This tests the fix for issue #416 where read_note fails to find notes
when given permalinks from underscored folder names (e.g., _archive/, _drafts/).
The permalink normalization strips underscores, but link resolution should
still be able to find the original files.
"""
# Create an entity in an underscored folder
entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Example Note",
entity_type="note",
folder="_archive/articles",
project=entity_service.repository.project_id,
)
)
# The entity's file path should have the underscore
assert entity.file_path == "_archive/articles/Example Note.md"
# The permalink should have the underscore stripped (normalized)
assert entity.permalink == "archive/articles/example-note"
# Index the entity for search
await search_service.index_entity(entity)
# Create link resolver
link_resolver = LinkResolver(entity_repository, search_service)
# Test 1: Using the normalized permalink (what the user would copy from YAML frontmatter)
# This should now work with our fix
result = await link_resolver.resolve_link("archive/articles/example-note")
assert result is not None
assert result.file_path == "_archive/articles/Example Note.md"
assert result.permalink == "archive/articles/example-note"
# Test 2: Using the title
result = await link_resolver.resolve_link("Example Note")
assert result is not None
assert result.file_path == "_archive/articles/Example Note.md"
# Test 3: Using the actual file path (with underscore)
result = await link_resolver.resolve_link("_archive/articles/Example Note.md")
assert result is not None
assert result.file_path == "_archive/articles/Example Note.md"
# Test 4: Multiple underscored folders
entity2, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Draft Post",
entity_type="note",
folder="_drafts/_private",
project=entity_service.repository.project_id,
)
)
await search_service.index_entity(entity2)
result = await link_resolver.resolve_link("drafts/private/draft-post")
assert result is not None
assert result.file_path == "_drafts/_private/Draft Post.md"
assert result.permalink == "drafts/private/draft-post"
@pytest.mark.asyncio
async def test_underscored_variants_generation(entity_repository, search_service):
"""Test the _generate_underscored_variants helper method."""
link_resolver = LinkResolver(entity_repository, search_service)
# Test single segment
variants = link_resolver._generate_underscored_variants("archive")
assert "_archive" in variants
# Test two segments
variants = link_resolver._generate_underscored_variants("archive/articles")
assert "_archive/articles" in variants
assert "archive/_articles" in variants
assert "_archive/_articles" in variants
# Test three segments
variants = link_resolver._generate_underscored_variants("archive/articles/note")
assert "_archive/articles/note" in variants
assert "archive/_articles/note" in variants
assert "archive/articles/_note" in variants
assert "_archive/_articles/note" in variants # First two underscored