diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a889445..c161eaf 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -81,6 +81,15 @@ func main() { } }))) + mux.Handle("/notes/imports", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + handler.ImportNotesHandler(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }))) + mux.Handle("/notes/", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/backend/internal/api/handler/handler.go b/backend/internal/api/handler/handler.go index b1722c2..1243936 100644 --- a/backend/internal/api/handler/handler.go +++ b/backend/internal/api/handler/handler.go @@ -83,6 +83,45 @@ func (h *Handler) CreateNodeHandler(w http.ResponseWriter, r *http.Request) { response.WriteResponse(w, http.StatusAccepted, fmt.Sprintf("%d", noteID)) } +func (db *Handler) ImportNotesHandler(w http.ResponseWriter, r *http.Request) { + db.Logger.Debug("ImportNotesHandler called") + + httperr := validator.ImportNotesValidator(r) + if httperr != nil { + validator.WriteError(w, httperr) + return + } + + clerkID, ok := auth.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + note, err := request.UpdateNoteparser(r) + if err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + noteID, err := db.Service.CreateNote(r.Context(), clerkID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := db.Service.UpdateNodeById(r.Context(), note, noteID); err != nil { + if deleteErr := db.Service.DelNoteById(r.Context(), noteID); deleteErr != nil { + db.Logger.Error("failed to delete imported note after update failure", "note_id", noteID, "error", deleteErr) + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response.WriteResponse(w, http.StatusAccepted, fmt.Sprintf("%d", noteID)) +} + func (db *Handler) UpdateNoteHandler(w http.ResponseWriter, r *http.Request) { db.Logger.Debug("UpdateNoteHandler called") @@ -93,9 +132,15 @@ func (db *Handler) UpdateNoteHandler(w http.ResponseWriter, r *http.Request) { } note, err := request.UpdateNoteparser(r) - check(err) + if err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } - db.Service.UpdateNodeById(r.Context(), note, id) + if err := db.Service.UpdateNodeById(r.Context(), note, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } func (db *Handler) DelNoteHandler(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/handler/handler_test.go b/backend/internal/api/handler/handler_test.go index ce793c8..53443f5 100644 --- a/backend/internal/api/handler/handler_test.go +++ b/backend/internal/api/handler/handler_test.go @@ -4,19 +4,21 @@ import ( "backend/internal/api/request" "backend/internal/db" "context" + "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" + + clerk "github.com/clerk/clerk-sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) type MockService struct { mock.Mock - } func (m *MockService) GetNoteById(ctx context.Context, noteID int) (db.Note, error) { @@ -44,6 +46,13 @@ func (m *MockService) CreateNote(ctx context.Context, clerkid string) (int, erro return args.Int(0), args.Error(1) } +func authenticatedRequest(req *http.Request, userID string) *http.Request { + claims := &clerk.SessionClaims{} + claims.Subject = userID + ctx := clerk.ContextWithSessionClaims(req.Context(), claims) + return req.WithContext(ctx) +} + func TestNoteHandler(t *testing.T) { mockService := new(MockService) @@ -52,8 +61,8 @@ func TestNoteHandler(t *testing.T) { Return(db.Note{ID: 1}, nil) handler := Handler{Service: mockService, - Logger: slog.New(slog.NewTextHandler(io.Discard,nil)), -} + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } req := httptest.NewRequest(http.MethodGet, "/notes/1", nil) w := httptest.NewRecorder() @@ -72,8 +81,8 @@ func TestNoteListHandler(t *testing.T) { Return([]db.Note{}, nil) handler := Handler{Service: mockService, - Logger: slog.New(slog.NewTextHandler(io.Discard,nil)), -} + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } req := httptest.NewRequest(http.MethodGet, "/notes", nil) w := httptest.NewRecorder() @@ -92,34 +101,114 @@ func TestUpdateNoteHandler(t *testing.T) { Return(nil) handler := Handler{Service: mockService, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + body := `{"title":"test","content":{}}` + req := httptest.NewRequest(http.MethodPatch, "/notes/1", strings.NewReader(body)) + w := httptest.NewRecorder() + req.Header.Set("Content-Type", "application/json") + + handler.UpdateNoteHandler(w, req) - Logger: slog.New(slog.NewTextHandler(io.Discard,nil)), + assert.Equal(t, http.StatusOK, w.Code) + mockService.AssertExpectations(t) } - body := `{"title":"test","content":{}}` - req := httptest.NewRequest(http.MethodPatch, "/notes/1", - strings.NewReader(body)) +func TestUpdateNoteHandlerRejectsInvalidJSON(t *testing.T) { + mockService := new(MockService) + handler := Handler{Service: mockService, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + req := httptest.NewRequest(http.MethodPatch, "/notes/1", strings.NewReader(`{"title":`)) + w := httptest.NewRecorder() req.Header.Set("Content-Type", "application/json") + + handler.UpdateNoteHandler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + mockService.AssertNotCalled(t, "UpdateNodeById", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUpdateNoteHandlerReturnsInternalServerErrorOnUpdateFailure(t *testing.T) { + mockService := new(MockService) + mockService. + On("UpdateNodeById", mock.Anything, mock.Anything, 1). + Return(errors.New("update failed")) + + handler := Handler{Service: mockService, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + body := `{"title":"test","content":{}}` + req := httptest.NewRequest(http.MethodPatch, "/notes/1", strings.NewReader(body)) w := httptest.NewRecorder() + req.Header.Set("Content-Type", "application/json") handler.UpdateNoteHandler(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) mockService.AssertExpectations(t) } -func TestDelNoteHandler(t *testing.T) { +func TestImportNotesHandlerRejectsInvalidJSONBeforeCreate(t *testing.T) { mockService := new(MockService) + handler := Handler{Service: mockService, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + req := httptest.NewRequest(http.MethodPost, "/notes/imports", strings.NewReader(`{"title":`)) + req = authenticatedRequest(req, "user_123") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ImportNotesHandler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + mockService.AssertNotCalled(t, "CreateNote", mock.Anything, mock.Anything) + mockService.AssertNotCalled(t, "UpdateNodeById", mock.Anything, mock.Anything, mock.Anything) + mockService.AssertNotCalled(t, "DelNoteById", mock.Anything, mock.Anything) +} +func TestImportNotesHandlerDeletesCreatedNoteOnUpdateFailure(t *testing.T) { + mockService := new(MockService) mockService. - On("DelNoteById", mock.Anything, 1). + On("CreateNote", mock.Anything, "user_123"). + Return(42, nil) + mockService. + On("UpdateNodeById", mock.Anything, mock.Anything, 42). + Return(errors.New("update failed")) + mockService. + On("DelNoteById", mock.Anything, 42). Return(nil) - handler := Handler{Service: mockService, - Logger: slog.New(slog.NewTextHandler(io.Discard,nil)), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + body := `{"title":"test","content":{}}` + req := httptest.NewRequest(http.MethodPost, "/notes/imports", strings.NewReader(body)) + req = authenticatedRequest(req, "user_123") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ImportNotesHandler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + mockService.AssertExpectations(t) } +func TestDelNoteHandler(t *testing.T) { + mockService := new(MockService) + + mockService. + On("DelNoteById", mock.Anything, 1). + Return(nil) + + handler := Handler{Service: mockService, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } req := httptest.NewRequest(http.MethodDelete, "/notes/1", nil) w := httptest.NewRecorder() @@ -128,5 +217,3 @@ func TestDelNoteHandler(t *testing.T) { mockService.AssertExpectations(t) } - - diff --git a/backend/internal/api/validator/validator.go b/backend/internal/api/validator/validator.go index e978a43..a96e518 100644 --- a/backend/internal/api/validator/validator.go +++ b/backend/internal/api/validator/validator.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "mime" "net/http" "strconv" "strings" @@ -36,6 +37,17 @@ func formatpath(path string) []string { return strings.Split(path, "/") } +func validateJSONContentType(r *http.Request) *httpError { + contentType := r.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + log.Printf("invalid content-type: %s", contentType) + return &httpError{Status: http.StatusUnsupportedMediaType, Msg: "media not supported"} + } + + return nil +} + func GetNoteValidator(r *http.Request) (int, *httpError) { if err := validatehttpmethod(r, http.MethodGet); err != nil { return 0, &httpError{Status: http.StatusBadRequest, Msg: "method is not supported"} @@ -110,10 +122,8 @@ func UpdateNoteValidator(r *http.Request) (int, *httpError) { return 0, &httpError{Status: http.StatusMethodNotAllowed, Msg: "method not supported"} } - contentType := r.Header.Get("Content-Type") - if contentType != "application/json" { - log.Printf("invalid content-type: %s", contentType) - return 0, &httpError{Status: http.StatusUnsupportedMediaType, Msg: "media not supported"} + if err := validateJSONContentType(r); err != nil { + return 0, err } if r.ContentLength == 0 { @@ -135,3 +145,21 @@ func UpdateNoteValidator(r *http.Request) (int, *httpError) { return id, nil } + +func ImportNotesValidator(r *http.Request) *httpError { + if err := validatehttpmethod(r, http.MethodPost); err != nil { + return &httpError{Status: http.StatusMethodNotAllowed, Msg: "method not supported"} + } + + if err := validateJSONContentType(r); err != nil { + return err + } + + parts := formatpath(r.URL.Path) + if len(parts) != 2 || parts[0] != "notes" || parts[1] != "imports" { + log.Printf("invalid path: %s", r.URL.Path) + return &httpError{Status: http.StatusBadRequest, Msg: "path wrong"} + } + + return nil +} diff --git a/backend/internal/api/validator/validator_test.go b/backend/internal/api/validator/validator_test.go index 0e9889c..ae4e45b 100644 --- a/backend/internal/api/validator/validator_test.go +++ b/backend/internal/api/validator/validator_test.go @@ -10,7 +10,7 @@ import ( ) /* - GetNoteValidator +GetNoteValidator */ func TestGetNoteValidator_Success(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/notes/1", nil) @@ -30,7 +30,7 @@ func TestGetNoteValidator_InvalidMethod(t *testing.T) { } /* - GetNotelistValidator +GetNotelistValidator */ func TestGetNotelistValidator_Success(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/notes", nil) @@ -49,7 +49,7 @@ func TestGetNotelistValidator_InvalidPath(t *testing.T) { } /* - CreateNoteValidator +CreateNoteValidator */ func TestCreateNoteValidator_Success(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/notes", nil) @@ -68,7 +68,7 @@ func TestCreateNoteValidator_InvalidMethod(t *testing.T) { } /* - DeleteNoteValidator +DeleteNoteValidator */ func TestDeleteNoteValidator_Success(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, "/notes/2", nil) @@ -88,7 +88,7 @@ func TestDeleteNoteValidator_InvalidID(t *testing.T) { } /* - UpdateNoteValidator +UpdateNoteValidator */ func TestUpdateNoteValidator_Success(t *testing.T) { body := strings.NewReader(`{"title":"test"}`) @@ -101,6 +101,17 @@ func TestUpdateNoteValidator_Success(t *testing.T) { assert.Equal(t, 3, id) } +func TestUpdateNoteValidator_AcceptsContentTypeParameters(t *testing.T) { + body := strings.NewReader(`{"title":"test"}`) + req := httptest.NewRequest(http.MethodPatch, "/notes/3", body) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + id, err := UpdateNoteValidator(req) + + assert.Nil(t, err) + assert.Equal(t, 3, id) +} + func TestUpdateNoteValidator_InvalidContentType(t *testing.T) { body := strings.NewReader(`{"title":"test"}`) req := httptest.NewRequest(http.MethodPatch, "/notes/3", body) @@ -110,3 +121,33 @@ func TestUpdateNoteValidator_InvalidContentType(t *testing.T) { assert.NotNil(t, err) } + +func TestImportNotesValidator_Success(t *testing.T) { + body := strings.NewReader(`{"title":"test","content":{}}`) + req := httptest.NewRequest(http.MethodPost, "/notes/imports", body) + req.Header.Set("Content-Type", "application/json") + + err := ImportNotesValidator(req) + + assert.Nil(t, err) +} + +func TestImportNotesValidator_AcceptsContentTypeParameters(t *testing.T) { + body := strings.NewReader(`{"title":"test","content":{}}`) + req := httptest.NewRequest(http.MethodPost, "/notes/imports", body) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + err := ImportNotesValidator(req) + + assert.Nil(t, err) +} + +func TestImportNotesValidator_InvalidContentType(t *testing.T) { + body := strings.NewReader(`{"title":"test","content":{}}`) + req := httptest.NewRequest(http.MethodPost, "/notes/imports", body) + req.Header.Set("Content-Type", "text/plain") + + err := ImportNotesValidator(req) + + assert.NotNil(t, err) +} diff --git a/frontend/src/Puffnote.tsx b/frontend/src/Puffnote.tsx index e092e90..8da1d87 100644 --- a/frontend/src/Puffnote.tsx +++ b/frontend/src/Puffnote.tsx @@ -6,6 +6,7 @@ import { SignUpButton, } from "@clerk/clerk-react"; import { Editor } from "./components/Editor"; +import { Options } from "./components/Options"; import { Sidebar } from "./components/Sidebar"; import { useNotes } from "./hooks/useNotes"; @@ -16,6 +17,7 @@ function NotesApp() { activeNote, setActiveIndex, createNote, + handleImportNotes, updateNoteContent, renameNote, deleteNote, @@ -35,14 +37,7 @@ function NotesApp() { onToggle={() => setSidebarOpen(!sidebarOpen)} />
-
- -
+
diff --git a/frontend/src/components/Editor.tsx b/frontend/src/components/Editor.tsx index 71b8454..81fcab3 100644 --- a/frontend/src/components/Editor.tsx +++ b/frontend/src/components/Editor.tsx @@ -1,12 +1,7 @@ -import { useEditor, EditorContent } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import { Markdown } from "@tiptap/markdown"; -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { createLowlight, common } from "lowlight"; -import { useEffect } from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { useEffect, useRef } from "react"; import type { Note } from "../hooks/useNotes"; - -const lowlight = createLowlight(common); +import { editorExtensions, normalizeEditorContent } from "../editor"; type Props = { activeNote: Note | null; @@ -14,16 +9,17 @@ type Props = { }; export function Editor({ activeNote, updateNoteContent }: Props) { + const updateNoteContentRef = useRef(updateNoteContent); + + useEffect(() => { + updateNoteContentRef.current = updateNoteContent; + }, [updateNoteContent]); + const editor = useEditor({ - extensions: [ - StarterKit.configure({ codeBlock: false }), - CodeBlockLowlight.configure({ lowlight }), - Markdown, - ], - content: activeNote?.content ?? "", - contentType: "markdown", + extensions: editorExtensions, + content: normalizeEditorContent(activeNote?.content), onUpdate({ editor }) { - updateNoteContent(editor.getJSON()); + updateNoteContentRef.current(editor.getJSON()); }, onBlur({ editor }) { setTimeout(() => { @@ -32,7 +28,6 @@ export function Editor({ activeNote, updateNoteContent }: Props) { }, }); - // Auto-focus on mount and when the window regains focus useEffect(() => { const timer = setTimeout(() => editor?.commands.focus(), 100); return () => clearTimeout(timer); @@ -44,16 +39,17 @@ export function Editor({ activeNote, updateNoteContent }: Props) { return () => window.removeEventListener("focus", onFocus); }, [editor]); - // When switching notes, update editor content useEffect(() => { - if (!editor || !activeNote) return; + if (!editor) return; + const currentJson = JSON.stringify(editor.getJSON()); - const targetContent = activeNote.content; + const targetContent = normalizeEditorContent(activeNote?.content); const targetJson = JSON.stringify(targetContent); + if (currentJson !== targetJson) { - editor.commands.setContent(targetContent as string | object, { emitUpdate: false }); + editor.commands.setContent(targetContent, { emitUpdate: false }); } - }, [editor, activeNote?.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [editor, activeNote]); return ( Promise | void; +}; + +export function Options({ onImportNotes }: Props) { + const fileInputRef = useRef(null); + + function handleImportButtonClick() { + if (!fileInputRef.current) return; + fileInputRef.current.value = ""; + fileInputRef.current.click(); + } + + function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + void onImportNotes(file); + event.target.value = ""; + } + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/editor.ts b/frontend/src/editor.ts new file mode 100644 index 0000000..08e6a5d --- /dev/null +++ b/frontend/src/editor.ts @@ -0,0 +1,31 @@ +import type { Content, JSONContent } from "@tiptap/core"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { Markdown, MarkdownManager } from "@tiptap/markdown"; +import StarterKit from "@tiptap/starter-kit"; +import { createLowlight, common } from "lowlight"; + +const lowlight = createLowlight(common); + +export const editorExtensions = [ + StarterKit.configure({ codeBlock: false }), + CodeBlockLowlight.configure({ lowlight }), + Markdown, +]; + +const markdownManager = new MarkdownManager({ + extensions: editorExtensions, +}); + +export function parseMarkdownContent(markdown: string): JSONContent { + return markdownManager.parse(markdown); +} + +export function normalizeEditorContent( + content: object | string | null | undefined +): Content { + if (typeof content === "string") { + return parseMarkdownContent(content); + } + + return (content ?? parseMarkdownContent("")) as Content; +} diff --git a/frontend/src/hooks/useNotes.ts b/frontend/src/hooks/useNotes.ts index ad7c0f6..3b19097 100644 --- a/frontend/src/hooks/useNotes.ts +++ b/frontend/src/hooks/useNotes.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { useAuth } from "@clerk/clerk-react"; +import { parseMarkdownContent } from "../editor"; const BASEURL = import.meta.env.VITE_API_BASE_URL; @@ -104,6 +105,31 @@ async function deleteNoteRequest(id: string, getToken: GetToken): Promise } } +async function importNotes(file: File, getToken: GetToken): Promise { + const url = BASEURL + "/notes/imports"; + const headers = await authHeaders(getToken); + const markdown = await file.text(); + const content = parseMarkdownContent(markdown); + const title = file.name.replace(/\.md$/i, ""); + + const response = await fetch(url, { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + title, + content, + }), + }); + + if (!response.ok) { + throw new Error("request failed"); + } +} + export function useNotes() { const { isLoaded, isSignedIn, getToken } = useAuth(); const [notes, setNotes] = useState([]); @@ -211,6 +237,21 @@ export function useNotes() { [getToken, notes] ); + const handleImportNotes = useCallback( + async (file: File) => { + try { + await importNotes(file, getToken); + + const loadedNotes = await loadNotes(getToken); + setNotes(loadedNotes); + setActiveIndex(Math.max(loadedNotes.length - 1, 0)); + } catch (err) { + console.error(err); + } + }, + [getToken] + ); + const activeNote = notes[activeIndex] ?? null; return { @@ -219,6 +260,7 @@ export function useNotes() { activeNote, setActiveIndex, createNote, + handleImportNotes, updateNoteContent, renameNote, deleteNote,