chore: added /feeds route back d1b7593f
Steve · 2026-04-18 07:35 2 file(s) · +92 −58
apps/feeds/src/main.rs +91 −57
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)
apps/feeds/src/templates/admin.html +1 −1
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>