Compare commits

..

4 Commits

Author SHA1 Message Date
Greg Shuflin
89f249a3d5 TODO 2025-02-16 01:49:42 -08:00
Greg Shuflin
059fd1a50d Show status of importing opml 2025-02-16 01:48:38 -08:00
Greg Shuflin
c138940250 Job status work 2025-02-16 01:35:49 -08:00
Greg Shuflin
2874d3a885 Fix some unused stuff 2025-02-16 01:13:29 -08:00
8 changed files with 240 additions and 36 deletions

20
TODO.md
View File

@ -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

View File

@ -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
})?;

View File

@ -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))
}

View File

@ -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
}
})
}
}

View File

@ -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"))

View File

@ -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;

View File

@ -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();

View File

@ -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">