Skip to content

Commit 617e60b

Browse files
authored
feat: permalink enhancements (#82)
- Avoiding "useless permalink values" for files without metadata - Enable permalinks to be updated on move via config setting
1 parent 6c19c9e commit 617e60b

19 files changed

Lines changed: 256 additions & 129 deletions

src/basic_memory/cli/commands/sync.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async def get_sync_service(): # pragma: no cover
7070

7171
# Create sync service
7272
sync_service = SyncService(
73+
config=config,
7374
entity_service=entity_service,
7475
entity_parser=entity_parser,
7576
entity_repository=entity_repository,

src/basic_memory/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class ProjectConfig(BaseSettings):
4040

4141
log_level: str = "DEBUG"
4242

43+
update_permalinks_on_move: bool = Field(
44+
default=False,
45+
description="Whether to update permalinks when files are moved or renamed. default (False)",
46+
)
47+
4348
model_config = SettingsConfigDict(
4449
env_prefix="BASIC_MEMORY_",
4550
extra="ignore",

src/basic_memory/file_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ def has_frontmatter(content: str) -> bool:
104104
Returns:
105105
True if content has valid frontmatter markers (---), False otherwise
106106
"""
107+
if not content:
108+
return False
109+
107110
content = content.strip()
108111
if not content.startswith("---"):
109112
return False

src/basic_memory/markdown/entity_parser.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,32 +92,36 @@ def parse_date(self, value: Any) -> Optional[datetime]:
9292
async def parse_file(self, path: Path | str) -> EntityMarkdown:
9393
"""Parse markdown file into EntityMarkdown."""
9494

95+
# TODO move to api endpoint to check if absolute path was requested
9596
# Check if the path is already absolute
96-
if isinstance(path, Path) and path.is_absolute() or (isinstance(path, str) and Path(path).is_absolute()):
97+
if (
98+
isinstance(path, Path)
99+
and path.is_absolute()
100+
or (isinstance(path, str) and Path(path).is_absolute())
101+
):
97102
absolute_path = Path(path)
98103
else:
99104
absolute_path = self.base_path / path
100-
105+
101106
# Parse frontmatter and content using python-frontmatter
102-
post = frontmatter.load(str(absolute_path))
107+
file_content = absolute_path.read_text()
108+
return await self.parse_file_content(absolute_path, file_content)
103109

110+
async def parse_file_content(self, absolute_path, file_content):
111+
post = frontmatter.loads(file_content)
104112
# Extract file stat info
105113
file_stats = absolute_path.stat()
106-
107114
metadata = post.metadata
108-
metadata["title"] = post.metadata.get("title", absolute_path.name)
115+
metadata["title"] = post.metadata.get("title", absolute_path.stem)
109116
metadata["type"] = post.metadata.get("type", "note")
110117
tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
111118
if tags:
112119
metadata["tags"] = tags
113-
114120
# frontmatter
115121
entity_frontmatter = EntityFrontmatter(
116122
metadata=post.metadata,
117123
)
118-
119124
entity_content = parse(post.content)
120-
121125
return EntityMarkdown(
122126
frontmatter=entity_frontmatter,
123127
content=post.content,

src/basic_memory/markdown/utils.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""Utilities for converting between markdown and entity models."""
22

33
from pathlib import Path
4-
from typing import Optional, Any
4+
from typing import Any, Optional
55

66
from frontmatter import Post
77

88
from basic_memory.file_utils import has_frontmatter, remove_frontmatter
99
from basic_memory.markdown import EntityMarkdown
10-
from basic_memory.models import Entity, Observation as ObservationModel
11-
from basic_memory.utils import generate_permalink
10+
from basic_memory.models import Entity
11+
from basic_memory.models import Observation as ObservationModel
1212

1313

1414
def entity_model_from_markdown(
@@ -32,16 +32,13 @@ def entity_model_from_markdown(
3232
if not markdown.created or not markdown.modified: # pragma: no cover
3333
raise ValueError("Both created and modified dates are required in markdown")
3434

35-
# Generate permalink if not provided
36-
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
37-
3835
# Create or update entity
3936
model = entity or Entity()
4037

4138
# Update basic fields
4239
model.title = markdown.frontmatter.title
4340
model.entity_type = markdown.frontmatter.type
44-
model.permalink = permalink
41+
model.permalink = markdown.frontmatter.permalink
4542
model.file_path = str(file_path)
4643
model.content_type = "text/markdown"
4744
model.created_at = markdown.created
@@ -87,12 +84,17 @@ async def schema_to_markdown(schema: Any) -> Post:
8784
for field in ["type", "title", "permalink"]:
8885
frontmatter_metadata.pop(field, None)
8986

90-
# Create Post with ordered fields
87+
# Create Post with fields ordered by insert order
9188
post = Post(
9289
content,
9390
title=schema.title,
9491
type=schema.entity_type,
95-
permalink=schema.permalink,
96-
**frontmatter_metadata,
9792
)
93+
# set the permalink if passed in
94+
if schema.permalink:
95+
post.metadata["permalink"] = schema.permalink
96+
97+
if frontmatter_metadata:
98+
post.metadata.update(frontmatter_metadata)
99+
98100
return post

src/basic_memory/mcp/tools/write_note.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ async def write_note(
8888
# Format semantic summary based on status code
8989
action = "Created" if response.status_code == 201 else "Updated"
9090
summary = [
91-
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
91+
f"# {action} note",
92+
f"file_path: {result.file_path}",
9293
f"permalink: {result.permalink}",
94+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
9395
]
9496

9597
# Count observations by category

src/basic_memory/services/entity_service.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
"""Service for managing entities in the database."""
22

33
from pathlib import Path
4-
from typing import Sequence, List, Optional, Tuple, Union
4+
from typing import List, Optional, Sequence, Tuple, Union
55

66
import frontmatter
77
from loguru import logger
88
from sqlalchemy.exc import IntegrityError
99

1010
from basic_memory.markdown import EntityMarkdown
11+
from basic_memory.markdown.entity_parser import EntityParser
1112
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
12-
from basic_memory.models import Entity as EntityModel, Observation, Relation
13+
from basic_memory.models import Entity as EntityModel
14+
from basic_memory.models import Observation, Relation
1315
from basic_memory.repository import ObservationRepository, RelationRepository
1416
from basic_memory.repository.entity_repository import EntityRepository
1517
from basic_memory.schemas import Entity as EntitySchema
1618
from basic_memory.schemas.base import Permalink
17-
from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
18-
from basic_memory.services import FileService
19-
from basic_memory.services import BaseService
19+
from basic_memory.services import BaseService, FileService
20+
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
2021
from basic_memory.services.link_resolver import LinkResolver
21-
from basic_memory.markdown.entity_parser import EntityParser
2222
from basic_memory.utils import generate_permalink
2323

2424

@@ -89,7 +89,7 @@ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityMod
8989
logger.debug(f"Creating or updating entity: {schema}")
9090

9191
# Try to find existing entity using smart resolution
92-
existing = await self.link_resolver.resolve_link(schema.permalink)
92+
existing = await self.link_resolver.resolve_link(schema.permalink or schema.file_path)
9393

9494
if existing:
9595
logger.debug(f"Found existing entity: {existing.permalink}")
@@ -100,7 +100,7 @@ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityMod
100100

101101
async def create_entity(self, schema: EntitySchema) -> EntityModel:
102102
"""Create a new entity and write to filesystem."""
103-
logger.debug(f"Creating entity: {schema.permalink}")
103+
logger.debug(f"Creating entity: {schema.title}")
104104

105105
# Get file path and ensure it's a Path object
106106
file_path = Path(schema.file_path)
@@ -230,7 +230,7 @@ async def create_entity_from_markdown(
230230
Creates the entity with null checksum to indicate sync not complete.
231231
Relations will be added in second pass.
232232
"""
233-
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
233+
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
234234
model = entity_model_from_markdown(file_path, markdown)
235235

236236
# Mark as incomplete because we still need to add relations

src/basic_memory/services/search_service.py

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,6 @@ async def index_entity_markdown(
181181
Each type gets its own row in the search index with appropriate metadata.
182182
"""
183183

184-
if entity.permalink is None: # pragma: no cover
185-
logger.error(
186-
"Missing permalink for markdown entity",
187-
entity_id=entity.id,
188-
title=entity.title,
189-
file_path=entity.file_path,
190-
)
191-
raise ValueError(
192-
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
193-
)
194-
195184
content_stems = []
196185
content_snippet = ""
197186
title_variants = self._generate_variants(entity.title)
@@ -202,22 +191,13 @@ async def index_entity_markdown(
202191
content_stems.append(content)
203192
content_snippet = f"{content[:250]}"
204193

205-
content_stems.extend(self._generate_variants(entity.permalink))
194+
if entity.permalink:
195+
content_stems.extend(self._generate_variants(entity.permalink))
196+
206197
content_stems.extend(self._generate_variants(entity.file_path))
207198

208199
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
209200

210-
if entity.permalink is None: # pragma: no cover
211-
logger.error(
212-
"Missing permalink for markdown entity",
213-
entity_id=entity.id,
214-
title=entity.title,
215-
file_path=entity.file_path,
216-
)
217-
raise ValueError(
218-
f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
219-
)
220-
221201
# Index entity
222202
await self.repository.index_item(
223203
SearchIndexRow(

src/basic_memory/sync/sync_service.py

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from loguru import logger
1212
from sqlalchemy.exc import IntegrityError
1313

14+
from basic_memory.config import ProjectConfig
15+
from basic_memory.file_utils import has_frontmatter
1416
from basic_memory.markdown import EntityParser
1517
from basic_memory.models import Entity
1618
from basic_memory.repository import EntityRepository, RelationRepository
@@ -65,13 +67,15 @@ class SyncService:
6567

6668
def __init__(
6769
self,
70+
config: ProjectConfig,
6871
entity_service: EntityService,
6972
entity_parser: EntityParser,
7073
entity_repository: EntityRepository,
7174
relation_repository: RelationRepository,
7275
search_service: SearchService,
7376
file_service: FileService,
7477
):
78+
self.config = config
7579
self.entity_service = entity_service
7680
self.entity_parser = entity_parser
7781
self.entity_repository = entity_repository
@@ -327,36 +331,40 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
327331
"""
328332
# Parse markdown first to get any existing permalink
329333
logger.debug("Parsing markdown file", path=path)
330-
entity_markdown = await self.entity_parser.parse_file(path)
331334

332-
# Resolve permalink - this handles all the cases including conflicts
333-
permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown)
335+
file_path = self.entity_parser.base_path / path
336+
file_content = file_path.read_text()
337+
file_contains_frontmatter = has_frontmatter(file_content)
334338

335-
# If permalink changed, update the file
336-
if permalink != entity_markdown.frontmatter.permalink:
337-
logger.info(
338-
"Updating permalink",
339-
path=path,
340-
old_permalink=entity_markdown.frontmatter.permalink,
341-
new_permalink=permalink,
342-
)
339+
# entity markdown will always contain front matter, so it can be used up create/update the entity
340+
entity_markdown = await self.entity_parser.parse_file(path)
343341

344-
entity_markdown.frontmatter.metadata["permalink"] = permalink
345-
checksum = await self.file_service.update_frontmatter(path, {"permalink": permalink})
346-
else:
347-
checksum = await self.file_service.compute_checksum(path)
342+
# if the file contains frontmatter, resolve a permalink
343+
if file_contains_frontmatter:
344+
# Resolve permalink - this handles all the cases including conflicts
345+
permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown)
346+
347+
# If permalink changed, update the file
348+
if permalink != entity_markdown.frontmatter.permalink:
349+
logger.info(
350+
"Updating permalink",
351+
path=path,
352+
old_permalink=entity_markdown.frontmatter.permalink,
353+
new_permalink=permalink,
354+
)
355+
356+
entity_markdown.frontmatter.metadata["permalink"] = permalink
357+
await self.file_service.update_frontmatter(path, {"permalink": permalink})
348358

349359
# if the file is new, create an entity
350360
if new:
351361
# Create entity with final permalink
352-
logger.debug("Creating new entity from markdown", path=path, permalink=permalink)
353-
362+
logger.debug("Creating new entity from markdown", path=path)
354363
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
355364

356365
# otherwise we need to update the entity and observations
357366
else:
358-
logger.debug("Updating entity from markdown", path=path, permalink=permalink)
359-
367+
logger.debug("Updating entity from markdown", path=path)
360368
await self.entity_service.update_entity_and_observations(Path(path), entity_markdown)
361369

362370
# Update relations and search index
@@ -366,10 +374,10 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
366374
# This is necessary for files with wikilinks to ensure consistent checksums
367375
# after relation processing is complete
368376
final_checksum = await self.file_service.compute_checksum(path)
369-
377+
370378
# set checksum
371379
await self.entity_repository.update(entity.id, {"checksum": final_checksum})
372-
380+
373381
logger.debug(
374382
"Markdown sync completed",
375383
path=path,
@@ -378,7 +386,7 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
378386
relation_count=len(entity.relations),
379387
checksum=final_checksum,
380388
)
381-
389+
382390
# Return the final checksum to ensure everything is consistent
383391
return entity, final_checksum
384392

@@ -475,8 +483,30 @@ async def handle_move(self, old_path, new_path):
475483

476484
entity = await self.entity_repository.get_by_file_path(old_path)
477485
if entity:
478-
# Update file_path but keep the same permalink for link stability
479-
updated = await self.entity_repository.update(entity.id, {"file_path": new_path})
486+
# Update file_path in all cases
487+
updates = {"file_path": new_path}
488+
489+
# If configured, also update permalink to match new path
490+
if self.config.update_permalinks_on_move:
491+
# generate new permalink value
492+
new_permalink = await self.entity_service.resolve_permalink(new_path)
493+
494+
# write to file and get new checksum
495+
new_checksum = await self.file_service.update_frontmatter(
496+
new_path, {"permalink": new_permalink}
497+
)
498+
499+
updates["permalink"] = new_permalink
500+
updates["checksum"] = new_checksum
501+
502+
logger.info(
503+
"Updating permalink on move",
504+
old_permalink=entity.permalink,
505+
new_permalink=new_permalink,
506+
new_checksum=new_checksum,
507+
)
508+
509+
updated = await self.entity_repository.update(entity.id, updates)
480510

481511
if updated is None: # pragma: no cover
482512
logger.error(

0 commit comments

Comments
 (0)