feat: Implement data import and export functionality for notes and tags.

This commit is contained in:
2025-12-23 02:27:26 +01:00
parent 2ee9de866a
commit e4f021a68f
6 changed files with 200 additions and 8 deletions

View File

@@ -27,7 +27,7 @@ interface NoteFormProps {
export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Save" }: NoteFormProps) {
const form = useForm<NoteFormValues>({
resolver: zodResolver(noteSchema),
resolver: zodResolver(noteSchema) as any,
defaultValues: {
title: "",
content: "",
@@ -40,9 +40,9 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit as any)} className="space-y-4">
<FormField
control={form.control}
control={form.control as any}
name="title"
render={({ field }) => (
<FormItem>
@@ -55,7 +55,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
)}
/>
<FormField
control={form.control}
control={form.control as any}
name="content"
render={({ field }) => (
<FormItem>
@@ -68,7 +68,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
)}
/>
<FormField
control={form.control}
control={form.control as any}
name="tags"
render={({ field }) => (
<FormItem>
@@ -82,7 +82,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
/>
<FormField
control={form.control}
control={form.control as any}
name="color"
render={({ field }) => (
<FormItem>
@@ -111,7 +111,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
/>
<FormField
control={form.control}
control={form.control as any}
name="is_pinned"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md border p-4">

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from "react";
import { useRef, useState, useEffect } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Separator } from "@/components/ui/separator";
interface SettingsDialogProps {
open: boolean;
@@ -35,6 +37,42 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
try {
const blob = await api.exportData();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `k-notes-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("Export successful");
} catch (e) {
toast.error("Export failed");
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
await api.importData(data);
toast.success("Import successful. Reloading...");
onOpenChange(false);
window.location.reload();
} catch (e) {
console.error(e);
toast.error("Import failed");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -58,6 +96,32 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
/>
</div>
</div>
<Separator className="my-2" />
<div className="py-4 space-y-4">
<div className="flex flex-col space-y-2">
<h4 className="font-medium leading-none">Data Management</h4>
<p className="text-sm text-muted-foreground">
Export your notes for backup or import from a JSON file.
</p>
</div>
<div className="flex gap-4">
<Button variant="outline" onClick={handleExport}>
Export Data
</Button>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
Import Data
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept=".json"
onChange={handleImport}
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleSave}>Save changes</Button>
</DialogFooter>

View File

@@ -73,4 +73,12 @@ export const api = {
body: JSON.stringify(body),
}),
delete: (endpoint: string) => fetchWithAuth(endpoint, { method: "DELETE" }),
exportData: async () => {
const response = await fetch(`${getApiUrl()}/export`, {
credentials: "include",
});
if (!response.ok) throw new ApiError(response.status, "Failed to export data");
return response.blob();
},
importData: (data: any) => api.post("/import", data),
};

View File

@@ -0,0 +1,28 @@
declare module "react-resizable-panels" {
import * as React from "react";
export interface PanelGroupProps extends React.HTMLAttributes<HTMLDivElement> {
direction: "horizontal" | "vertical";
autoSaveId?: string;
storage?: any;
}
export const PanelGroup: React.FC<PanelGroupProps>;
export interface PanelProps extends React.HTMLAttributes<HTMLDivElement> {
defaultSize?: number;
minSize?: number;
maxSize?: number;
order?: number;
collapsible?: boolean;
collapsedSize?: number;
onCollapse?: (collapsed: boolean) => void;
onResize?: (size: number) => void;
}
export const Panel: React.FC<PanelProps>;
export interface PanelResizeHandleProps extends React.HTMLAttributes<HTMLDivElement> {
disabled?: boolean;
hitAreaMargins?: { fine: number; coarse: number };
}
export const PanelResizeHandle: React.FC<PanelResizeHandleProps>;
}

View File

@@ -0,0 +1,88 @@
use axum::{Json, extract::State, http::StatusCode};
use axum_login::{AuthSession, AuthUser};
use serde::{Deserialize, Serialize};
use crate::auth::AuthBackend;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
use notes_domain::{Note, NoteFilter, Tag};
#[derive(Serialize, Deserialize)]
pub struct BackupData {
pub notes: Vec<Note>,
pub tags: Vec<Tag>,
}
/// Export user data
/// GET /api/v1/export
pub async fn export_data(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
) -> ApiResult<Json<BackupData>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
let notes = state
.note_repo
.find_by_user(user_id, NoteFilter::default())
.await?;
let tags = state.tag_repo.find_by_user(user_id).await?;
Ok(Json(BackupData { notes, tags }))
}
/// Import user data
/// POST /api/v1/import
pub async fn import_data(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Json(payload): Json<BackupData>,
) -> ApiResult<StatusCode> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
// 1. Import standalone tags (to ensure even unused tags are restored)
for tag in payload.tags {
// Security check: ensure tag belongs to user
if tag.user_id != user_id {
// Skip tags from other users if malformed, or overwrite user_id?
// Safer to skip or force user_id. Let's force user_id to current user to allow migrating data between accounts.
let mut tag = tag;
tag.user_id = user_id;
state.tag_repo.save(&tag).await?;
} else {
state.tag_repo.save(&tag).await?;
}
}
// 2. Import notes
for mut note in payload.notes {
// Security check: ensure note belongs to user
note.user_id = user_id; // Force ownership to current user
// Save note content
state.note_repo.save(&note).await?;
// 3. Re-establish tag associations
// Note: note.tags contains the tags associated with this note
for mut tag in note.tags {
tag.user_id = user_id; // Force ownership
// Ensure tag exists (upsert) - might be redundant if in payload.tags but safe
state.tag_repo.save(&tag).await?;
// Link tag to note
state.tag_repo.add_to_note(tag.id, note.id).await?;
}
}
Ok(StatusCode::OK)
}

View File

@@ -1,6 +1,7 @@
//! Route definitions and module structure
pub mod auth;
pub mod import_export;
pub mod notes;
pub mod tags;
@@ -29,6 +30,9 @@ pub fn api_v1_router() -> Router<AppState> {
)
// Search route
.route("/search", get(notes::search_notes))
// Import/Export routes
.route("/export", get(import_export::export_data))
.route("/import", post(import_export::import_data))
// Tag routes
.route("/tags", get(tags::list_tags).post(tags::create_tag))
.route("/tags/{id}", delete(tags::delete_tag))