Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/basic_memory/cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def add_project(
) -> None:
"""Add a new project."""
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(path))
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()

try:
data = {"name": name, "path": resolved_path, "set_default": set_default}
Expand Down Expand Up @@ -156,7 +156,7 @@ def move_project(
) -> None:
"""Move a project to a new location."""
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()

try:
data = {"path": resolved_path}
Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class BasicMemoryConfig(BaseSettings):

projects: Dict[str, str] = Field(
default_factory=lambda: {
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
},
description="Mapping of project names to their filesystem paths",
)
Expand Down Expand Up @@ -100,9 +100,9 @@ def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization."""
# Ensure main project exists
if "main" not in self.projects: # pragma: no cover
self.projects["main"] = str(
self.projects["main"] = (
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
)
).as_posix()

# Ensure default project is valid
if self.default_project not in self.projects: # pragma: no cover
Expand Down Expand Up @@ -215,7 +215,7 @@ def add_project(self, name: str, path: str) -> ProjectConfig:

# Load config, modify it, and save it
config = self.load_config()
config.projects[name] = str(project_path)
config.projects[name] = project_path.as_posix()
self.save_config(config)
return ProjectConfig(name=name, home=project_path)

Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/markdown/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def entity_model_from_markdown(
# Only update permalink if it exists in frontmatter, otherwise preserve existing
if markdown.frontmatter.permalink is not None:
model.permalink = markdown.frontmatter.permalink
model.file_path = str(file_path)
model.file_path = file_path.as_posix()
model.content_type = "text/markdown"
model.created_at = markdown.created
model.updated_at = markdown.modified
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/repository/entity_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity
"""
query = (
self.select()
.where(Entity.file_path == str(file_path))
.where(Entity.file_path == Path(file_path).as_posix())
.options(*self.get_load_options())
)
return await self.find_one(query)
Expand All @@ -68,7 +68,7 @@ async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
Args:
file_path: Path to the entity file (will be converted to string internally)
"""
return await self.delete_by_fields(file_path=str(file_path))
return await self.delete_by_fields(file_path=Path(file_path).as_posix())

def get_load_options(self) -> List[LoaderOption]:
"""Get SQLAlchemy loader options for eager loading relationships."""
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/repository/project_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]:
Args:
path: Path to the project directory (will be converted to string internally)
"""
query = self.select().where(Project.path == str(path))
query = self.select().where(Project.path == Path(path).as_posix())
return await self.find_one(query)

async def get_default_project(self) -> Optional[Project]:
Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/repository/search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from pathlib import Path

from loguru import logger
from sqlalchemy import Executable, Result, text
Expand Down Expand Up @@ -59,8 +60,11 @@ def directory(self) -> str:
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
return ""

# Normalize path separators to handle both Windows (\) and Unix (/) paths
normalized_path = Path(self.file_path).as_posix()

# Split the path by slashes
parts = self.file_path.split("/")
parts = normalized_path.split("/")

