fix: fix permalink uniqueness violations on create/update/sync · basicmachines-co/basic-memory@135bec1 · GitHub
Skip to content

Commit 135bec1

Browse files
author
phernandez
committed
fix: fix permalink uniqueness violations on create/update/sync
1 parent c429d64 commit 135bec1

6 files changed

Lines changed: 199 additions & 11 deletions

File tree

src/basic_memory/schemas/base.py

Lines changed: 5 additions & 2 deletions

src/basic_memory/services/entity_service.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from basic_memory.services import BaseService
2020
from basic_memory.services.link_resolver import LinkResolver
2121
from basic_memory.markdown.entity_parser import EntityParser
22+
from basic_memory.utils import generate_permalink
2223

2324

2425
class EntityService(BaseService[EntityModel]):
@@ -40,6 +41,40 @@ def __init__(
4041
self.file_service = file_service
4142
self.link_resolver = link_resolver
4243

44+
async def resolve_permalink(
45+
self,
46+
file_path: Path,
47+
markdown: Optional[EntityMarkdown] = None
48+
) -> str:
49+
"""Get or generate unique permalink for an entity.
50+
51+
Priority:
52+
1. Use explicit permalink from markdown frontmatter if present
53+
2. For existing files, keep current permalink
54+
3. Generate new unique permalink for new files
55+
"""
56+
# If markdown has explicit permalink, try to use it
57+
if markdown and markdown.frontmatter.permalink:
58+
desired_permalink = markdown.frontmatter.permalink
59+
else:
60+
# For existing files, try to find current permalink
61+
existing = await self.repository.get_by_file_path(str(file_path))
62+
if existing:
63+
return existing.permalink
64+
65+
# New file - generate permalink
66+
desired_permalink = generate_permalink(file_path)
67+
68+
# Make unique if needed
69+
permalink = desired_permalink
70+
suffix = 1
71+
while await self.repository.get_by_permalink(permalink):
72+
permalink = f"{desired_permalink}-{suffix}"
73+
suffix += 1
74+
logger.debug(f"creating unique permalink: {permalink}")
75+
76+
return permalink
77+
4378
async def create_or_update_entity(self, schema: EntitySchema) -> (EntityModel, bool):
4479
"""Create new entity or update existing one.
4580
if a new entity is created, the return value is (entity, True)
@@ -66,9 +101,13 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel:
66101

67102
if await self.file_service.exists(file_path):
68103
raise EntityCreationError(
69-
f"file_path {file_path} for entity {schema.permalink} already exists: {file_path}"
104+
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
70105
)
71106

107+
# Get unique permalink
108+
permalink = await self.resolve_permalink(schema.permalink or file_path)
109+
schema._permalink = permalink
110+
72111
post = await schema_to_markdown(schema)
73112

74113
# write file
@@ -184,7 +223,7 @@ async def create_entity_from_markdown(
184223
Creates the entity with null checksum to indicate sync not complete.
185224
Relations will be added in second pass.
186225
"""
187-
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
226+
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
188227
model = entity_model_from_markdown(file_path, markdown)
189228

190229
# Mark as incomplete sync

src/basic_memory/services/file_service.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ def get_entity_path(self, entity: EntityModel| EntitySchema) -> Path:
3535
"""Generate absolute filesystem path for entity."""
3636
return self.base_path / f"{entity.file_path}"
3737

38-
# TODO move to tests
3938
async def write_entity_file(
4039
self,
4140
entity: EntityModel,

src/basic_memory/sync/sync_service.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,28 @@ async def sync(self, directory: Path) -> SyncReport:
9292
# First pass: Create/update entities
9393
# entities will have a null checksum to indicate they are not complete
9494
for file_path, entity_markdown in parsed_entities.items():
95+
96+
# Get unique permalink and update markdown if needed
97+
permalink = await self.entity_service.resolve_permalink(
98+
file_path,
99+
markdown=entity_markdown
100+
)
101+
102+
if permalink != entity_markdown.frontmatter.permalink:
103+
# Permalink changed - update markdown and rewrite file
104+
entity_markdown.frontmatter.metadata["permalink"] = permalink
105+
106+
# update file
107+
logger.info(f"Adding permalink '{permalink}' to file: {file_path}")
108+
updated_checksum = await self.entity_service.file_service.markdown_processor.write_file(
109+
directory / file_path, entity_markdown)
110+
111+
# Update checksum in changes report since file was modified
112+
changes.checksums[file_path] = updated_checksum
113+
95114
# if the file is new, create an entity
96115
if file_path in changes.new:
116+
# Create entity with final permalink
97117
logger.debug(f"Creating new entity_markdown: {file_path}")
98118
await self.entity_service.create_entity_from_markdown(
99119
file_path, entity_markdown

tests/services/test_entity_service.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import yaml
77

88
from basic_memory.models import Entity as EntityModel
9+
from basic_memory.repository import EntityRepository
910
from basic_memory.schemas import Entity as EntitySchema
1011
from basic_memory.services import FileService
1112
from basic_memory.services.entity_service import EntityService
1213
from basic_memory.services.exceptions import EntityNotFoundError
13-
14+
from basic_memory.utils import generate_permalink
1415

1516

1617
@pytest.mark.asyncio
@@ -51,7 +52,36 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
5152
assert metadata["permalink"] == entity.permalink
5253
assert metadata["type"] == entity.entity_type
5354

55+
@pytest.mark.asyncio
56+
async def test_create_entity_unique_permalink(test_config, entity_service: EntityService, file_service: FileService, entity_repository: EntityRepository):
57+
"""Test successful entity creation."""
58+
entity_data = EntitySchema(
59+
title="Test Entity",
60+
folder="test",
61+
entity_type="test",
62+
)
5463

64+
entity = await entity_service.create_entity(entity_data)
65+
66+
# default permalink
67+
assert entity.permalink == generate_permalink(entity.file_path)
68+
69+
# move file
70+
file_path = file_service.get_entity_path(entity)
71+
file_path.rename(test_config.home / "new_path.md")
72+
await entity_repository.update(entity.id, {"file_path": "new_path.md"})
73+
74+
# create again
75+
entity2 = await entity_service.create_entity(entity_data)
76+
assert entity2.permalink == f"{entity.permalink}-1"
77+
78+
file_path = file_service.get_entity_path(entity2)
79+
file_content, _ = await file_service.read_file(file_path)
80+
_, frontmatter, doc_content = file_content.split("---", 2)
81+
metadata = yaml.safe_load(frontmatter)
82+
83+
# Verify frontmatter contents
84+
assert metadata["permalink"] == entity2.permalink
5585

5686
@pytest.mark.asyncio
5787
async def test_get_by_permalink(entity_service: EntityService):
@@ -440,4 +470,3 @@ async def test_update_with_content(
440470

441471

442472

443-
# TODO handle permalink conflicts

tests/sync/test_sync_service.py

Lines changed: 102 additions & 4 deletions

0 commit comments

Comments
 (0)