learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: lecture import with file upload

* text chunking

+24
Cargo.lock
··· 200 200 "matchit", 201 201 "memchr", 202 202 "mime", 203 + "multer", 203 204 "percent-encoding", 204 205 "pin-project-lite", 205 206 "serde_core", ··· 2121 2122 ] 2122 2123 2123 2124 [[package]] 2125 + name = "multer" 2126 + version = "3.1.0" 2127 + source = "registry+https://github.com/rust-lang/crates.io-index" 2128 + checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" 2129 + dependencies = [ 2130 + "bytes", 2131 + "encoding_rs", 2132 + "futures-util", 2133 + "http", 2134 + "httparse", 2135 + "memchr", 2136 + "mime", 2137 + "spin", 2138 + "version_check", 2139 + ] 2140 + 2141 + [[package]] 2124 2142 name = "multibase" 2125 2143 version = "0.9.2" 2126 2144 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3273 3291 "libc", 3274 3292 "windows-sys 0.60.2", 3275 3293 ] 3294 + 3295 + [[package]] 3296 + name = "spin" 3297 + version = "0.9.8" 3298 + source = "registry+https://github.com/rust-lang/crates.io-index" 3299 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3276 3300 3277 3301 [[package]] 3278 3302 name = "spki"
+1 -1
crates/server/Cargo.toml
··· 7 7 anyhow = "1.0" 8 8 async-trait = "0.1.83" 9 9 atproto-jetstream = "0.13" 10 - axum = "0.8.8" 10 + axum = { version = "0.8.8", features = ["multipart"] } 11 11 base64 = "0.22" 12 12 chrono = { version = "0.4.42", features = ["serde"] } 13 13 deadpool-postgres = "0.14.0"
+66 -1
crates/server/src/api/importer.rs
··· 1 + use crate::import::{ 2 + DocumentParser, 3 + chunker::{Chunk, chunk_text}, 4 + docx::DocxParser, 5 + pdf::PdfParser, 6 + }; 1 7 use crate::middleware::auth::UserContext; 2 8 use crate::state::SharedState; 3 - use axum::{Json, extract::Extension, http::StatusCode, response::IntoResponse}; 9 + use axum::{ 10 + Json, 11 + extract::{Extension, Multipart}, 12 + http::StatusCode, 13 + response::IntoResponse, 14 + }; 4 15 use malfestio_core::model::Visibility; 5 16 use malfestio_readability::Readability; 6 17 use serde::{Deserialize, Serialize}; 7 18 use serde_json::json; 19 + use tokio::fs; 8 20 9 21 #[derive(Deserialize)] 10 22 pub struct ImportRequest { ··· 25 37 title: String, 26 38 markdown: String, 27 39 metadata: ArticleMetadata, 40 + } 41 + 42 + #[derive(Serialize)] 43 + pub struct ImportLectureResponse { 44 + filename: String, 45 + content: String, 46 + chunks: Vec<Chunk>, 47 + } 48 + 49 + pub async fn post_import_lecture(mut multipart: Multipart) -> impl IntoResponse { 50 + while let Ok(Some(field)) = multipart.next_field().await { 51 + let file_name = field.file_name().unwrap_or("lecture.pdf").to_string(); 52 + let _content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); 53 + 54 + // TODO: in-memory parsing, but pdf-extract takes Path usually. 55 + if let Ok(data) = field.bytes().await { 56 + let temp_dir = std::env::temp_dir(); 57 + let temp_path = temp_dir.join(format!("upload_{}_{}", uuid::Uuid::new_v4(), file_name)); 58 + 59 + if let Err(e) = fs::write(&temp_path, &data).await { 60 + return ( 61 + StatusCode::INTERNAL_SERVER_ERROR, 62 + Json(json!({"error": format!("Failed to save temp file: {}", e)})), 63 + ) 64 + .into_response(); 65 + } 66 + 67 + let parser: Box<dyn DocumentParser + Send + Sync> = 68 + if file_name.ends_with(".docx") { Box::new(DocxParser) } else { Box::new(PdfParser) }; 69 + 70 + let result = parser.parse(&temp_path); 71 + 72 + if let Err(e) = fs::remove_file(&temp_path).await { 73 + tracing::warn!("Failed to remove temp file: {}", e); 74 + } 75 + 76 + match result { 77 + Ok(text) => { 78 + let chunks = chunk_text(&text, 1000); 79 + return Json(ImportLectureResponse { filename: file_name, content: text, chunks }).into_response(); 80 + } 81 + Err(e) => { 82 + return ( 83 + StatusCode::INTERNAL_SERVER_ERROR, 84 + Json(json!({"error": format!("Failed to parse document: {}", e)})), 85 + ) 86 + .into_response(); 87 + } 88 + } 89 + } 90 + } 91 + 92 + (StatusCode::BAD_REQUEST, Json(json!({"error": "No file uploaded"}))).into_response() 28 93 } 29 94 30 95 pub async fn import_article(Json(payload): Json<ImportRequest>) -> impl IntoResponse {
+86
crates/server/src/import/chunker.rs
··· 1 + use serde::Serialize; 2 + 3 + #[derive(Debug, Serialize, PartialEq)] 4 + pub struct Chunk { 5 + pub heading: String, 6 + pub content: String, 7 + } 8 + 9 + /// Chunks text based on markdown headers or paragraph breaks. 10 + /// 11 + /// Tries to keep chunks under `max_words` roughly, but honors logical sections first. 12 + pub fn chunk_text(text: &str, _max_words: usize) -> Vec<Chunk> { 13 + let mut chunks = Vec::new(); 14 + let mut current_heading = "Introduction".to_string(); 15 + let mut current_content = String::new(); 16 + 17 + for line in text.lines() { 18 + let trimmed = line.trim(); 19 + let is_header = trimmed.starts_with('#') || is_common_header(trimmed); 20 + 21 + if is_header { 22 + if !current_content.trim().is_empty() { 23 + chunks.push(Chunk { heading: current_heading.clone(), content: current_content.trim().to_string() }); 24 + } 25 + 26 + current_heading = if trimmed.starts_with('#') { 27 + trimmed.trim_start_matches('#').trim().to_string() 28 + } else { 29 + trimmed.to_string() 30 + }; 31 + current_content.clear(); 32 + } else { 33 + current_content.push_str(line); 34 + current_content.push('\n'); 35 + } 36 + } 37 + 38 + if !current_content.trim().is_empty() { 39 + chunks.push(Chunk { heading: current_heading, content: current_content.trim().to_string() }); 40 + } 41 + 42 + chunks 43 + } 44 + 45 + fn is_common_header(line: &str) -> bool { 46 + let lower = line.to_lowercase(); 47 + if line.len() > 50 { 48 + return false; 49 + } 50 + lower.contains("abstract") 51 + || lower.contains("introduction") 52 + || lower.contains("references") 53 + || lower.contains("conclusion") 54 + || lower.contains("background") 55 + } 56 + 57 + #[cfg(test)] 58 + mod tests { 59 + use super::*; 60 + 61 + #[test] 62 + fn test_chunking_with_headers() { 63 + let text = "# Header 1\nContent 1\n## Header 2\nContent 2"; 64 + let chunks = chunk_text(text, 1000); 65 + assert_eq!(chunks.len(), 2); 66 + assert_eq!(chunks[0].heading, "Header 1"); 67 + assert_eq!(chunks[0].content, "Content 1"); 68 + assert_eq!(chunks[1].heading, "Header 2"); 69 + assert_eq!(chunks[1].content, "Content 2"); 70 + } 71 + 72 + #[test] 73 + fn test_chunking_no_headers() { 74 + let text = "Just some content\nMore content"; 75 + let chunks = chunk_text(text, 1000); 76 + assert_eq!(chunks.len(), 1); 77 + assert_eq!(chunks[0].heading, "Introduction"); 78 + assert_eq!(chunks[0].content, "Just some content\nMore content"); 79 + } 80 + 81 + #[test] 82 + fn test_empty_text() { 83 + let chunks = chunk_text("", 1000); 84 + assert_eq!(chunks.len(), 0); 85 + } 86 + }
+1
crates/server/src/import/mod.rs
··· 1 1 use anyhow::Result; 2 2 use std::path::Path; 3 3 4 + pub mod chunker; 4 5 pub mod docx; 5 6 pub mod pdf; 6 7
+14 -7
crates/server/src/lib.rs
··· 36 36 37 37 tracing::info!("Starting Malfestio Server..."); 38 38 39 + let app = create_app().await?; 40 + 41 + let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); 42 + 43 + tracing::info!("Listening on {}", addr); 44 + 45 + let listener = TcpListener::bind(addr).await.unwrap(); 46 + axum::serve(listener, app).await.unwrap(); 47 + Ok(()) 48 + } 49 + 50 + pub async fn create_app() -> malfestio_core::Result<Router> { 39 51 let database_url = std::env::var("DATABASE_URL") 40 52 .unwrap_or_else(|_| std::env::var("DB_URL").expect("DATABASE_URL or DB_URL must be set")); 41 53 let pool = db::create_pool(&database_url).map_err(|e| { ··· 110 122 .route("/.well-known/atproto-did", get(well_known::atproto_did_handler)) 111 123 .route("/api/auth/login", post(api::auth::login)) 112 124 .route("/api/import/article", post(api::importer::import_article)) 125 + .route("/api/import/lecture", post(api::importer::post_import_lecture)) 113 126 .nest("/api/oauth", oauth_routes) 114 127 .nest("/api", optional_auth_routes) 115 128 .nest("/api", auth_routes) ··· 122 135 ) 123 136 .with_state(state); 124 137 125 - let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); 126 - 127 - tracing::info!("Listening on {}", addr); 128 - 129 - let listener = TcpListener::bind(addr).await.unwrap(); 130 - axum::serve(listener, app).await.unwrap(); 131 - Ok(()) 138 + Ok(app) 132 139 } 133 140 134 141 /// Basic liveness check - returns 200 if the server is running.
+140
crates/server/tests/api_import_tests.rs
··· 1 + use axum::{ 2 + Router, 3 + body::Body, 4 + http::{Request, StatusCode}, 5 + routing::post, 6 + }; 7 + use reqwest::header::CONTENT_TYPE; 8 + use serde_json::Value; 9 + use std::path::PathBuf; 10 + use tokio::fs; 11 + use tower::ServiceExt; 12 + 13 + fn get_test_data_path(filename: &str) -> PathBuf { 14 + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 15 + path.push("tests/data"); 16 + path.push(filename); 17 + path 18 + } 19 + 20 + #[tokio::test] 21 + async fn test_import_lecture_pdf() { 22 + let app = Router::new().route( 23 + "/api/import/lecture", 24 + post(malfestio_server::api::importer::post_import_lecture), 25 + ); 26 + 27 + let path = get_test_data_path("1904.09828v2.pdf"); 28 + let file_bytes = fs::read(&path).await.expect("Failed to read test PDF"); 29 + let boundary = "------------------------boundary123"; 30 + let body_data = format!( 31 + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"lecture.pdf\"\r\nContent-Type: application/pdf\r\n\r\n", 32 + boundary = boundary 33 + ); 34 + let mut full_body = body_data.into_bytes(); 35 + full_body.extend_from_slice(&file_bytes); 36 + full_body.extend_from_slice(format!("\r\n--{boundary}--\r\n", boundary = boundary).as_bytes()); 37 + 38 + let req = Request::builder() 39 + .method("POST") 40 + .uri("/api/import/lecture") 41 + .header(CONTENT_TYPE, format!("multipart/form-data; boundary={}", boundary)) 42 + .body(Body::from(full_body)) 43 + .unwrap(); 44 + 45 + let response = app.oneshot(req).await.unwrap(); 46 + let status = response.status(); 47 + 48 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 49 + 50 + if status != StatusCode::OK { 51 + let body_str = String::from_utf8_lossy(&body_bytes); 52 + println!("Test PDF Failed. Status: {}, Body: {}", status, body_str); 53 + panic!("Status was not 200 OK"); 54 + } 55 + 56 + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); 57 + 58 + assert_eq!(body["filename"], "lecture.pdf"); 59 + 60 + let content = body["content"].as_str().unwrap(); 61 + assert!(content.contains("Magic: The Gathering")); 62 + 63 + let chunks = body["chunks"].as_array().unwrap(); 64 + assert!(!chunks.is_empty(), "Should have at least one chunk"); 65 + 66 + let has_abstract = chunks.iter().any(|c| { 67 + c["heading"].as_str().unwrap_or("").to_lowercase().contains("abstract") 68 + || c["content"].as_str().unwrap_or("").contains("Abstract") 69 + }); 70 + assert!(has_abstract, "Should contain 'Abstract' in chunks (heading or content)"); 71 + } 72 + 73 + #[tokio::test] 74 + async fn test_import_lecture_docx() { 75 + let app = Router::new().route( 76 + "/api/import/lecture", 77 + post(malfestio_server::api::importer::post_import_lecture), 78 + ); 79 + 80 + let boundary = "------------------------boundary123"; 81 + let body_data = format!( 82 + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes.docx\"\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n\r\n", 83 + boundary = boundary 84 + ); 85 + let mut full_body = body_data.into_bytes(); 86 + full_body.extend_from_slice(b"fake docx content"); 87 + full_body.extend_from_slice(format!("\r\n--{boundary}--\r\n", boundary = boundary).as_bytes()); 88 + 89 + let req = Request::builder() 90 + .method("POST") 91 + .uri("/api/import/lecture") 92 + .header(CONTENT_TYPE, format!("multipart/form-data; boundary={}", boundary)) 93 + .body(Body::from(full_body)) 94 + .unwrap(); 95 + 96 + let response = app.oneshot(req).await.unwrap(); 97 + 98 + assert_eq!(response.status(), StatusCode::OK); 99 + 100 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 101 + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); 102 + 103 + assert!( 104 + body["content"] 105 + .as_str() 106 + .unwrap() 107 + .contains("DOCX parsing not yet implemented") 108 + ); 109 + } 110 + 111 + #[tokio::test] 112 + async fn test_import_lecture_no_file() { 113 + let app = Router::new().route( 114 + "/api/import/lecture", 115 + post(malfestio_server::api::importer::post_import_lecture), 116 + ); 117 + 118 + let boundary = "------------------------boundary123"; 119 + let full_body = format!("--{boundary}--\r\n", boundary = boundary); 120 + 121 + let req = Request::builder() 122 + .method("POST") 123 + .uri("/api/import/lecture") 124 + .header(CONTENT_TYPE, format!("multipart/form-data; boundary={}", boundary)) 125 + .body(Body::from(full_body)) 126 + .unwrap(); 127 + 128 + let response = app.oneshot(req).await.unwrap(); 129 + let status = response.status(); 130 + 131 + if status != StatusCode::BAD_REQUEST { 132 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 133 + println!( 134 + "Test No File Failed. Status: {}, Body: {}", 135 + status, 136 + String::from_utf8_lossy(&body_bytes) 137 + ); 138 + } 139 + assert_eq!(status, StatusCode::BAD_REQUEST); 140 + }
+106
web/src/components/import/FileDropZone.tsx
··· 1 + import clsx from "clsx"; 2 + import { createSignal } from "solid-js"; 3 + 4 + type FileInputProps = { 5 + onFileSelect: (file: File) => void; 6 + onError?: (message: string) => void; 7 + accept?: string; 8 + disabled?: boolean; 9 + maxSize?: number; // in bytes 10 + }; 11 + 12 + export default function FileDropZone(props: FileInputProps) { 13 + const [isDragOver, setIsDragOver] = createSignal(false); 14 + let inputRef: HTMLInputElement | undefined; 15 + 16 + const validateAndSelect = (file: File) => { 17 + if (props.maxSize && file.size > props.maxSize) { 18 + props.onError?.(`File size exceeds limit of ${(props.maxSize / (1024 * 1024)).toFixed(0)}MB`); 19 + return; 20 + } 21 + props.onFileSelect(file); 22 + }; 23 + 24 + const handleDragOver = (e: DragEvent) => { 25 + e.preventDefault(); 26 + if (!props.disabled) { 27 + setIsDragOver(true); 28 + } 29 + }; 30 + 31 + const handleDragLeave = () => { 32 + setIsDragOver(false); 33 + }; 34 + 35 + const handleDrop = (e: DragEvent) => { 36 + e.preventDefault(); 37 + setIsDragOver(false); 38 + 39 + if (props.disabled) return; 40 + 41 + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { 42 + validateAndSelect(e.dataTransfer.files[0]); 43 + } 44 + }; 45 + 46 + const handleClick = () => { 47 + if (!props.disabled) { 48 + inputRef?.click(); 49 + } 50 + }; 51 + 52 + const handleInputChange = (e: Event) => { 53 + const target = e.target as HTMLInputElement; 54 + if (target.files && target.files.length > 0) { 55 + validateAndSelect(target.files[0]); 56 + } 57 + }; 58 + 59 + return ( 60 + <div 61 + class={clsx( 62 + "border-2 border-dashed rounded-xl p-8 transition-colors cursor-pointer flex flex-col items-center justify-center text-center gap-4", 63 + isDragOver() 64 + ? "border-accent-500 bg-accent-500/10" 65 + : "border-neutral-700 hover:border-neutral-600 bg-neutral-800/50 hover:bg-neutral-800", 66 + props.disabled && "opacity-50 cursor-not-allowed", 67 + )} 68 + onDragOver={handleDragOver} 69 + onDragLeave={handleDragLeave} 70 + onDrop={handleDrop} 71 + onClick={handleClick}> 72 + <input 73 + type="file" 74 + data-testid="file-upload-input" 75 + ref={inputRef} 76 + class="hidden" 77 + accept={props.accept} 78 + onChange={handleInputChange} 79 + disabled={props.disabled} /> 80 + 81 + {/* TODO: replace with i-* icon */} 82 + <div class="p-4 rounded-full bg-neutral-700/50"> 83 + <svg 84 + xmlns="http://www.w3.org/2000/svg" 85 + width="32" 86 + height="32" 87 + viewBox="0 0 24 24" 88 + fill="none" 89 + stroke="currentColor" 90 + stroke-width="2" 91 + stroke-linecap="round" 92 + stroke-linejoin="round" 93 + class="text-neutral-400"> 94 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 95 + <polyline points="17 8 12 3 7 8" /> 96 + <line x1="12" x2="12" y1="3" y2="15" /> 97 + </svg> 98 + </div> 99 + 100 + <div class="space-y-1"> 101 + <p class="text-lg font-medium">Click to upload or drag and drop</p> 102 + <p class="text-sm text-neutral-400">PDF or DOCX (max 10MB)</p> 103 + </div> 104 + </div> 105 + ); 106 + }
+56
web/src/components/import/tests/FileDropZone.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import FileDropZone from "../FileDropZone"; 4 + 5 + describe("FileDropZone", () => { 6 + afterEach(cleanup); 7 + 8 + it("renders correctly", () => { 9 + render(() => <FileDropZone onFileSelect={() => {}} />); 10 + expect(screen.getByText("Click to upload or drag and drop")).toBeInTheDocument(); 11 + expect(screen.getByText("PDF or DOCX (max 10MB)")).toBeInTheDocument(); 12 + }); 13 + 14 + it("handles file selection via input change", () => { 15 + const onFileSelectRequest = vi.fn(); 16 + render(() => <FileDropZone onFileSelect={onFileSelectRequest} />); 17 + 18 + const file = new File(["dummy content"], "test.pdf", { type: "application/pdf" }); 19 + const input = screen.getByTestId("file-upload-input"); 20 + 21 + Object.defineProperty(input, "files", { value: [file] }); 22 + 23 + fireEvent.change(input); 24 + 25 + expect(onFileSelectRequest).toHaveBeenCalledWith(file); 26 + }); 27 + 28 + it("handles drag and drop", () => { 29 + const onFileSelectRequest = vi.fn(); 30 + render(() => <FileDropZone onFileSelect={onFileSelectRequest} />); 31 + 32 + const dropZone = screen.getByText("Click to upload or drag and drop").closest("div"); 33 + expect(dropZone).not.toBeNull(); 34 + 35 + const file = new File(["dummy content"], "lecture.pdf", { type: "application/pdf" }); 36 + 37 + fireEvent.drop(dropZone!, { dataTransfer: { files: [file] } }); 38 + 39 + expect(onFileSelectRequest).toHaveBeenCalledWith(file); 40 + }); 41 + 42 + it("validates file size", () => { 43 + const onFileSelectRequest = vi.fn(); 44 + const onErrorRequest = vi.fn(); 45 + render(() => <FileDropZone onFileSelect={onFileSelectRequest} onError={onErrorRequest} maxSize={10} />); // 10 bytes limit 46 + 47 + const file = new File(["content larger than 10 bytes"], "large.pdf", { type: "application/pdf" }); 48 + const input = screen.getByTestId("file-upload-input"); 49 + 50 + Object.defineProperty(input, "files", { value: [file] }); 51 + fireEvent.change(input); 52 + 53 + expect(onFileSelectRequest).not.toHaveBeenCalled(); 54 + expect(onErrorRequest).toHaveBeenCalledWith(expect.stringContaining("File size exceeds limit")); 55 + }); 56 + });
+2 -2
web/src/components/tests/OnboardingDialog.test.tsx
··· 56 56 { 57 57 ok: true, 58 58 json: () => Promise.resolve({ persona: "creator", onboarding_completed_at: "2024-01-01" }), 59 - } as unknown as Response, 59 + } as Response, 60 60 ); 61 61 62 62 const onComplete = vi.fn(); ··· 78 78 const { api } = await import("$lib/api"); 79 79 vi.mocked(api.updatePreferences).mockImplementation(() => 80 80 new Promise((resolve) => 81 - setTimeout(() => resolve({ ok: true, json: () => Promise.resolve({}) } as unknown as Response), 100) 81 + setTimeout(() => resolve({ ok: true, json: () => Promise.resolve({}) } as Response), 100) 82 82 ) 83 83 ); 84 84
+3 -2
web/src/lib/api.ts
··· 1 - import type { CreateDeckPayload } from "./model"; 1 + import type { CreateDeckPayload, CreateNotePayload } from "./model"; 2 2 import { authStore } from "./store"; 3 3 4 4 const API_BASE = "/api"; ··· 47 47 exportData: (collection: "decks" | "notes") => apiFetch(`/export/${collection}`, { method: "GET" }), 48 48 getNotes: () => apiFetch("/notes", { method: "GET" }), 49 49 getNote: (id: string) => apiFetch(`/notes/${id}`, { method: "GET" }), 50 + createNote: (payload: CreateNotePayload) => apiFetch("/notes", { method: "POST", body: JSON.stringify(payload) }), 50 51 deleteNote: (id: string) => apiFetch(`/notes/${id}`, { method: "DELETE" }), 51 52 updateNote: (id: string, payload: object) => { 52 53 return apiFetch(`/notes/${id}`, { method: "PUT", body: JSON.stringify(payload) }); ··· 101 102 } 102 103 return res; 103 104 }, 104 - // TODO: type visibility 105 + // TODO: type check visibility 105 106 saveImportedArticle: (payload: { url: string; tags?: string[]; visibility?: unknown }) => { 106 107 return apiFetch("/import/article/save", { method: "POST", body: JSON.stringify(payload) }); 107 108 },
+2
web/src/lib/model.ts
··· 135 135 complete_onboarding?: boolean; 136 136 tutorial_deck_completed?: boolean; 137 137 }; 138 + 139 + export type CreateNotePayload = { title: string; body: string; tags: string[]; visibility: { type: string } };
+192 -77
web/src/pages/LectureImport.tsx
··· 1 - import { NoteEditor } from "$components/NoteEditor"; 2 - import { Button } from "$ui/Button"; 3 - import { createSignal, Show } from "solid-js"; 1 + import FileDropZone from "$components/import/FileDropZone"; 2 + import { api } from "$lib/api"; 3 + import { toast } from "$lib/toast"; 4 + import { createSignal, For, Show } from "solid-js"; 5 + 6 + type Chunk = { heading: string; content: string }; 7 + type ImportResponse = { filename: string; content: string; chunks: Chunk[] }; 4 8 5 9 export default function LectureImport() { 6 - const [url, setUrl] = createSignal(""); 7 - const [title, setTitle] = createSignal(""); 8 - const [outline, setOutline] = createSignal(""); 9 - const [timestamps, setTimestamps] = createSignal(""); 10 - const [showEditor, setShowEditor] = createSignal(false); 10 + const [file, setFile] = createSignal<File | null>(null); 11 + const [loading, setLoading] = createSignal(false); 12 + const [error, setError] = createSignal<string | null>(null); 13 + const [result, setResult] = createSignal<ImportResponse | null>(null); 14 + const [saving, setSaving] = createSignal<string | null>(null); 15 + 16 + const handleFileSelect = async (selectedFile: File) => { 17 + setFile(selectedFile); 18 + setError(null); 19 + setLoading(true); 20 + 21 + const formData = new FormData(); 22 + formData.append("file", selectedFile); 23 + 24 + try { 25 + const response = await fetch("/api/import/lecture", { method: "POST", body: formData }); 26 + 27 + if (!response.ok) { 28 + throw new Error(`Upload failed: ${response.statusText}`); 29 + } 30 + 31 + const data: ImportResponse = await response.json(); 32 + setResult(data); 33 + } catch (err) { 34 + console.error(err); 35 + setError(err instanceof Error ? err.message : "An unknown error occurred"); 36 + setFile(null); 37 + } finally { 38 + setLoading(false); 39 + } 40 + }; 41 + 42 + const handleError = (msg: string) => { 43 + setError(msg); 44 + }; 45 + 46 + const handleReset = () => { 47 + setFile(null); 48 + setResult(null); 49 + setError(null); 50 + }; 51 + 52 + const createNote = async (chunk: Chunk) => { 53 + try { 54 + const res = await api.createNote({ 55 + title: chunk.heading || "Untitled Chunk", 56 + body: chunk.content, 57 + tags: ["lecture-import"], 58 + visibility: { type: "Private" }, 59 + }); 60 + if (!res.ok) throw new Error("Failed to create note"); 61 + toast.success("Note created!"); 62 + return true; 63 + } catch (e) { 64 + console.error(e); 65 + toast.error("Failed to save note"); 66 + return false; 67 + } 68 + }; 11 69 12 - const handleCreate = (e: Event) => { 13 - e.preventDefault(); 14 - setShowEditor(true); 70 + const handleSaveNote = async (chunk: Chunk, index: number) => { 71 + setSaving(index.toString()); 72 + await createNote(chunk); 73 + setSaving(null); 15 74 }; 16 75 17 - const buildContent = () => { 18 - let content = ""; 19 - if (url()) { 20 - content += `Source: [Lecture](${url()})\n\n`; 76 + const handleSaveAllNotes = async () => { 77 + const data = result(); 78 + if (!data) return; 79 + 80 + setSaving("all"); 81 + let successCount = 0; 82 + for (const chunk of data.chunks) { 83 + const success = await createNote(chunk); 84 + if (success) successCount++; 21 85 } 22 - if (timestamps()) { 23 - content += "## Timestamps\n\n"; 24 - timestamps().split("\n").filter(t => t.trim()).forEach(t => { 25 - content += `- ${t.trim()}\n`; 86 + setSaving(null); 87 + if (successCount > 0) { 88 + toast.success(`Saved ${successCount} notes`); 89 + } 90 + }; 91 + 92 + const handleCreateFlashcards = async () => { 93 + const data = result(); 94 + if (!data) return; 95 + 96 + setSaving("cards"); 97 + try { 98 + const cards = data.chunks.map((chunk) => ({ 99 + front: chunk.heading || "Untitled Section", 100 + back: chunk.content, 101 + mediaUrl: undefined, 102 + })); 103 + 104 + const res = await api.createDeck({ 105 + title: `Flashcards: ${data.filename}`, 106 + description: `Imported from ${data.filename}`, 107 + visibility: { type: "Private" }, 108 + cards, 109 + tags: ["lecture-import"], 26 110 }); 27 - content += "\n"; 111 + 112 + if (res.ok) { 113 + toast.success("Deck created with flashcards!"); 114 + } else { 115 + throw new Error("Failed to create deck"); 116 + } 117 + } catch (e) { 118 + console.error(e); 119 + toast.error("Failed to create flashcards"); 120 + } finally { 121 + setSaving(null); 28 122 } 29 - if (outline()) { 30 - content += "## Outline\n\n"; 31 - content += outline(); 32 - } 33 - return content; 34 123 }; 35 124 36 125 return ( 37 - <div class="max-w-4xl mx-auto space-y-8"> 126 + <div class="max-w-4xl mx-auto p-6 space-y-8"> 38 127 <div class="space-y-2"> 39 - <h1 class="text-3xl font-light text-[#F4F4F4]">Import Lecture</h1> 40 - <p class="text-[#C6C6C6]">Create notes from lecture videos with outlines and timestamps.</p> 128 + <h1 class="text-3xl font-bold tracking-tight text-white">Import Lecture Notes</h1> 129 + <p class="text-neutral-400">Upload a PDF or DOCX file to extract text and generate chunks for flashcards.</p> 41 130 </div> 42 131 43 - <Show when={!showEditor()}> 44 - <form onSubmit={handleCreate} class="space-y-6 p-6 border border-gray-800 rounded bg-gray-900/40"> 45 - <div> 46 - <label class="block text-sm font-medium text-gray-400 mb-1">Lecture URL</label> 47 - <input 48 - type="url" 49 - value={url()} 50 - onInput={(e) => setUrl(e.target.value)} 51 - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 52 - placeholder="https://youtube.com/watch?v=... or lecture platform URL" /> 53 - </div> 132 + <Show when={error()}> 133 + <div class="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400">{error()}</div> 134 + </Show> 54 135 55 - <div> 56 - <label class="block text-sm font-medium text-gray-400 mb-1">Title</label> 57 - <input 58 - type="text" 59 - value={title()} 60 - onInput={(e) => setTitle(e.target.value)} 61 - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 62 - placeholder="Lecture title" 63 - required /> 136 + <Show 137 + when={result()} 138 + fallback={ 139 + <div class="space-y-4"> 140 + <Show when={loading()}> 141 + <div class="flex flex-col items-center py-12 space-y-4"> 142 + <div class="w-8 h-8 border-4 border-accent-500 border-t-transparent rounded-full animate-spin" /> 143 + <p class="text-neutral-400">Processing {file()?.name}...</p> 144 + </div> 145 + </Show> 146 + <Show when={!loading()}> 147 + <FileDropZone 148 + onFileSelect={handleFileSelect} 149 + onError={handleError} 150 + accept=".pdf,.docx,.txt" 151 + maxSize={10 * 1024 * 1024} /> 152 + </Show> 64 153 </div> 65 - 66 - <div> 67 - <label class="block text-sm font-medium text-gray-400 mb-1">Timestamps (one per line)</label> 68 - <textarea 69 - value={timestamps()} 70 - onInput={(e) => setTimestamps(e.target.value)} 71 - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" 72 - placeholder="0:00 Introduction&#10;5:30 Main Topic&#10;15:00 Examples&#10;30:00 Summary" 73 - rows={5} /> 74 - </div> 75 - 76 - <div> 77 - <label class="block text-sm font-medium text-gray-400 mb-1">Outline (Markdown)</label> 78 - <textarea 79 - value={outline()} 80 - onInput={(e) => setOutline(e.target.value)} 81 - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" 82 - placeholder="# Key Concepts&#10;&#10;- Point 1&#10;- Point 2&#10;&#10;## Details&#10;&#10;Write your notes here..." 83 - rows={10} /> 154 + }> 155 + <div class="space-y-6"> 156 + <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-xl bg-neutral-800/50 border border-neutral-700"> 157 + <div> 158 + <h2 class="text-xl font-semibold text-white">Extracted Content</h2> 159 + <p class="text-sm text-neutral-400">from {file()?.name}</p> 160 + </div> 161 + <div class="flex flex-wrap gap-2"> 162 + <button 163 + onClick={handleSaveAllNotes} 164 + disabled={!!saving()} 165 + class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"> 166 + <span class="i-ri-file-text-line" /> 167 + {saving() === "all" ? "Saving..." : "Save All to Notes"} 168 + </button> 169 + <button 170 + onClick={handleCreateFlashcards} 171 + disabled={!!saving()} 172 + class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"> 173 + <span class="i-ri-gallery-view-2" /> 174 + {saving() === "cards" ? "Creating..." : "Create Flashcards"} 175 + </button> 176 + <button 177 + onClick={handleReset} 178 + disabled={!!saving()} 179 + class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white bg-neutral-700 hover:bg-neutral-600 rounded-lg transition-colors"> 180 + Import Another 181 + </button> 182 + </div> 84 183 </div> 85 184 86 - <Button type="submit">Create Note from Lecture</Button> 87 - </form> 88 - </Show> 89 - 90 - <Show when={showEditor()}> 91 - <div class="border-t border-gray-800 pt-8"> 92 - <div class="flex justify-between items-center mb-4"> 93 - <h2 class="text-xl font-semibold text-white">Edit Lecture Note</h2> 94 - <Button variant="ghost" onClick={() => setShowEditor(false)}>← Back</Button> 185 + <div class="grid gap-6"> 186 + <For each={result()?.chunks}> 187 + {(chunk, index) => ( 188 + <div class="relative group p-6 rounded-xl bg-neutral-800/30 border border-neutral-700/50 hover:border-neutral-600 transition-colors space-y-3"> 189 + <div class="flex items-center justify-between gap-4"> 190 + <div class="flex items-center gap-2 overflow-hidden"> 191 + <span class="shrink-0 px-2 py-1 text-xs font-medium uppercase tracking-wider text-accent-400 bg-accent-400/10 rounded"> 192 + Section 193 + </span> 194 + <h3 class="font-medium text-lg text-white truncate" title={chunk.heading}>{chunk.heading}</h3> 195 + </div> 196 + <button 197 + onClick={() => handleSaveNote(chunk, index())} 198 + disabled={!!saving()} 199 + class="shrink-0 opacity-0 group-hover:opacity-100 focus:opacity-100 px-3 py-1.5 text-xs font-medium text-neutral-300 hover:text-white bg-neutral-700/50 hover:bg-neutral-600 rounded transition-all flex items-center gap-1.5" 200 + title="Save this chunk as a note"> 201 + <span class="i-ri-save-line" /> 202 + {saving() === index().toString() ? "Saving..." : "Save Note"} 203 + </button> 204 + </div> 205 + <div class="prose prose-invert max-w-none text-neutral-300 text-sm whitespace-pre-wrap"> 206 + {chunk.content} 207 + </div> 208 + </div> 209 + )} 210 + </For> 95 211 </div> 96 - <NoteEditor initialTitle={title()} initialContent={buildContent()} /> 97 212 </div> 98 213 </Show> 99 214 </div>
+103
web/src/pages/tests/LectureImport.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; 3 + import LectureImport from "../LectureImport"; 4 + 5 + vi.mock( 6 + "$components/import/FileDropZone", 7 + () => ({ 8 + default: (props: { onFileSelect: (f: File) => void }) => ( 9 + <div data-testid="mock-dropzone"> 10 + <button onClick={() => props.onFileSelect(new File(["content"], "test.pdf"))}>Mock Upload</button> 11 + </div> 12 + ), 13 + }), 14 + ); 15 + 16 + vi.mock("$lib/api", () => ({ api: { createNote: vi.fn(), createDeck: vi.fn() } })); 17 + 18 + describe("LectureImport", () => { 19 + beforeEach(() => { 20 + vi.clearAllMocks(); 21 + globalThis.fetch = vi.fn(); 22 + }); 23 + 24 + afterEach(cleanup); 25 + 26 + it("renders the import page", () => { 27 + render(() => <LectureImport />); 28 + expect(screen.getByText("Import Lecture Notes")).toBeInTheDocument(); 29 + expect(screen.getByTestId("mock-dropzone")).toBeInTheDocument(); 30 + }); 31 + 32 + it("handles successful file upload and persistence actions", async () => { 33 + const { api } = await import("$lib/api"); 34 + vi.mocked(api.createNote).mockResolvedValue({ ok: true } as Response); 35 + vi.mocked(api.createDeck).mockResolvedValue({ ok: true } as Response); 36 + 37 + const mockResponse = { 38 + filename: "test.pdf", 39 + content: "Full extracted content", 40 + chunks: [{ heading: "Abstract", content: "This is the abstract." }, { 41 + heading: "Introduction", 42 + content: "Intro content.", 43 + }], 44 + }; 45 + 46 + (globalThis.fetch as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockResponse) }); 47 + 48 + render(() => <LectureImport />); 49 + 50 + fireEvent.click(screen.getByText("Mock Upload")); 51 + 52 + await waitFor(() => { 53 + expect(screen.getByText("Extracted Content")).toBeInTheDocument(); 54 + expect(screen.getByText("from test.pdf")).toBeInTheDocument(); 55 + }); 56 + 57 + const saveButtons = screen.getAllByTitle("Save this chunk as a note"); 58 + fireEvent.click(saveButtons[0]); 59 + await waitFor(() => { 60 + expect(api.createNote).toHaveBeenCalledWith({ 61 + title: "Abstract", 62 + body: "This is the abstract.", 63 + tags: ["lecture-import"], 64 + visibility: { type: "Private" }, 65 + }); 66 + }); 67 + 68 + const saveAllButton = screen.getByText("Save All to Notes"); 69 + await waitFor(() => { 70 + expect(saveAllButton).not.toBeDisabled(); 71 + }); 72 + 73 + vi.mocked(api.createNote).mockClear(); 74 + fireEvent.click(saveAllButton); 75 + await waitFor(() => { 76 + expect(api.createNote).toHaveBeenCalledTimes(2); 77 + }); 78 + 79 + fireEvent.click(screen.getByText("Create Flashcards")); 80 + await waitFor(() => { 81 + expect(api.createDeck).toHaveBeenCalledWith( 82 + expect.objectContaining({ 83 + title: "Flashcards: test.pdf", 84 + cards: expect.arrayContaining([ 85 + expect.objectContaining({ front: "Abstract", back: "This is the abstract." }), 86 + expect.objectContaining({ front: "Introduction", back: "Intro content." }), 87 + ]), 88 + }), 89 + ); 90 + }); 91 + }); 92 + 93 + it("handles upload error", async () => { 94 + (globalThis.fetch as Mock).mockResolvedValue({ ok: false, statusText: "Server Error" }); 95 + 96 + render(() => <LectureImport />); 97 + fireEvent.click(screen.getByText("Mock Upload")); 98 + 99 + await waitFor(() => { 100 + expect(screen.getByText("Upload failed: Server Error")).toBeInTheDocument(); 101 + }); 102 + }); 103 + });