feat: Add related notes functionality with new API endpoint and frontend components, and update note search route.
This commit is contained in:
@@ -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">
|
||||
|
||||
62
k-notes-frontend/src/components/related-notes.tsx
Normal file
62
k-notes-frontend/src/components/related-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
k-notes-frontend/src/hooks/use-related-notes.ts
Normal file
23
k-notes-frontend/src/hooks/use-related-notes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user