Compare commits
4 Commits
85550c318e
...
89f249a3d5
Author | SHA1 | Date | |
---|---|---|---|
|
89f249a3d5 | ||
|
059fd1a50d | ||
|
c138940250 | ||
|
2874d3a885 |
20
TODO.md
20
TODO.md
@ -18,6 +18,26 @@ Current session management is basic and needs improvement:
|
||||
- Consider using Font Awesome's SVG+JS version for better performance
|
||||
- Update CSS and HTML references to use local assets
|
||||
|
||||
## Testing
|
||||
### OPML Import Tests
|
||||
- Add unit tests for OPML parsing and validation
|
||||
- Add integration tests for the import endpoint
|
||||
- Test file size limits
|
||||
- Test invalid XML handling
|
||||
- Test empty OPML files
|
||||
- Test OPML files with no feeds
|
||||
- Test OPML files with invalid feed URLs
|
||||
- Add job status endpoint tests
|
||||
- Test authentication requirements
|
||||
- Test job ownership verification
|
||||
- Test status updates during import
|
||||
- Test completion status
|
||||
- Add UI tests for OPML import flow
|
||||
- Test progress indicator
|
||||
- Test error handling
|
||||
- Test successful import workflow
|
||||
- Test mobile view (job status hidden)
|
||||
|
||||
- [ ] Add a timeout to external RSS feed fetching to prevent hanging on slow feeds
|
||||
- Use reqwest's timeout feature
|
||||
- Consider making the timeout configurable
|
||||
|
@ -1,4 +1,5 @@
|
||||
use feed_rs;
|
||||
use feed_rs::model::Feed;
|
||||
use feed_rs::parser;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
use url::Url;
|
||||
@ -6,7 +7,7 @@ use url::Url;
|
||||
#[derive(Debug)]
|
||||
pub struct FeedError;
|
||||
|
||||
pub async fn fetch_feed(url: &Url) -> Result<feed_rs::model::Feed, FeedError> {
|
||||
pub async fn fetch_feed(url: &Url) -> Result<Feed, FeedError> {
|
||||
info!("Making a request to fetch feed `{url}`");
|
||||
|
||||
// Create a client with a 10 second timeout
|
||||
@ -34,7 +35,7 @@ pub async fn fetch_feed(url: &Url) -> Result<feed_rs::model::Feed, FeedError> {
|
||||
})?;
|
||||
|
||||
// Parse the feed
|
||||
let feed_data = feed_rs::parser::parse(content.as_bytes()).map_err(|e| {
|
||||
let feed_data = parser::parse(content.as_bytes()).map_err(|e| {
|
||||
error!("Failed to parse feed content: {}", e);
|
||||
FeedError
|
||||
})?;
|
||||
|
@ -1,26 +1,21 @@
|
||||
// Module for handling OPML feed list imports
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
|
||||
use quick_xml::de::from_reader;
|
||||
use rocket::data::ToByteUnit;
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::http::Status;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
use rocket::tokio::io::AsyncReadExt;
|
||||
use rocket::{post, Data, State};
|
||||
use rocket_db_pools::Connection;
|
||||
use tokio::spawn;
|
||||
use tracing::{error, info};
|
||||
use url::Url;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::feed_utils::fetch_feed;
|
||||
use crate::feeds::Feed;
|
||||
use crate::jobs::{JobStatus, SharedJobStore};
|
||||
use crate::jobs::{JobStatus, JobType, SharedJobStore};
|
||||
use crate::user::AuthenticatedUser;
|
||||
use crate::Db;
|
||||
|
||||
@ -75,11 +70,6 @@ impl OpmlOutline {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct UploadForm<'f> {
|
||||
file: TempFile<'f>,
|
||||
}
|
||||
|
||||
/// Import feeds from an OPML file
|
||||
#[post("/import/opml", data = "<file>")]
|
||||
pub async fn import_opml(
|
||||
@ -140,7 +130,7 @@ pub async fn import_opml(
|
||||
// Create a background job
|
||||
let job_id = {
|
||||
let mut store = job_store.write().await;
|
||||
store.create_job("opml_import".to_string())
|
||||
store.create_job(JobType::OpmlImport, user.user_id)
|
||||
};
|
||||
|
||||
// Launch background job
|
||||
@ -216,7 +206,7 @@ async fn import_feeds_job(
|
||||
imported_count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Err(_) => {
|
||||
error!("Failed to fetch or parse feed from {url}");
|
||||
}
|
||||
}
|
||||
@ -262,3 +252,39 @@ fn extract_feeds(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct JobStatusResponse {
|
||||
status: String,
|
||||
completed: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
#[get("/jobs/<job_id>")]
|
||||
pub async fn get_job_status(
|
||||
job_id: Uuid,
|
||||
job_store: &State<SharedJobStore>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<JobStatusResponse>, Status> {
|
||||
let store = job_store.read().await;
|
||||
let status = store
|
||||
.get_job_status(job_id, user.user_id)
|
||||
.ok_or(Status::NotFound)?;
|
||||
|
||||
let response = match status {
|
||||
JobStatus::InProgress { completed, total } => JobStatusResponse {
|
||||
status: "in_progress".to_string(),
|
||||
completed,
|
||||
total,
|
||||
},
|
||||
JobStatus::Completed { success_count } => JobStatusResponse {
|
||||
status: "completed".to_string(),
|
||||
completed: success_count,
|
||||
total: success_count,
|
||||
},
|
||||
JobStatus::Failed(_) => return Err(Status::InternalServerError),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
31
src/jobs.rs
31
src/jobs.rs
@ -3,18 +3,30 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum JobType {
|
||||
OpmlImport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum JobStatus {
|
||||
InProgress { completed: usize, total: usize },
|
||||
Completed { success_count: usize },
|
||||
InProgress {
|
||||
completed: usize,
|
||||
total: usize,
|
||||
},
|
||||
Completed {
|
||||
success_count: usize,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Job {
|
||||
pub id: Uuid,
|
||||
pub job_type: String,
|
||||
pub job_type: JobType,
|
||||
pub status: JobStatus,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@ -29,7 +41,7 @@ impl JobStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_job(&mut self, job_type: String) -> Uuid {
|
||||
pub fn create_job(&mut self, job_type: JobType, user_id: Uuid) -> Uuid {
|
||||
let job_id = Uuid::new_v4();
|
||||
self.jobs.insert(
|
||||
job_id,
|
||||
@ -40,6 +52,7 @@ impl JobStore {
|
||||
completed: 0,
|
||||
total: 0,
|
||||
},
|
||||
user_id,
|
||||
},
|
||||
);
|
||||
job_id
|
||||
@ -51,8 +64,14 @@ impl JobStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_job_status(&self, job_id: Uuid) -> Option<JobStatus> {
|
||||
self.jobs.get(&job_id).map(|job| job.status.clone())
|
||||
pub fn get_job_status(&self, job_id: Uuid, user_id: Uuid) -> Option<JobStatus> {
|
||||
self.jobs.get(&job_id).and_then(|job| {
|
||||
if job.user_id == user_id {
|
||||
Some(job.status.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -177,6 +177,7 @@ fn rocket() -> _ {
|
||||
poll::poll_feed,
|
||||
poll::update_entry_state,
|
||||
import::import_opml,
|
||||
import::get_job_status,
|
||||
],
|
||||
)
|
||||
.mount("/static", FileServer::from("static"))
|
||||
|
@ -3,19 +3,76 @@
|
||||
background-color: var(--topbar-bg);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.left-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hamburger-menu {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.add-feed-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.job-status i {
|
||||
color: var(--primary-red);
|
||||
}
|
||||
|
||||
.job-status.completed i {
|
||||
animation: none;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.job-status-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.job-status.fade-out {
|
||||
animation: fadeOut 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.top-bar-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
color: var(--primary-red);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.feed-title-separator {
|
||||
@ -23,6 +80,24 @@
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hamburger-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.top-bar-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* User menu styles */
|
||||
.user-menu {
|
||||
position: relative;
|
||||
|
@ -87,25 +87,81 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showStatusModal(result.message, false);
|
||||
// TODO: Poll job status endpoint when implemented
|
||||
// For now, just refresh the feed list after a delay
|
||||
setTimeout(handleFeeds, 5000);
|
||||
showJobStatus("Validating OPML file...");
|
||||
if (result.job_id) {
|
||||
pollJobStatus(result.job_id);
|
||||
}
|
||||
} else {
|
||||
showStatusModal('OPML import failed: ' + result.message, true);
|
||||
showError('OPML import failed: ' + result.message);
|
||||
}
|
||||
} else {
|
||||
showStatusModal('Failed to import OPML file. Please try again.', true);
|
||||
showError('Failed to import OPML file. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OPML import failed:', error);
|
||||
showStatusModal('Failed to import OPML file. Please try again.', true);
|
||||
showError('Failed to import OPML file. Please try again.');
|
||||
}
|
||||
}
|
||||
// Clear the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
function showJobStatus(message, isCompleted = false) {
|
||||
const jobStatus = document.getElementById('jobStatus');
|
||||
const jobStatusText = jobStatus.querySelector('.job-status-text');
|
||||
|
||||
jobStatus.style.display = 'flex';
|
||||
jobStatusText.textContent = message;
|
||||
|
||||
if (isCompleted) {
|
||||
jobStatus.classList.add('completed');
|
||||
// Hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
jobStatus.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
jobStatus.style.display = 'none';
|
||||
jobStatus.classList.remove('completed', 'fade-out');
|
||||
}, 500);
|
||||
}, 5000);
|
||||
} else {
|
||||
jobStatus.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJobStatus(jobId) {
|
||||
const maxAttempts = 30; // 30 attempts * 2 second delay = 1 minute maximum
|
||||
let attempts = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch(`/jobs/${jobId}`);
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
if (status.status === 'completed') {
|
||||
showJobStatus(`Import completed. Successfully imported ${status.completed} feeds.`, true);
|
||||
handleFeeds();
|
||||
return;
|
||||
} else if (status.status === 'in_progress') {
|
||||
showJobStatus(`Importing feeds... ${status.completed}/${status.total} completed`);
|
||||
if (attempts++ < maxAttempts) {
|
||||
setTimeout(poll, 2000); // Poll every 2 seconds
|
||||
} else {
|
||||
showJobStatus('Import taking longer than expected. Check feeds list in a few minutes.', true);
|
||||
setTimeout(handleFeeds, 5000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to fetch job status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll job status:', error);
|
||||
showError('Failed to check import status. Please refresh the page.');
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
// User menu handlers
|
||||
userMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
@ -66,9 +66,15 @@
|
||||
<button class="hamburger-menu" id="hamburgerMenu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<button class="add-feed-button" id="addFeedButton" title="Add Feed">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<div class="left-controls">
|
||||
<button class="add-feed-button" id="addFeedButton" title="Add Feed">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<div class="job-status" id="jobStatus" style="display: none">
|
||||
<i class="fas fa-sync fa-spin"></i>
|
||||
<span class="job-status-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="top-bar-title">RSS Reader</h1>
|
||||
<div class="user-menu">
|
||||
<button class="user-menu-button" id="userMenuButton">
|
||||
|
Loading…
x
Reference in New Issue
Block a user