+24
Cargo.lock
+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
+1
-1
crates/server/Cargo.toml
+66
-1
crates/server/src/api/importer.rs
+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
+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
crates/server/src/import/mod.rs
+14
-7
crates/server/src/lib.rs
+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
+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
+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
+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
+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
+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
+2
web/src/lib/model.ts
+192
-77
web/src/pages/LectureImport.tsx
+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 5:30 Main Topic 15:00 Examples 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 - Point 1 - Point 2 ## Details 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
+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
+
});