# If there's only one part (e.g., "README.md"), it's at the root
if len(parts) <= 1:
Expand Down
10 changes: 5 additions & 5 deletions src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def resolve_permalink(

Enhanced to detect and handle character-related conflicts.
"""
file_path_str = str(file_path)
file_path_str = Path(file_path).as_posix()

# Check for potential file path conflicts before resolving permalink
conflicts = await self.detect_file_path_conflicts(file_path_str)
Expand Down Expand Up @@ -119,7 +119,7 @@ async def resolve_permalink(
if markdown and markdown.frontmatter.permalink:
desired_permalink = markdown.frontmatter.permalink
else:
desired_permalink = generate_permalink(file_path)
desired_permalink = generate_permalink(file_path_str)

# Make unique if needed - enhanced to handle character conflicts
permalink = desired_permalink
Expand Down Expand Up @@ -283,7 +283,7 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti
entity = await self.update_entity_and_observations(file_path, entity_markdown)

# add relations
await self.update_entity_relations(str(file_path), entity_markdown)
await self.update_entity_relations(file_path.as_posix(), entity_markdown)

# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
Expand Down Expand Up @@ -374,7 +374,7 @@ async def update_entity_and_observations(
"""
logger.debug(f"Updating entity and observations: {file_path}")

db_entity = await self.repository.get_by_file_path(str(file_path))
db_entity = await self.repository.get_by_file_path(file_path.as_posix())

# Clear observations for entity
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
Expand Down Expand Up @@ -498,7 +498,7 @@ async def edit_entity(

# 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)
await self.update_entity_relations(file_path.as_posix(), entity_markdown)

# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
Expand Down
6 changes: 3 additions & 3 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
raise ValueError("Repository is required for add_project")

# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(path))
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()

# First add to config file (this will validate the project doesn't exist)
project_config = self.config_manager.add_project(name, resolved_path)
Expand Down Expand Up @@ -323,7 +323,7 @@ async def move_project(self, name: str, new_path: str) -> None:
raise ValueError("Repository is required for move_project")

# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()

# Validate project exists in config
if name not in self.config_manager.projects:
Expand Down Expand Up @@ -378,7 +378,7 @@ async def update_project( # pragma: no cover

# Update path if provided
if updated_path:
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
resolved_path = Path(os.path.abspath(os.path.expanduser(updated_path))).as_posix()

# Update in config
config = self.config_manager.load_config()
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/sync/sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ async def scan_directory(self, directory: Path) -> ScanResult:
continue

path = Path(root) / filename
rel_path = str(path.relative_to(directory))
rel_path = path.relative_to(directory).as_posix()
checksum = await self.file_service.compute_checksum(rel_path)
result.files[rel_path] = checksum
result.checksums[checksum] = rel_path
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/sync/watch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> No

for change, path in changes:
# convert to relative path
relative_path = str(Path(path).relative_to(directory))
relative_path = Path(path).relative_to(directory).as_posix()

# Skip .tmp files - they're temporary and shouldn't be synced
if relative_path.endswith(".tmp"):
Expand Down
14 changes: 13 additions & 1 deletion src/basic_memory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
'中文/测试文档'
"""
# Convert Path to string if needed
path_str = str(file_path)
path_str = Path(str(file_path)).as_posix()

# Remove extension
base = os.path.splitext(path_str)[0]
Expand Down Expand Up @@ -219,6 +219,18 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
return []


def normalize_newlines(multiline: str) -> str:
"""Replace any \r\n, \r, or \n with the native newline.

Args:
multiline: String containing any mixture of newlines.

Returns:
A string with normalized newlines native to the platform.
"""
return re.sub(r'\r\n?|\n', os.linesep, multiline)


def normalize_file_path_for_comparison(file_path: str) -> str:
"""Normalize a file path for conflict detection.

Expand Down
5 changes: 3 additions & 2 deletions tests/api/test_knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
EntityResponse,
)
from basic_memory.schemas.search import SearchItemType, SearchResponse
from basic_memory.utils import normalize_newlines


@pytest.mark.asyncio
Expand Down Expand Up @@ -684,14 +685,14 @@ async def test_edit_entity_prepend(client: AsyncClient, project_url):
file_content = response.text

# Expected content with frontmatter preserved and content prepended to body
expected_content = """---
expected_content = normalize_newlines("""---
title: Test Note
type: note
permalink: test/test-note
---

Prepended content
Original content"""
Original content""")

assert file_content.strip() == expected_content.strip()

Expand Down
14 changes: 8 additions & 6 deletions tests/api/test_project_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ async def test_update_project_path_endpoint(
"""Test the update project endpoint for changing project path."""
# Create a test project to update
test_project_name = "test-update-project"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
old_path = (tmp_path / "old-location").as_posix()
new_path = (tmp_path / "new-location").as_posix()

await project_service.add_project(test_project_name, old_path)

Expand Down Expand Up @@ -256,8 +256,8 @@ async def test_update_project_both_params_endpoint(
"""Test the update project endpoint with both path and is_active parameters."""
# Create a test project to update
test_project_name = "test-update-both-project"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
old_path = (tmp_path / "old-location").as_posix()
new_path = (tmp_path / "new-location").as_posix()

await project_service.add_project(test_project_name, old_path)

Expand Down Expand Up @@ -344,7 +344,8 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
await project_service.add_project(test_project_name, test_path)
proj_info = await project_service.get_project(test_project_name)
assert proj_info.name == test_project_name
assert proj_info.path == test_path
# On Windows the path is prepended with a drive letter
assert test_path in proj_info.path

try:
# Try to update with no parameters
Expand All @@ -354,7 +355,8 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
assert response.status_code == 200
proj_info = await project_service.get_project(test_project_name)
assert proj_info.name == test_project_name
assert proj_info.path == test_path
# On Windows the path is prepended with a drive letter
assert test_path in proj_info.path

finally:
# Clean up
Expand Down
21 changes: 11 additions & 10 deletions tests/api/test_resource_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from basic_memory.schemas import EntityResponse
from basic_memory.utils import normalize_newlines


@pytest.mark.asyncio
Expand Down Expand Up @@ -35,7 +36,7 @@ async def test_get_resource_content(client, project_config, entity_repository, p
response = await client.get(f"{project_url}/resource/{entity.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert response.text == content
assert response.text == normalize_newlines(content)


@pytest.mark.asyncio
Expand Down Expand Up @@ -66,7 +67,7 @@ async def test_get_resource_pagination(client, project_config, entity_repository
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert response.text == content
assert response.text == normalize_newlines(content)


@pytest.mark.asyncio
Expand Down Expand Up @@ -148,7 +149,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
"""
normalize_newlines("""
---
title: Test Entity
type: test
Expand All @@ -158,7 +159,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor
# Test Content

- [note] an observation.
""".strip()
""".strip())
in response.text
)

Expand Down Expand Up @@ -196,7 +197,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
normalize_newlines(f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}

# Test Content
Expand All @@ -206,7 +207,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
# Related Content
- links to [[Test Entity]]

""".strip()
""".strip())
in response.text
)

Expand Down Expand Up @@ -249,7 +250,7 @@ async def test_get_resource_entities_pagination(
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
"""
normalize_newlines("""
---
title: Related Entity
type: test
Expand All @@ -258,7 +259,7 @@ async def test_get_resource_entities_pagination(

# Related Content
- links to [[Test Entity]]
""".strip()
""".strip())
in response.text
)

Expand Down Expand Up @@ -297,7 +298,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
normalize_newlines(f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}

# Test Content
Expand All @@ -307,7 +308,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
# Related Content
- links to [[Test Entity]]

""".strip()
""".strip())
in response.text
)

Expand Down
Loading