From c8276ac30670031305e9ad7af410d3e88e823a63 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 26 Dec 2025 00:35:32 +0100 Subject: [PATCH] feat: Add related notes functionality with new API endpoint and frontend components, and update note search route. --- .../src/components/note-view-dialog.tsx | 15 ++++- .../src/components/related-notes.tsx | 62 +++++++++++++++++++ .../src/hooks/use-related-notes.ts | 23 +++++++ k-notes-frontend/src/lib/constants.ts | 14 ++--- notes-api/src/dto.rs | 20 ++++++ notes-api/src/main.rs | 61 +++++++++--------- notes-api/src/routes/mod.rs | 1 + notes-api/src/routes/notes.rs | 29 ++++++++- notes-api/src/state.rs | 3 + notes-domain/src/ports.rs | 3 + notes-domain/src/services.rs | 8 +++ notes-infra/src/link_repository.rs | 43 +++++++++++++ 12 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 k-notes-frontend/src/components/related-notes.tsx create mode 100644 k-notes-frontend/src/hooks/use-related-notes.ts 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), + } + } }