Skip to content

Commit 49c702f

Browse files
committed
feat: enable project-prefixed permalinks
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 312662f commit 49c702f

40 files changed

Lines changed: 991 additions & 349 deletions

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ def import_chatgpt(
6060
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
6161

6262
# Create importer and run import
63-
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
63+
importer = ChatGPTImporter(
64+
config.home, markdown_processor, file_service, project_name=config.name
65+
)
6466
with conversations_json.open("r", encoding="utf-8") as file:
6567
json_data = json.load(file)
6668
result = run_with_cleanup(importer.import_data(json_data, folder))

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def import_claude(
5757
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5858

5959
# Create the importer
60-
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
60+
importer = ClaudeConversationsImporter(
61+
config.home, markdown_processor, file_service, project_name=config.name
62+
)
6163

6264
# Process the file
6365
base_path = config.home / folder

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def import_projects(
5656
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5757

5858
# Create the importer
59-
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
59+
importer = ClaudeProjectsImporter(
60+
config.home, markdown_processor, file_service, project_name=config.name
61+
)
6062

6163
# Process the file
6264
base_path = config.home / base_folder if base_folder else config.home

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def memory_json(
5555
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5656

5757
# Create the importer
58-
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
58+
importer = MemoryJsonImporter(
59+
config.home, markdown_processor, file_service, project_name=config.name
60+
)
5961

6062
# Process the file
6163
base_path = config.home if not destination_folder else config.home / destination_folder

src/basic_memory/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ class BasicMemoryConfig(BaseSettings):
188188
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
189189
)
190190

191+
permalinks_include_project: bool = Field(
192+
default=True,
193+
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",
194+
)
195+
191196
skip_initialization_sync: bool = Field(
192197
default=False,
193198
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",

src/basic_memory/deps/importers.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ async def get_chatgpt_importer(
4141
file_service: FileServiceDep,
4242
) -> ChatGPTImporter:
4343
"""Create ChatGPTImporter with dependencies."""
44-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
44+
return ChatGPTImporter(
45+
project_config.home,
46+
markdown_processor,
47+
file_service,
48+
project_name=project_config.name,
49+
)
4550

4651

4752
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
@@ -53,7 +58,12 @@ async def get_chatgpt_importer_v2( # pragma: no cover
5358
file_service: FileServiceV2Dep,
5459
) -> ChatGPTImporter:
5560
"""Create ChatGPTImporter with v2 dependencies."""
56-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
61+
return ChatGPTImporter(
62+
project_config.home,
63+
markdown_processor,
64+
file_service,
65+
project_name=project_config.name,
66+
)
5767

5868

5969
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
@@ -65,7 +75,12 @@ async def get_chatgpt_importer_v2_external(
6575
file_service: FileServiceV2ExternalDep,
6676
) -> ChatGPTImporter:
6777
"""Create ChatGPTImporter with v2 external_id dependencies."""
68-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
78+
return ChatGPTImporter(
79+
project_config.home,
80+
markdown_processor,
81+
file_service,
82+
project_name=project_config.name,
83+
)
6984

7085

7186
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
@@ -80,7 +95,12 @@ async def get_claude_conversations_importer(
8095
file_service: FileServiceDep,
8196
) -> ClaudeConversationsImporter:
8297
"""Create ClaudeConversationsImporter with dependencies."""
83-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
98+
return ClaudeConversationsImporter(
99+
project_config.home,
100+
markdown_processor,
101+
file_service,
102+
project_name=project_config.name,
103+
)
84104

85105

86106
ClaudeConversationsImporterDep = Annotated[
@@ -94,7 +114,12 @@ async def get_claude_conversations_importer_v2( # pragma: no cover
94114
file_service: FileServiceV2Dep,
95115
) -> ClaudeConversationsImporter:
96116
"""Create ClaudeConversationsImporter with v2 dependencies."""
97-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
117+
return ClaudeConversationsImporter(
118+
project_config.home,
119+
markdown_processor,
120+
file_service,
121+
project_name=project_config.name,
122+
)
98123

99124

100125
ClaudeConversationsImporterV2Dep = Annotated[
@@ -108,7 +133,12 @@ async def get_claude_conversations_importer_v2_external(
108133
file_service: FileServiceV2ExternalDep,
109134
) -> ClaudeConversationsImporter:
110135
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
111-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
136+
return ClaudeConversationsImporter(
137+
project_config.home,
138+
markdown_processor,
139+
file_service,
140+
project_name=project_config.name,
141+
)
112142

113143

114144
ClaudeConversationsImporterV2ExternalDep = Annotated[
@@ -125,7 +155,12 @@ async def get_claude_projects_importer(
125155
file_service: FileServiceDep,
126156
) -> ClaudeProjectsImporter:
127157
"""Create ClaudeProjectsImporter with dependencies."""
128-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
158+
return ClaudeProjectsImporter(
159+
project_config.home,
160+
markdown_processor,
161+
file_service,
162+
project_name=project_config.name,
163+
)
129164

130165

131166
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
@@ -137,7 +172,12 @@ async def get_claude_projects_importer_v2( # pragma: no cover
137172
file_service: FileServiceV2Dep,
138173
) -> ClaudeProjectsImporter:
139174
"""Create ClaudeProjectsImporter with v2 dependencies."""
140-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
175+
return ClaudeProjectsImporter(
176+
project_config.home,
177+
markdown_processor,
178+
file_service,
179+
project_name=project_config.name,
180+
)
141181

142182

143183
ClaudeProjectsImporterV2Dep = Annotated[
@@ -151,7 +191,12 @@ async def get_claude_projects_importer_v2_external(
151191
file_service: FileServiceV2ExternalDep,
152192
) -> ClaudeProjectsImporter:
153193
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
154-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
194+
return ClaudeProjectsImporter(
195+
project_config.home,
196+
markdown_processor,
197+
file_service,
198+
project_name=project_config.name,
199+
)
155200

156201

157202
ClaudeProjectsImporterV2ExternalDep = Annotated[
@@ -168,7 +213,12 @@ async def get_memory_json_importer(
168213
file_service: FileServiceDep,
169214
) -> MemoryJsonImporter:
170215
"""Create MemoryJsonImporter with dependencies."""
171-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
216+
return MemoryJsonImporter(
217+
project_config.home,
218+
markdown_processor,
219+
file_service,
220+
project_name=project_config.name,
221+
)
172222

173223

174224
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
@@ -180,7 +230,12 @@ async def get_memory_json_importer_v2( # pragma: no cover
180230
file_service: FileServiceV2Dep,
181231
) -> MemoryJsonImporter:
182232
"""Create MemoryJsonImporter with v2 dependencies."""
183-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
233+
return MemoryJsonImporter(
234+
project_config.home,
235+
markdown_processor,
236+
file_service,
237+
project_name=project_config.name,
238+
)
184239

185240

186241
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
@@ -192,7 +247,12 @@ async def get_memory_json_importer_v2_external(
192247
file_service: FileServiceV2ExternalDep,
193248
) -> MemoryJsonImporter:
194249
"""Create MemoryJsonImporter with v2 external_id dependencies."""
195-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
250+
return MemoryJsonImporter(
251+
project_config.home,
252+
markdown_processor,
253+
file_service,
254+
project_name=project_config.name,
255+
)
196256

197257

198258
MemoryJsonImporterV2ExternalDep = Annotated[

src/basic_memory/importers/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from basic_memory.markdown.markdown_processor import MarkdownProcessor
99
from basic_memory.markdown.schemas import EntityMarkdown
1010
from basic_memory.schemas.importer import ImportResult
11+
from basic_memory.utils import build_canonical_permalink, generate_permalink
1112

1213
if TYPE_CHECKING: # pragma: no cover
1314
from basic_memory.services.file_service import FileService
@@ -29,6 +30,7 @@ def __init__(
2930
base_path: Path,
3031
markdown_processor: MarkdownProcessor,
3132
file_service: "FileService",
33+
project_name: Optional[str] = None,
3234
):
3335
"""Initialize the import service.
3436
@@ -40,6 +42,8 @@ def __init__(
4042
self.base_path = base_path.resolve() # Get absolute path
4143
self.markdown_processor = markdown_processor
4244
self.file_service = file_service
45+
self.project_name = project_name
46+
self.project_permalink = generate_permalink(project_name) if project_name else None
4347

4448
@abstractmethod
4549
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
@@ -73,6 +77,26 @@ async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> s
7377
# FileService.write_file handles directory creation and returns checksum
7478
return await self.file_service.write_file(file_path, content)
7579

80+
def canonical_permalink(self, path: str) -> str:
81+
"""Build a canonical permalink for imported content."""
82+
include_project = True
83+
# Trigger: importer has app config with permalink prefixing flag
84+
# Why: imported notes should align with canonical permalink format
85+
# Outcome: include project prefix when enabled
86+
if self.file_service.app_config is not None:
87+
include_project = self.file_service.app_config.permalinks_include_project
88+
89+
return build_canonical_permalink(
90+
self.project_permalink,
91+
path,
92+
include_project=include_project,
93+
)
94+
95+
def build_import_paths(self, path: str) -> tuple[str, str]:
96+
"""Return (permalink, file_path) for an imported entity."""
97+
permalink = self.canonical_permalink(path)
98+
return permalink, f"{path}.md"
99+
76100
async def ensure_folder_exists(self, folder: str) -> None:
77101
"""Ensure folder exists using FileService.
78102

src/basic_memory/importers/chatgpt_importer.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,20 @@ async def import_data(
5151
chats_imported = 0
5252

5353
for chat in conversations:
54+
created_at = chat["create_time"]
55+
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
56+
clean_title = clean_filename(chat["title"])
57+
relative_path = (
58+
f"{destination_folder}/{date_prefix}-{clean_title}"
59+
if destination_folder
60+
else f"{date_prefix}-{clean_title}"
61+
)
62+
permalink, file_path = self.build_import_paths(relative_path)
63+
5464
# Convert to entity
55-
entity = self._format_chat_content(destination_folder, chat)
65+
entity = self._format_chat_content(chat, permalink)
5666

5767
# Write file using relative path - FileService handles base_path
58-
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
5968
await self.write_entity(entity, file_path)
6069

6170
# Count messages
@@ -83,7 +92,7 @@ async def import_data(
8392
return self.handle_error("Failed to import ChatGPT conversations", e)
8493

8594
def _format_chat_content(
86-
self, folder: str, conversation: Dict[str, Any]
95+
self, conversation: Dict[str, Any], permalink: str
8796
) -> EntityMarkdown: # pragma: no cover
8897
"""Convert chat conversation to Basic Memory entity.
8998
@@ -105,10 +114,6 @@ def _format_chat_content(
105114
root_id = node_id
106115
break
107116

108-
# Generate permalink
109-
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
110-
clean_title = clean_filename(conversation["title"])
111-
112117
# Format content
113118
content = self._format_chat_markdown(
114119
title=conversation["title"],
@@ -126,7 +131,7 @@ def _format_chat_content(
126131
"title": conversation["title"],
127132
"created": format_timestamp(created_at),
128133
"modified": format_timestamp(modified_at),
129-
"permalink": f"{folder}/{date_prefix}-{clean_title}",
134+
"permalink": permalink,
130135
}
131136
),
132137
content=content,

src/basic_memory/importers/claude_conversations_importer.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,27 @@ async def import_data(
5454
for chat in conversations:
5555
# Get name, providing default for unnamed conversations
5656
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
57+
date_prefix = datetime.fromisoformat(chat["created_at"].replace("Z", "+00:00")).strftime(
58+
"%Y%m%d"
59+
)
60+
clean_title = clean_filename(chat_name)
61+
relative_path = (
62+
f"{destination_folder}/{date_prefix}-{clean_title}"
63+
if destination_folder
64+
else f"{date_prefix}-{clean_title}"
65+
)
66+
permalink, file_path = self.build_import_paths(relative_path)
5767

5868
# Convert to entity
5969
entity = self._format_chat_content(
60-
folder=destination_folder,
6170
name=chat_name,
6271
messages=chat["chat_messages"],
6372
created_at=chat["created_at"],
6473
modified_at=chat["updated_at"],
74+
permalink=permalink,
6575
)
6676

6777
# Write file using relative path - FileService handles base_path
68-
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
6978
await self.write_entity(entity, file_path)
7079

7180
chats_imported += 1
@@ -84,11 +93,11 @@ async def import_data(
8493

8594
def _format_chat_content(
8695
self,
87-
folder: str,
8896
name: str,
8997
messages: List[Dict[str, Any]],
9098
created_at: str,
9199
modified_at: str,
100+
permalink: str,
92101
) -> EntityMarkdown:
93102
"""Convert chat messages to Basic Memory entity format.
94103
@@ -102,11 +111,6 @@ def _format_chat_content(
102111
Returns:
103112
EntityMarkdown instance representing the conversation.
104113
"""
105-
# Generate permalink using folder name (relative path)
106-
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
107-
clean_title = clean_filename(name)
108-
permalink = f"{folder}/{date_prefix}-{clean_title}"
109-
110114
# Format content
111115
content = self._format_chat_markdown(
112116
name=name,

0 commit comments

Comments
 (0)