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 { getNoteColor } from "@/lib/constants";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { RelatedNotes } from "./related-notes";
|
||||||
|
|
||||||
|
|
||||||
interface NoteViewDialogProps {
|
interface NoteViewDialogProps {
|
||||||
@@ -15,9 +16,10 @@ interface NoteViewDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onEdit: () => 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);
|
const colorClass = getNoteColor(note.color);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="prose dark:prose-invert max-w-none text-base leading-relaxed break-words pb-6">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{note.content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{note.content}</ReactMarkdown>
|
||||||
</div>
|
</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>
|
</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">
|
<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 = [
|
const NOTE_COLORS = [
|
||||||
{ name: "DEFAULT", value: "bg-background border-border", label: "Default" },
|
{ 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: "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/20 dark:border-orange-900", label: "Orange" },
|
{ 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/20 dark:border-yellow-900", label: "Yellow" },
|
{ 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/20 dark:border-green-900", label: "Green" },
|
{ 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/20 dark:border-teal-900", label: "Teal" },
|
{ 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/20 dark:border-blue-900", label: "Blue" },
|
{ 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/20 dark:border-indigo-900", label: "Indigo" },
|
{ 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 {
|
export function getNoteColor(colorName: string | undefined): string {
|
||||||
|
|||||||
@@ -172,3 +172,23 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
|
|||||||
pub struct ConfigResponse {
|
pub struct ConfigResponse {
|
||||||
pub allow_registration: bool,
|
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);
|
let db_config = DatabaseConfig::new(&config.database_url);
|
||||||
|
|
||||||
use notes_infra::factory::{
|
use notes_infra::factory::{
|
||||||
build_database_pool, build_note_repository, build_session_store, build_tag_repository,
|
build_database_pool, build_link_repository, build_note_repository, build_session_store,
|
||||||
build_user_repository,
|
build_tag_repository, build_user_repository,
|
||||||
};
|
};
|
||||||
let pool = build_database_pool(&db_config)
|
let pool = build_database_pool(&db_config)
|
||||||
.await
|
.await
|
||||||
@@ -73,6 +73,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let user_repo = build_user_repository(&pool)
|
let user_repo = build_user_repository(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
let link_repo = build_link_repository(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
// Create services
|
// Create services
|
||||||
use notes_domain::{NoteService, TagService, UserService};
|
use notes_domain::{NoteService, TagService, UserService};
|
||||||
@@ -91,6 +94,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
note_repo,
|
note_repo,
|
||||||
tag_repo,
|
tag_repo,
|
||||||
user_repo.clone(),
|
user_repo.clone(),
|
||||||
|
link_repo,
|
||||||
note_service,
|
note_service,
|
||||||
tag_service,
|
tag_service,
|
||||||
user_service,
|
user_service,
|
||||||
@@ -119,35 +123,36 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
|
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||||
|
|
||||||
// Parse CORS origins
|
// Parse CORS origins
|
||||||
let mut cors = CorsLayer::new()
|
// let mut cors = CorsLayer::new()
|
||||||
.allow_methods([
|
// .allow_methods([
|
||||||
axum::http::Method::GET,
|
// axum::http::Method::GET,
|
||||||
axum::http::Method::POST,
|
// axum::http::Method::POST,
|
||||||
axum::http::Method::PATCH,
|
// axum::http::Method::PATCH,
|
||||||
axum::http::Method::DELETE,
|
// axum::http::Method::DELETE,
|
||||||
axum::http::Method::OPTIONS,
|
// axum::http::Method::OPTIONS,
|
||||||
])
|
// ])
|
||||||
.allow_headers([
|
// .allow_headers([
|
||||||
axum::http::header::AUTHORIZATION,
|
// axum::http::header::AUTHORIZATION,
|
||||||
axum::http::header::ACCEPT,
|
// axum::http::header::ACCEPT,
|
||||||
axum::http::header::CONTENT_TYPE,
|
// axum::http::header::CONTENT_TYPE,
|
||||||
])
|
// ])
|
||||||
.allow_credentials(true);
|
// .allow_credentials(true);
|
||||||
|
let mut cors = CorsLayer::very_permissive();
|
||||||
|
|
||||||
// Add allowed origins
|
// Add allowed origins
|
||||||
let mut allowed_origins = Vec::new();
|
// let mut allowed_origins = Vec::new();
|
||||||
for origin in &config.cors_allowed_origins {
|
// for origin in &config.cors_allowed_origins {
|
||||||
tracing::debug!("Allowing CORS origin: {}", origin);
|
// tracing::debug!("Allowing CORS origin: {}", origin);
|
||||||
if let Ok(value) = origin.parse::<axum::http::HeaderValue>() {
|
// if let Ok(value) = origin.parse::<axum::http::HeaderValue>() {
|
||||||
allowed_origins.push(value);
|
// allowed_origins.push(value);
|
||||||
} else {
|
// } else {
|
||||||
tracing::warn!("Invalid CORS origin: {}", origin);
|
// tracing::warn!("Invalid CORS origin: {}", origin);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
if !allowed_origins.is_empty() {
|
// if !allowed_origins.is_empty() {
|
||||||
cors = cors.allow_origin(allowed_origins);
|
// cors = cors.allow_origin(allowed_origins);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Build the application
|
// Build the application
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub fn api_v1_router() -> Router<AppState> {
|
|||||||
.delete(notes::delete_note),
|
.delete(notes::delete_note),
|
||||||
)
|
)
|
||||||
.route("/notes/{id}/versions", get(notes::list_note_versions))
|
.route("/notes/{id}/versions", get(notes::list_note_versions))
|
||||||
|
.route("/notes/{id}/related", get(notes::get_related_notes))
|
||||||
// Search route
|
// Search route
|
||||||
.route("/search", get(notes::search_notes))
|
.route("/search", get(notes::search_notes))
|
||||||
// Import/Export routes
|
// Import/Export routes
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ pub async fn delete_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search notes
|
/// Search notes
|
||||||
/// GET /api/v1/search
|
/// GET /api/v1/notes/search
|
||||||
pub async fn search_notes(
|
pub async fn search_notes(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
@@ -225,3 +225,30 @@ pub async fn list_note_versions(
|
|||||||
|
|
||||||
Ok(Json(response))
|
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 note_repo: Arc<dyn NoteRepository>,
|
||||||
pub tag_repo: Arc<dyn TagRepository>,
|
pub tag_repo: Arc<dyn TagRepository>,
|
||||||
pub user_repo: Arc<dyn UserRepository>,
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
|
||||||
pub note_service: Arc<NoteService>,
|
pub note_service: Arc<NoteService>,
|
||||||
pub tag_service: Arc<TagService>,
|
pub tag_service: Arc<TagService>,
|
||||||
pub user_service: Arc<UserService>,
|
pub user_service: Arc<UserService>,
|
||||||
@@ -23,6 +24,7 @@ impl AppState {
|
|||||||
note_repo: Arc<dyn NoteRepository>,
|
note_repo: Arc<dyn NoteRepository>,
|
||||||
tag_repo: Arc<dyn TagRepository>,
|
tag_repo: Arc<dyn TagRepository>,
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
|
link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
|
||||||
note_service: Arc<NoteService>,
|
note_service: Arc<NoteService>,
|
||||||
tag_service: Arc<TagService>,
|
tag_service: Arc<TagService>,
|
||||||
user_service: Arc<UserService>,
|
user_service: Arc<UserService>,
|
||||||
@@ -33,6 +35,7 @@ impl AppState {
|
|||||||
note_repo,
|
note_repo,
|
||||||
tag_repo,
|
tag_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
|
link_repo,
|
||||||
note_service,
|
note_service,
|
||||||
tag_service,
|
tag_service,
|
||||||
user_service,
|
user_service,
|
||||||
|
|||||||
@@ -30,4 +30,7 @@ pub trait LinkRepository: Send + Sync {
|
|||||||
|
|
||||||
/// Delete existing links for a specific source note (e.g., before regenerating).
|
/// Delete existing links for a specific source note (e.g., before regenerating).
|
||||||
async fn delete_links_for_source(&self, source_note_id: Uuid) -> DomainResult<()>;
|
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(())
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -65,4 +65,47 @@ impl LinkRepository for SqliteLinkRepository {
|
|||||||
|
|
||||||
Ok(())
|
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