chore: added /feeds route back
d1b7593f
2 file(s) · +92 −58
| 166 | 166 | .into_response() |
|
| 167 | 167 | } |
|
| 168 | 168 | ||
| 169 | - | /// Export current subscriptions as OPML. |
|
| 170 | - | async fn feeds_opml_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 169 | + | /// Export current subscriptions. `?format=json` (default) or `?format=opml`. |
|
| 170 | + | async fn feeds_handler( |
|
| 171 | + | State(state): State<Arc<AppState>>, |
|
| 172 | + | Query(params): Query<HashMap<String, String>>, |
|
| 173 | + | ) -> Response { |
|
| 174 | + | let format = params |
|
| 175 | + | .get("format") |
|
| 176 | + | .map(|s| s.as_str()) |
|
| 177 | + | .unwrap_or("json"); |
|
| 178 | + | ||
| 171 | 179 | let subs = match fdb::list_subscriptions(&state.db) { |
|
| 172 | 180 | Ok(s) => s, |
|
| 173 | 181 | Err(e) => { |
|
| 174 | - | tracing::error!("opml export failed: {e}"); |
|
| 182 | + | tracing::error!("feeds export failed: {e}"); |
|
| 175 | 183 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 176 | 184 | } |
|
| 177 | 185 | }; |
|
| 178 | - | let cats: HashMap<i64, String> = fdb::list_categories(&state.db) |
|
| 179 | - | .unwrap_or_default() |
|
| 180 | - | .into_iter() |
|
| 181 | - | .map(|c| (c.id, c.name)) |
|
| 182 | - | .collect(); |
|
| 183 | 186 | ||
| 184 | - | let now = chrono::Utc::now().to_rfc2822(); |
|
| 185 | - | let mut by_cat: HashMap<String, Vec<&fdb::Subscription>> = HashMap::new(); |
|
| 186 | - | for sub in &subs { |
|
| 187 | - | let key = sub |
|
| 188 | - | .category_id |
|
| 189 | - | .and_then(|id| cats.get(&id).cloned()) |
|
| 190 | - | .unwrap_or_default(); |
|
| 191 | - | by_cat.entry(key).or_default().push(sub); |
|
| 192 | - | } |
|
| 187 | + | match format { |
|
| 188 | + | "json" => { |
|
| 189 | + | let subscriptions: Vec<_> = subs |
|
| 190 | + | .iter() |
|
| 191 | + | .map(|s| { |
|
| 192 | + | serde_json::json!({ |
|
| 193 | + | "id": format!("feed/{}", s.id), |
|
| 194 | + | "title": s.title, |
|
| 195 | + | "url": s.feed_url, |
|
| 196 | + | "htmlUrl": s.site_url.clone().unwrap_or_default(), |
|
| 197 | + | }) |
|
| 198 | + | }) |
|
| 199 | + | .collect(); |
|
| 200 | + | Json(serde_json::json!({ "subscriptions": subscriptions })).into_response() |
|
| 201 | + | } |
|
| 202 | + | "opml" => { |
|
| 203 | + | let cats: HashMap<i64, String> = fdb::list_categories(&state.db) |
|
| 204 | + | .unwrap_or_default() |
|
| 205 | + | .into_iter() |
|
| 206 | + | .map(|c| (c.id, c.name)) |
|
| 207 | + | .collect(); |
|
| 193 | 208 | ||
| 194 | - | let mut opml = format!( |
|
| 195 | - | "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n <head>\n <title>Feeds</title>\n <dateCreated>{now}</dateCreated>\n </head>\n <body>\n" |
|
| 196 | - | ); |
|
| 209 | + | let now = chrono::Utc::now().to_rfc2822(); |
|
| 210 | + | let mut by_cat: HashMap<String, Vec<&fdb::Subscription>> = HashMap::new(); |
|
| 211 | + | for sub in &subs { |
|
| 212 | + | let key = sub |
|
| 213 | + | .category_id |
|
| 214 | + | .and_then(|id| cats.get(&id).cloned()) |
|
| 215 | + | .unwrap_or_default(); |
|
| 216 | + | by_cat.entry(key).or_default().push(sub); |
|
| 217 | + | } |
|
| 197 | 218 | ||
| 198 | - | let mut keys: Vec<&String> = by_cat.keys().collect(); |
|
| 199 | - | keys.sort(); |
|
| 200 | - | for key in keys { |
|
| 201 | - | let subs = &by_cat[key]; |
|
| 202 | - | let indent = if key.is_empty() { " " } else { " " }; |
|
| 203 | - | if !key.is_empty() { |
|
| 204 | - | opml.push_str(&format!( |
|
| 205 | - | " <outline text=\"{}\" title=\"{}\">\n", |
|
| 206 | - | escape_xml(key), |
|
| 207 | - | escape_xml(key) |
|
| 208 | - | )); |
|
| 209 | - | } |
|
| 210 | - | for sub in subs { |
|
| 211 | - | opml.push_str(&format!( |
|
| 212 | - | "{indent}<outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n", |
|
| 213 | - | escape_xml(&sub.title), |
|
| 214 | - | escape_xml(&sub.title), |
|
| 215 | - | escape_xml(&sub.feed_url), |
|
| 216 | - | escape_xml(sub.site_url.as_deref().unwrap_or("")), |
|
| 217 | - | )); |
|
| 218 | - | } |
|
| 219 | - | if !key.is_empty() { |
|
| 220 | - | opml.push_str(" </outline>\n"); |
|
| 221 | - | } |
|
| 222 | - | } |
|
| 219 | + | let mut opml = format!( |
|
| 220 | + | "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n <head>\n <title>Feeds</title>\n <dateCreated>{now}</dateCreated>\n </head>\n <body>\n" |
|
| 221 | + | ); |
|
| 222 | + | ||
| 223 | + | let mut keys: Vec<&String> = by_cat.keys().collect(); |
|
| 224 | + | keys.sort(); |
|
| 225 | + | for key in keys { |
|
| 226 | + | let subs = &by_cat[key]; |
|
| 227 | + | let indent = if key.is_empty() { " " } else { " " }; |
|
| 228 | + | if !key.is_empty() { |
|
| 229 | + | opml.push_str(&format!( |
|
| 230 | + | " <outline text=\"{}\" title=\"{}\">\n", |
|
| 231 | + | escape_xml(key), |
|
| 232 | + | escape_xml(key) |
|
| 233 | + | )); |
|
| 234 | + | } |
|
| 235 | + | for sub in subs { |
|
| 236 | + | opml.push_str(&format!( |
|
| 237 | + | "{indent}<outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n", |
|
| 238 | + | escape_xml(&sub.title), |
|
| 239 | + | escape_xml(&sub.title), |
|
| 240 | + | escape_xml(&sub.feed_url), |
|
| 241 | + | escape_xml(sub.site_url.as_deref().unwrap_or("")), |
|
| 242 | + | )); |
|
| 243 | + | } |
|
| 244 | + | if !key.is_empty() { |
|
| 245 | + | opml.push_str(" </outline>\n"); |
|
| 246 | + | } |
|
| 247 | + | } |
|
| 223 | 248 | ||
| 224 | - | opml.push_str(" </body>\n</opml>"); |
|
| 249 | + | opml.push_str(" </body>\n</opml>"); |
|
| 225 | 250 | ||
| 226 | - | ( |
|
| 227 | - | [ |
|
| 228 | - | (header::CONTENT_TYPE, "application/xml"), |
|
| 229 | 251 | ( |
|
| 230 | - | header::CONTENT_DISPOSITION, |
|
| 231 | - | "attachment; filename=\"feeds.opml\"", |
|
| 232 | - | ), |
|
| 233 | - | ], |
|
| 234 | - | opml, |
|
| 235 | - | ) |
|
| 236 | - | .into_response() |
|
| 252 | + | [ |
|
| 253 | + | (header::CONTENT_TYPE, "application/xml"), |
|
| 254 | + | ( |
|
| 255 | + | header::CONTENT_DISPOSITION, |
|
| 256 | + | "attachment; filename=\"feeds.opml\"", |
|
| 257 | + | ), |
|
| 258 | + | ], |
|
| 259 | + | opml, |
|
| 260 | + | ) |
|
| 261 | + | .into_response() |
|
| 262 | + | } |
|
| 263 | + | _ => ( |
|
| 264 | + | StatusCode::BAD_REQUEST, |
|
| 265 | + | Json(serde_json::json!({ |
|
| 266 | + | "error": "Invalid format. Use ?format=json or ?format=opml" |
|
| 267 | + | })), |
|
| 268 | + | ) |
|
| 269 | + | .into_response(), |
|
| 270 | + | } |
|
| 237 | 271 | } |
|
| 238 | 272 | ||
| 239 | 273 | fn escape_xml(s: &str) -> String { |
|
| 606 | 640 | ||
| 607 | 641 | let app = Router::new() |
|
| 608 | 642 | .route("/", get(index_handler)) |
|
| 609 | - | .route("/feeds.opml", get(feeds_opml_handler)) |
|
| 643 | + | .route("/feeds", get(feeds_handler)) |
|
| 610 | 644 | .route("/static/{*path}", get(static_handler)) |
|
| 611 | 645 | .merge(admin_router) |
|
| 612 | 646 | .merge(api_router) |
|
| 15 | 15 | <div class="header"> |
|
| 16 | 16 | <a href="/" class="logo"><h1>FEEDS</h1></a> |
|
| 17 | 17 | <nav class="links"> |
|
| 18 | - | <a href="/feeds.opml">opml</a> |
|
| 18 | + | <a href="/feeds?format=opml">opml</a> |
|
| 19 | 19 | <a href="/admin/logout">logout</a> |
|
| 20 | 20 | </nav> |
|
| 21 | 21 | </div> |