feat: Add related notes functionality with new API endpoint and frontend components, and update note search route.

This commit is contained in:
2025-12-26 00:35:32 +01:00
parent 64f8118228
commit c8276ac306
12 changed files with 245 additions and 37 deletions

View File

@@ -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
<div className="prose dark:prose-invert max-w-none text-base leading-relaxed break-words pb-6">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{note.content}</ReactMarkdown>
</div>
{/* Smart Features: Related Notes */}
<div className="pb-4">
<RelatedNotes
noteId={note.id}
onSelectNote={onSelectNote ? (id) => {
onOpenChange(false);
setTimeout(() => onSelectNote(id), 100); // Small delay to allow dialog close animation?
} : undefined}
/>
</div>
</div>
<DialogFooter className="pt-4 mt-2 border-t border-black/5 dark:border-white/5 flex sm:justify-between items-center gap-4 shrink-0">

View File

@@ -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 (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">Related Notes</h3>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-32" />
</div>
</div>
);
}
if (!relatedLinks || relatedLinks.length === 0) {
return null;
}
return (
<div className="space-y-2 mt-6 border-t pt-4">
<h3 className="text-sm font-medium flex items-center gap-2">
<Link2 className="w-4 h-4" />
Related Notes
</h3>
<div className="flex flex-wrap gap-2">
{relatedLinks.map((link) => {
const targetNote = notes?.find((n: any) => n.id === link.target_note_id);
if (!targetNote) return null;
return (
<Button
key={link.target_note_id}
variant="outline"
size="sm"
className="h-8 text-xs max-w-[200px] justify-start"
onClick={() => onSelectNote?.(link.target_note_id)}
>
<span className="truncate">{targetNote.title || "Untitled"}</span>
<Badge variant="secondary" className="ml-2 text-[10px] h-5 px-1">
{Math.round(link.score * 100)}%
</Badge>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -172,3 +172,23 @@ impl From<notes_domain::NoteVersion> 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<Utc>,
}
impl From<notes_domain::entities::NoteLink> 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,
}
}
}

View File

@@ -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::<axum::http::HeaderValue>() {
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::<axum::http::HeaderValue>() {
// 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()

View File

@@ -30,6 +30,7 @@ pub fn api_v1_router() -> Router<AppState> {
.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

View File

@@ -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<AppState>,
auth: AuthSession<AuthBackend>,
@@ -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<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<crate::dto::NoteLinkResponse>>> {
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<crate::dto::NoteLinkResponse> = links
.into_iter()
.map(crate::dto::NoteLinkResponse::from)
.collect();
Ok(Json(response))
}

View File

@@ -11,6 +11,7 @@ pub struct AppState {
pub note_repo: Arc<dyn NoteRepository>,
pub tag_repo: Arc<dyn TagRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
pub note_service: Arc<NoteService>,
pub tag_service: Arc<TagService>,
pub user_service: Arc<UserService>,
@@ -23,6 +24,7 @@ impl AppState {
note_repo: Arc<dyn NoteRepository>,
tag_repo: Arc<dyn TagRepository>,
user_repo: Arc<dyn UserRepository>,
link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
note_service: Arc<NoteService>,
tag_service: Arc<TagService>,
user_service: Arc<UserService>,
@@ -33,6 +35,7 @@ impl AppState {
note_repo,
tag_repo,
user_repo,
link_repo,
note_service,
tag_service,
user_service,

View File

@@ -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<Vec<NoteLink>>;
}

View File

@@ -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<Vec<crate::entities::NoteLink>> {
self.link_repo.get_links_for_note(note_id).await
}
}
#[cfg(test)]

View File

@@ -65,4 +65,47 @@ impl LinkRepository for SqliteLinkRepository {
Ok(())
}
async fn get_links_for_note(&self, source_note_id: Uuid) -> DomainResult<Vec<NoteLink>> {
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<SqliteNoteLink> 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),
}
}
}