Skip to main content

max / balanced_breakfast

10.5 KB · 381 lines History Blame Raw
1 //! Query feed and reader view commands.
2
3 use crate::commands::error::ApiError;
4 use crate::state::AppState;
5 use bb_db::{CreateQueryFeed, QueryCondition, QueryFeedId};
6 use bb_feed::{FeedFilter, FeedGenerator};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 // ── Query feed types ──────────────────────────────────────────────
13
14 /// Response for a single query feed, including a live match count.
15 #[derive(Debug, Clone, Serialize)]
16 #[serde(rename_all = "camelCase")]
17 pub struct QueryFeedResponse {
18 pub id: String,
19 pub name: String,
20 pub rules: Vec<QueryCondition>,
21 pub match_count: i64,
22 }
23
24 /// Input for creating or updating a query feed.
25 #[derive(Debug, Clone, Deserialize)]
26 #[serde(rename_all = "camelCase")]
27 pub struct QueryFeedInput {
28 pub name: String,
29 pub rules: Vec<QueryCondition>,
30 }
31
32 // ── Validation ────────────────────────────────────────────────────
33
34 /// Valid (field, operator) combinations for query conditions.
35 fn validate_condition(c: &QueryCondition) -> Result<(), String> {
36 match (c.field.as_str(), c.operator.as_str()) {
37 ("title", "contains" | "not_contains" | "equals" | "matches_regex") => Ok(()),
38 ("author", "contains" | "equals" | "matches_regex") => Ok(()),
39 ("body", "contains" | "not_contains" | "matches_regex") => Ok(()),
40 ("source", "equals") => Ok(()),
41 ("tag", "equals") => Ok(()),
42 ("starred", "is") => Ok(()),
43 ("unread", "is") => Ok(()),
44 _ => Err(format!(
45 "Invalid condition: field='{}' operator='{}'",
46 c.field, c.operator
47 )),
48 }
49 }
50
51 fn validate_input(input: &QueryFeedInput) -> Result<(), ApiError> {
52 let name = input.name.trim();
53 if name.is_empty() {
54 return Err(ApiError::bad_request("Name is required"));
55 }
56 if name.len() > 200 {
57 return Err(ApiError::bad_request("Name must be 200 characters or fewer"));
58 }
59 for c in &input.rules {
60 validate_condition(c).map_err(ApiError::bad_request)?;
61 }
62 Ok(())
63 }
64
65 /// Count matching items for a set of conditions.
66 async fn count_matches(
67 db: &bb_db::Database,
68 rules: &[QueryCondition],
69 ) -> Result<i64, ApiError> {
70 let filter = FeedFilter::from_conditions(rules.to_vec());
71 let generator = FeedGenerator::new(db.clone()).with_filter(filter);
72 Ok(generator.count().await?)
73 }
74
75 // ── Query feed commands ───────────────────────────────────────────
76
77 /// List all query feeds with match counts.
78 #[tauri::command]
79 #[instrument(skip_all)]
80 pub async fn list_query_feeds(
81 state: State<'_, Arc<AppState>>,
82 ) -> Result<Vec<QueryFeedResponse>, ApiError> {
83 let db = state.orchestrator.database();
84 let feeds = db.query_feeds().list_all().await?;
85
86 let mut responses = Vec::with_capacity(feeds.len());
87 for feed in feeds {
88 let rules = feed.rules_vec();
89 let match_count = count_matches(db, &rules).await?;
90 responses.push(QueryFeedResponse {
91 id: feed.id.to_string(),
92 name: feed.name,
93 rules,
94 match_count,
95 });
96 }
97
98 Ok(responses)
99 }
100
101 /// Create a new query feed.
102 #[tauri::command]
103 #[instrument(skip_all)]
104 pub async fn create_query_feed(
105 state: State<'_, Arc<AppState>>,
106 input: QueryFeedInput,
107 ) -> Result<QueryFeedResponse, ApiError> {
108 validate_input(&input)?;
109
110 let db = state.orchestrator.database();
111 let feed = db
112 .query_feeds()
113 .create(CreateQueryFeed {
114 name: input.name.trim().to_string(),
115 rules: input.rules.clone(),
116 })
117 .await?;
118
119 let rules = feed.rules_vec();
120 let match_count = count_matches(db, &rules).await?;
121
122 Ok(QueryFeedResponse {
123 id: feed.id.to_string(),
124 name: feed.name,
125 rules,
126 match_count,
127 })
128 }
129
130 /// Update a query feed's name and rules.
131 #[tauri::command]
132 #[instrument(skip_all)]
133 pub async fn update_query_feed(
134 state: State<'_, Arc<AppState>>,
135 id: String,
136 name: String,
137 rules: Vec<QueryCondition>,
138 ) -> Result<(), ApiError> {
139 let feed_id: QueryFeedId = id
140 .parse()
141 .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?;
142
143 let input = QueryFeedInput {
144 name: name.clone(),
145 rules: rules.clone(),
146 };
147 validate_input(&input)?;
148
149 let db = state.orchestrator.database();
150 db.query_feeds()
151 .update(feed_id, name.trim(), &rules)
152 .await?;
153
154 Ok(())
155 }
156
157 /// Delete a query feed.
158 #[tauri::command]
159 #[instrument(skip_all)]
160 pub async fn delete_query_feed(
161 state: State<'_, Arc<AppState>>,
162 id: String,
163 ) -> Result<(), ApiError> {
164 let feed_id: QueryFeedId = id
165 .parse()
166 .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?;
167
168 state
169 .orchestrator
170 .database()
171 .query_feeds()
172 .delete(feed_id)
173 .await?;
174
175 Ok(())
176 }
177
178 // ── Reader view ───────────────────────────────────────────────────
179
180 /// Response for reader view extraction.
181 #[derive(Debug, Clone, Serialize)]
182 #[serde(rename_all = "camelCase")]
183 pub struct ReaderViewResponse {
184 pub title: String,
185 pub content: String,
186 pub text_content: String,
187 }
188
189 /// Extract readable article content from a URL using the reader view plugin.
190 #[tauri::command]
191 #[instrument(skip_all)]
192 pub async fn extract_reader_view(
193 state: State<'_, Arc<AppState>>,
194 url: String,
195 ) -> Result<ReaderViewResponse, ApiError> {
196 let plugins_dir = state
197 .orchestrator
198 .plugins()
199 .read()
200 .await
201 .plugins_dir()
202 .to_path_buf();
203 // Validate URL scheme to prevent file:// and other local access.
204 let lower = url.to_ascii_lowercase();
205 if !lower.starts_with("http://") && !lower.starts_with("https://") {
206 return Err(ApiError::bad_request("Only http and https URLs are allowed for reader view"));
207 }
208
209 // Run in a blocking task since Rhai engine and HTTP are synchronous.
210 let result = tokio::task::spawn_blocking(move || {
211 bb_core::rhai_plugin::run_reader_script(&url, &plugins_dir).map_err(|e| {
212 ApiError::plugin(format!("Reader extraction failed: {}", e))
213 })
214 })
215 .await
216 .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))??;
217
218 Ok(ReaderViewResponse {
219 title: result.title,
220 content: result.content,
221 text_content: result.text_content,
222 })
223 }
224
225 #[cfg(test)]
226 mod tests {
227 use super::*;
228
229 #[test]
230 fn validate_title_contains() {
231 let c = QueryCondition {
232 field: "title".into(),
233 operator: "contains".into(),
234 value: "rust".into(),
235 };
236 assert!(validate_condition(&c).is_ok());
237 }
238
239 #[test]
240 fn validate_title_not_contains() {
241 let c = QueryCondition {
242 field: "title".into(),
243 operator: "not_contains".into(),
244 value: "spam".into(),
245 };
246 assert!(validate_condition(&c).is_ok());
247 }
248
249 #[test]
250 fn validate_title_matches_regex() {
251 let c = QueryCondition {
252 field: "title".into(),
253 operator: "matches_regex".into(),
254 value: "rust.*lang".into(),
255 };
256 assert!(validate_condition(&c).is_ok());
257 }
258
259 #[test]
260 fn validate_author_contains() {
261 let c = QueryCondition {
262 field: "author".into(),
263 operator: "contains".into(),
264 value: "alice".into(),
265 };
266 assert!(validate_condition(&c).is_ok());
267 }
268
269 #[test]
270 fn validate_body_not_contains() {
271 let c = QueryCondition {
272 field: "body".into(),
273 operator: "not_contains".into(),
274 value: "ad".into(),
275 };
276 assert!(validate_condition(&c).is_ok());
277 }
278
279 #[test]
280 fn validate_source_equals() {
281 let c = QueryCondition {
282 field: "source".into(),
283 operator: "equals".into(),
284 value: "rss".into(),
285 };
286 assert!(validate_condition(&c).is_ok());
287 }
288
289 #[test]
290 fn validate_starred_is() {
291 let c = QueryCondition {
292 field: "starred".into(),
293 operator: "is".into(),
294 value: "true".into(),
295 };
296 assert!(validate_condition(&c).is_ok());
297 }
298
299 #[test]
300 fn validate_unread_is() {
301 let c = QueryCondition {
302 field: "unread".into(),
303 operator: "is".into(),
304 value: "true".into(),
305 };
306 assert!(validate_condition(&c).is_ok());
307 }
308
309 #[test]
310 fn validate_tag_equals() {
311 let c = QueryCondition {
312 field: "tag".into(),
313 operator: "equals".into(),
314 value: "rust".into(),
315 };
316 assert!(validate_condition(&c).is_ok());
317 }
318
319 #[test]
320 fn validate_invalid_field() {
321 let c = QueryCondition {
322 field: "nonexistent".into(),
323 operator: "contains".into(),
324 value: "x".into(),
325 };
326 assert!(validate_condition(&c).is_err());
327 }
328
329 #[test]
330 fn validate_invalid_operator() {
331 let c = QueryCondition {
332 field: "title".into(),
333 operator: "invalid_op".into(),
334 value: "x".into(),
335 };
336 assert!(validate_condition(&c).is_err());
337 }
338
339 #[test]
340 fn validate_source_contains_invalid() {
341 // source only supports "equals"
342 let c = QueryCondition {
343 field: "source".into(),
344 operator: "contains".into(),
345 value: "rss".into(),
346 };
347 assert!(validate_condition(&c).is_err());
348 }
349
350 #[test]
351 fn validate_input_empty_name() {
352 let input = QueryFeedInput {
353 name: " ".into(),
354 rules: vec![],
355 };
356 assert!(validate_input(&input).is_err());
357 }
358
359 #[test]
360 fn validate_input_long_name() {
361 let input = QueryFeedInput {
362 name: "a".repeat(201),
363 rules: vec![],
364 };
365 assert!(validate_input(&input).is_err());
366 }
367
368 #[test]
369 fn validate_input_ok() {
370 let input = QueryFeedInput {
371 name: "My Filter".into(),
372 rules: vec![QueryCondition {
373 field: "title".into(),
374 operator: "contains".into(),
375 value: "rust".into(),
376 }],
377 };
378 assert!(validate_input(&input).is_ok());
379 }
380 }
381