diff --git a/k-notes-frontend/src/components/note-view-dialog.tsx b/k-notes-frontend/src/components/note-view-dialog.tsx
index 0477a92..8408b74 100644
--- a/k-notes-frontend/src/components/note-view-dialog.tsx
+++ b/k-notes-frontend/src/components/note-view-dialog.tsx
@@ -8,6 +8,7 @@ import { Edit, Calendar, Pin } from "lucide-react";
import { getNoteColor } from "@/lib/constants";
import clsx from "clsx";
import remarkGfm from "remark-gfm";
+import { RelatedNotes } from "./related-notes";
interface NoteViewDialogProps {
@@ -15,9 +16,10 @@ interface NoteViewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: () => void;
+ onSelectNote?: (id: string) => void;
}
-export function NoteViewDialog({ note, open, onOpenChange, onEdit }: NoteViewDialogProps) {
+export function NoteViewDialog({ note, open, onOpenChange, onEdit, onSelectNote }: NoteViewDialogProps) {
const colorClass = getNoteColor(note.color);
return (
@@ -42,6 +44,17 @@ export function NoteViewDialog({ note, open, onOpenChange, onEdit }: NoteViewDia
{note.content}
+
+ {/* Smart Features: Related Notes */}
+
+ {
+ onOpenChange(false);
+ setTimeout(() => onSelectNote(id), 100); // Small delay to allow dialog close animation?
+ } : undefined}
+ />
+
diff --git a/k-notes-frontend/src/components/related-notes.tsx b/k-notes-frontend/src/components/related-notes.tsx
new file mode 100644
index 0000000..84acb87
--- /dev/null
+++ b/k-notes-frontend/src/components/related-notes.tsx
@@ -0,0 +1,62 @@
+import { useRelatedNotes } from "@/hooks/use-related-notes";
+import { useNotes } from "@/hooks/use-notes";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Link2 } from "lucide-react";
+
+interface RelatedNotesProps {
+ noteId: string;
+ onSelectNote?: (id: string) => void;
+}
+
+export function RelatedNotes({ noteId, onSelectNote }: RelatedNotesProps) {
+ const { relatedLinks, isRelatedLoading } = useRelatedNotes(noteId);
+ const { data: notes } = useNotes(); // We need to look up note titles from source_id
+
+ if (isRelatedLoading) {
+ return (
+
+
Related Notes
+
+
+
+
+
+ );
+ }
+
+ if (!relatedLinks || relatedLinks.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Related Notes
+
+
+ {relatedLinks.map((link) => {
+ const targetNote = notes?.find((n: any) => n.id === link.target_note_id);
+ if (!targetNote) return null;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/k-notes-frontend/src/hooks/use-related-notes.ts b/k-notes-frontend/src/hooks/use-related-notes.ts
new file mode 100644
index 0000000..637b0ab
--- /dev/null
+++ b/k-notes-frontend/src/hooks/use-related-notes.ts
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+
+export interface NoteLink {
+ source_note_id: string;
+ target_note_id: string;
+ score: number;
+ created_at: string;
+}
+
+export function useRelatedNotes(noteId: string | undefined) {
+ const { data, error, isLoading } = useQuery({
+ queryKey: ["notes", noteId, "related"],
+ queryFn: () => api.get(`/notes/${noteId}/related`),
+ enabled: !!noteId,
+ });
+
+ return {
+ relatedLinks: data as NoteLink[] | undefined,
+ isRelatedLoading: isLoading,
+ relatedError: error,
+ };
+}
diff --git a/k-notes-frontend/src/lib/constants.ts b/k-notes-frontend/src/lib/constants.ts
index 7dbf15b..80fc2b6 100644
--- a/k-notes-frontend/src/lib/constants.ts
+++ b/k-notes-frontend/src/lib/constants.ts
@@ -1,12 +1,12 @@
const NOTE_COLORS = [
{ name: "DEFAULT", value: "bg-background border-border", label: "Default" },
- { name: "RED", value: "bg-red-50 border-red-200 dark:bg-red-950/20 dark:border-red-900", label: "Red" },
- { name: "ORANGE", value: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-900", label: "Orange" },
- { name: "YELLOW", value: "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/20 dark:border-yellow-900", label: "Yellow" },
- { name: "GREEN", value: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-900", label: "Green" },
- { name: "TEAL", value: "bg-teal-50 border-teal-200 dark:bg-teal-950/20 dark:border-teal-900", label: "Teal" },
- { name: "BLUE", value: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-900", label: "Blue" },
- { name: "INDIGO", value: "bg-indigo-50 border-indigo-200 dark:bg-indigo-950/20 dark:border-indigo-900", label: "Indigo" },
+ { name: "RED", value: "bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-900", label: "Red" },
+ { name: "ORANGE", value: "bg-orange-50 border-orange-200 dark:bg-orange-950 dark:border-orange-900", label: "Orange" },
+ { name: "YELLOW", value: "bg-yellow-50 border-yellow-200 dark:bg-yellow-950 dark:border-yellow-900", label: "Yellow" },
+ { name: "GREEN", value: "bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900", label: "Green" },
+ { name: "TEAL", value: "bg-teal-50 border-teal-200 dark:bg-teal-950 dark:border-teal-900", label: "Teal" },
+ { name: "BLUE", value: "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-900", label: "Blue" },
+ { name: "INDIGO", value: "bg-indigo-50 border-indigo-200 dark:bg-indigo-950 dark:border-indigo-900", label: "Indigo" },
];
export function getNoteColor(colorName: string | undefined): string {
diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs
index f1fa9e8..0e31a23 100644
--- a/notes-api/src/dto.rs
+++ b/notes-api/src/dto.rs
@@ -172,3 +172,23 @@ impl From for NoteVersionResponse {
pub struct ConfigResponse {
pub allow_registration: bool,
}
+
+/// Note Link response DTO
+#[derive(Debug, Serialize)]
+pub struct NoteLinkResponse {
+ pub source_note_id: Uuid,
+ pub target_note_id: Uuid,
+ pub score: f32,
+ pub created_at: DateTime,
+}
+
+impl From for NoteLinkResponse {
+ fn from(link: notes_domain::entities::NoteLink) -> Self {
+ Self {
+ source_note_id: link.source_note_id,
+ target_note_id: link.target_note_id,
+ score: link.score,
+ created_at: link.created_at,
+ }
+ }
+}
diff --git a/notes-api/src/main.rs b/notes-api/src/main.rs
index f175b62..3955ec3 100644
--- a/notes-api/src/main.rs
+++ b/notes-api/src/main.rs
@@ -44,8 +44,8 @@ async fn main() -> anyhow::Result<()> {
let db_config = DatabaseConfig::new(&config.database_url);
use notes_infra::factory::{
- build_database_pool, build_note_repository, build_session_store, build_tag_repository,
- build_user_repository,
+ build_database_pool, build_link_repository, build_note_repository, build_session_store,
+ build_tag_repository, build_user_repository,
};
let pool = build_database_pool(&db_config)
.await
@@ -73,6 +73,9 @@ async fn main() -> anyhow::Result<()> {
let user_repo = build_user_repository(&pool)
.await
.map_err(|e| anyhow::anyhow!(e))?;
+ let link_repo = build_link_repository(&pool)
+ .await
+ .map_err(|e| anyhow::anyhow!(e))?;
// Create services
use notes_domain::{NoteService, TagService, UserService};
@@ -91,6 +94,7 @@ async fn main() -> anyhow::Result<()> {
note_repo,
tag_repo,
user_repo.clone(),
+ link_repo,
note_service,
tag_service,
user_service,
@@ -119,35 +123,36 @@ async fn main() -> anyhow::Result<()> {
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
// Parse CORS origins
- let mut cors = CorsLayer::new()
- .allow_methods([
- axum::http::Method::GET,
- axum::http::Method::POST,
- axum::http::Method::PATCH,
- axum::http::Method::DELETE,
- axum::http::Method::OPTIONS,
- ])
- .allow_headers([
- axum::http::header::AUTHORIZATION,
- axum::http::header::ACCEPT,
- axum::http::header::CONTENT_TYPE,
- ])
- .allow_credentials(true);
+ // let mut cors = CorsLayer::new()
+ // .allow_methods([
+ // axum::http::Method::GET,
+ // axum::http::Method::POST,
+ // axum::http::Method::PATCH,
+ // axum::http::Method::DELETE,
+ // axum::http::Method::OPTIONS,
+ // ])
+ // .allow_headers([
+ // axum::http::header::AUTHORIZATION,
+ // axum::http::header::ACCEPT,
+ // axum::http::header::CONTENT_TYPE,
+ // ])
+ // .allow_credentials(true);
+ let mut cors = CorsLayer::very_permissive();
// Add allowed origins
- let mut allowed_origins = Vec::new();
- for origin in &config.cors_allowed_origins {
- tracing::debug!("Allowing CORS origin: {}", origin);
- if let Ok(value) = origin.parse::() {
- allowed_origins.push(value);
- } else {
- tracing::warn!("Invalid CORS origin: {}", origin);
- }
- }
+ // let mut allowed_origins = Vec::new();
+ // for origin in &config.cors_allowed_origins {
+ // tracing::debug!("Allowing CORS origin: {}", origin);
+ // if let Ok(value) = origin.parse::() {
+ // allowed_origins.push(value);
+ // } else {
+ // tracing::warn!("Invalid CORS origin: {}", origin);
+ // }
+ // }
- if !allowed_origins.is_empty() {
- cors = cors.allow_origin(allowed_origins);
- }
+ // if !allowed_origins.is_empty() {
+ // cors = cors.allow_origin(allowed_origins);
+ // }
// Build the application
let app = Router::new()
diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs
index 59134b8..d15a9f0 100644
--- a/notes-api/src/routes/mod.rs
+++ b/notes-api/src/routes/mod.rs
@@ -30,6 +30,7 @@ pub fn api_v1_router() -> Router {
.delete(notes::delete_note),
)
.route("/notes/{id}/versions", get(notes::list_note_versions))
+ .route("/notes/{id}/related", get(notes::get_related_notes))
// Search route
.route("/search", get(notes::search_notes))
// Import/Export routes
diff --git a/notes-api/src/routes/notes.rs b/notes-api/src/routes/notes.rs
index 541bc1b..c8d37d8 100644
--- a/notes-api/src/routes/notes.rs
+++ b/notes-api/src/routes/notes.rs
@@ -184,7 +184,7 @@ pub async fn delete_note(
}
/// Search notes
-/// GET /api/v1/search
+/// GET /api/v1/notes/search
pub async fn search_notes(
State(state): State,
auth: AuthSession,
@@ -225,3 +225,30 @@ pub async fn list_note_versions(
Ok(Json(response))
}
+
+/// Get related notes
+/// GET /api/v1/notes/:id/related
+pub async fn get_related_notes(
+ State(state): State,
+ auth: AuthSession,
+ Path(id): Path,
+) -> ApiResult>> {
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
+ let user_id = user.id();
+
+ // Verify access to the source note
+ state.note_service.get_note(id, user_id).await?;
+
+ // Get links
+ let links = state.link_repo.get_links_for_note(id).await?;
+ let response: Vec = links
+ .into_iter()
+ .map(crate::dto::NoteLinkResponse::from)
+ .collect();
+
+ Ok(Json(response))
+}
diff --git a/notes-api/src/state.rs b/notes-api/src/state.rs
index 889193c..9cd09f9 100644
--- a/notes-api/src/state.rs
+++ b/notes-api/src/state.rs
@@ -11,6 +11,7 @@ pub struct AppState {
pub note_repo: Arc,
pub tag_repo: Arc,
pub user_repo: Arc,
+ pub link_repo: Arc,
pub note_service: Arc,
pub tag_service: Arc,
pub user_service: Arc,
@@ -23,6 +24,7 @@ impl AppState {
note_repo: Arc,
tag_repo: Arc,
user_repo: Arc,
+ link_repo: Arc,
note_service: Arc,
tag_service: Arc,
user_service: Arc,
@@ -33,6 +35,7 @@ impl AppState {
note_repo,
tag_repo,
user_repo,
+ link_repo,
note_service,
tag_service,
user_service,
diff --git a/notes-domain/src/ports.rs b/notes-domain/src/ports.rs
index 69d8dd2..35f24bd 100644
--- a/notes-domain/src/ports.rs
+++ b/notes-domain/src/ports.rs
@@ -30,4 +30,7 @@ pub trait LinkRepository: Send + Sync {
/// Delete existing links for a specific source note (e.g., before regenerating).
async fn delete_links_for_source(&self, source_note_id: Uuid) -> DomainResult<()>;
+
+ /// Get links for a specific source note.
+ async fn get_links_for_note(&self, source_note_id: Uuid) -> DomainResult>;
}
diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs
index 7aac25b..a60cde6 100644
--- a/notes-domain/src/services.rs
+++ b/notes-domain/src/services.rs
@@ -406,6 +406,14 @@ impl SmartNoteService {
Ok(())
}
+
+ /// Get related notes for a given note ID
+ pub async fn get_related_notes(
+ &self,
+ note_id: Uuid,
+ ) -> DomainResult> {
+ self.link_repo.get_links_for_note(note_id).await
+ }
}
#[cfg(test)]
diff --git a/notes-infra/src/link_repository.rs b/notes-infra/src/link_repository.rs
index a538660..067aa69 100644
--- a/notes-infra/src/link_repository.rs
+++ b/notes-infra/src/link_repository.rs
@@ -65,4 +65,47 @@ impl LinkRepository for SqliteLinkRepository {
Ok(())
}
+
+ async fn get_links_for_note(&self, source_note_id: Uuid) -> DomainResult> {
+ let source_str = source_note_id.to_string();
+
+ // We select links where the note is the source
+ // TODO: Should we also include links where the note is the target?
+ // For now, let's stick to outgoing links as defined by the service logic.
+ // Actually, semantic similarity is symmetric, but we only save (A -> B) if we process A.
+ // Ideally we should look for both directions or enforce symmetry.
+ // Given current implementation saves A->B when A is processed, if B is processed it saves B->A.
+ // So just querying source_note_id is fine if we assume all notes are processed.
+
+ let links = sqlx::query_as::<_, SqliteNoteLink>(
+ "SELECT * FROM note_links WHERE source_note_id = ? ORDER BY score DESC",
+ )
+ .bind(source_str)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| DomainError::RepositoryError(e.to_string()))?;
+
+ Ok(links.into_iter().map(NoteLink::from).collect())
+ }
+}
+
+#[derive(sqlx::FromRow)]
+struct SqliteNoteLink {
+ source_note_id: String,
+ target_note_id: String,
+ score: f32,
+ created_at: String, // Stored as ISO string
+}
+
+impl From for NoteLink {
+ fn from(row: SqliteNoteLink) -> Self {
+ Self {
+ source_note_id: Uuid::parse_str(&row.source_note_id).unwrap_or_default(),
+ target_note_id: Uuid::parse_str(&row.target_note_id).unwrap_or_default(),
+ score: row.score,
+ created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)
+ .unwrap_or_default()
+ .with_timezone(&chrono::Utc),
+ }
+ }
}