pacsea/state/app_state/methods.rs
1//! Implementation methods for `AppState`.
2
3use crate::state::app_state::{AppState, recent_capacity};
4use crate::state::types::{
5 NewsBookmark, NewsFeedItem, NewsReadFilter, NewsSortMode, severity_rank,
6};
7use chrono::{NaiveDate, Utc};
8
9impl AppState {
10 /// What: Return recent searches in most-recent-first order.
11 ///
12 /// Inputs:
13 /// - `self`: Application state containing the recent LRU cache.
14 ///
15 /// Output:
16 /// - Vector of recent search strings ordered from most to least recent.
17 ///
18 /// Details:
19 /// - Clones stored values; limited to `RECENT_CAPACITY`.
20 #[must_use]
21 pub fn recent_values(&self) -> Vec<String> {
22 self.recent.iter().map(|(_, v)| v.clone()).collect()
23 }
24
25 /// What: Fetch a recent search by positional index.
26 ///
27 /// Inputs:
28 /// - `index`: Zero-based position in most-recent-first ordering.
29 ///
30 /// Output:
31 /// - `Some(String)` when the index is valid; `None` otherwise.
32 ///
33 /// Details:
34 /// - Uses the LRU iterator, so `index == 0` is the most recent entry.
35 #[must_use]
36 pub fn recent_value_at(&self, index: usize) -> Option<String> {
37 self.recent.iter().nth(index).map(|(_, v)| v.clone())
38 }
39
40 /// What: Remove a recent search at the provided position.
41 ///
42 /// Inputs:
43 /// - `index`: Zero-based position in most-recent-first ordering.
44 ///
45 /// Output:
46 /// - `Some(String)` containing the removed value when found; `None` otherwise.
47 ///
48 /// Details:
49 /// - Resolves the cache key via iteration, then pops it to maintain LRU invariants.
50 pub fn remove_recent_at(&mut self, index: usize) -> Option<String> {
51 let key = self.recent.iter().nth(index).map(|(k, _)| k.clone())?;
52 self.recent.pop(&key)
53 }
54
55 /// What: Add or replace a news bookmark, marking state dirty.
56 ///
57 /// Inputs:
58 /// - `bookmark`: Bookmark to insert (deduped by `item.id`).
59 ///
60 /// Output:
61 /// - None (mutates bookmarks and dirty flag).
62 pub fn add_news_bookmark(&mut self, bookmark: NewsBookmark) {
63 if let Some(pos) = self
64 .news_bookmarks
65 .iter()
66 .position(|b| b.item.id == bookmark.item.id)
67 {
68 self.news_bookmarks[pos] = bookmark;
69 } else {
70 self.news_bookmarks.push(bookmark);
71 }
72 self.news_bookmarks_dirty = true;
73 }
74
75 /// What: Remove a news bookmark at a position.
76 ///
77 /// Inputs:
78 /// - `index`: Zero-based index into bookmarks vector.
79 ///
80 /// Output:
81 /// - Removed bookmark if present.
82 pub fn remove_news_bookmark_at(&mut self, index: usize) -> Option<NewsBookmark> {
83 if index >= self.news_bookmarks.len() {
84 return None;
85 }
86 let removed = self.news_bookmarks.remove(index);
87 self.news_bookmarks_dirty = true;
88 Some(removed)
89 }
90
91 /// What: Return recent news searches in most-recent-first order.
92 ///
93 /// Inputs:
94 /// - `self`: Application state containing the news recent LRU cache.
95 ///
96 /// Output:
97 /// - Vector of recent news search strings ordered from most to least recent.
98 ///
99 /// Details:
100 /// - Clones stored values; limited by the configured recent capacity.
101 #[must_use]
102 pub fn news_recent_values(&self) -> Vec<String> {
103 self.news_recent.iter().map(|(_, v)| v.clone()).collect()
104 }
105
106 /// What: Fetch a recent news search by positional index.
107 ///
108 /// Inputs:
109 /// - `index`: Zero-based position in most-recent-first ordering.
110 ///
111 /// Output:
112 /// - `Some(String)` when the index is valid; `None` otherwise.
113 ///
114 /// Details:
115 /// - Uses the LRU iterator, so `index == 0` is the most recent entry.
116 #[must_use]
117 pub fn news_recent_value_at(&self, index: usize) -> Option<String> {
118 self.news_recent.iter().nth(index).map(|(_, v)| v.clone())
119 }
120
121 /// What: Replace the news recent cache with the provided most-recent-first entries.
122 ///
123 /// Inputs:
124 /// - `items`: Slice of recent news search strings ordered from most to least recent.
125 ///
126 /// Output:
127 /// - None (mutates `self.news_recent`).
128 ///
129 /// Details:
130 /// - Clears existing entries, enforces configured capacity, and preserves ordering by
131 /// inserting from least-recent to most-recent.
132 pub fn load_news_recent_items(&mut self, items: &[String]) {
133 self.news_recent.clear();
134 self.news_recent.resize(recent_capacity());
135 for value in items.iter().rev() {
136 let stored = value.clone();
137 let key = stored.to_ascii_lowercase();
138 self.news_recent.put(key, stored);
139 }
140 }
141
142 /// What: Remove a recent news search at the provided position.
143 ///
144 /// Inputs:
145 /// - `index`: Zero-based position in most-recent-first ordering.
146 ///
147 /// Output:
148 /// - `Some(String)` containing the removed value when found; `None` otherwise.
149 ///
150 /// Details:
151 /// - Resolves the cache key via iteration, then pops it to maintain LRU invariants.
152 pub fn remove_news_recent_at(&mut self, index: usize) -> Option<String> {
153 let key = self.news_recent.iter().nth(index).map(|(k, _)| k.clone())?;
154 self.news_recent.pop(&key)
155 }
156
157 /// What: Replace the recent cache with the provided most-recent-first entries.
158 ///
159 /// Inputs:
160 /// - `items`: Slice of recent search strings ordered from most to least recent.
161 ///
162 /// Output:
163 /// - None (mutates `self.recent`).
164 ///
165 /// Details:
166 /// - Clears existing entries, enforces configured capacity, and preserves ordering by
167 /// inserting from least-recent to most-recent.
168 pub fn load_recent_items(&mut self, items: &[String]) {
169 self.recent.clear();
170 self.recent.resize(recent_capacity());
171 for value in items.iter().rev() {
172 let stored = value.clone();
173 let key = stored.to_ascii_lowercase();
174 self.recent.put(key, stored);
175 }
176 }
177
178 /// What: Recompute news results applying filters, search, age cutoff, and sorting.
179 ///
180 /// Inputs:
181 /// - `self`: Mutable application state containing news items and filter fields.
182 ///
183 /// Output:
184 /// - Updates `news_results`, selection state, and recent news searches.
185 pub fn refresh_news_results(&mut self) {
186 let query = self.news_search_input.to_lowercase();
187 if query.is_empty() {
188 self.news_history_pending = None;
189 self.news_history_pending_at = None;
190 } else {
191 self.news_history_pending = Some(self.news_search_input.clone());
192 self.news_history_pending_at = Some(std::time::Instant::now());
193 }
194 let mut filtered: Vec<NewsFeedItem> = self
195 .news_items
196 .iter()
197 .filter(|it| match it.source {
198 crate::state::types::NewsFeedSource::ArchNews => self.news_filter_show_arch_news,
199 crate::state::types::NewsFeedSource::SecurityAdvisory => {
200 self.news_filter_show_advisories
201 }
202 crate::state::types::NewsFeedSource::InstalledPackageUpdate => {
203 self.news_filter_show_pkg_updates
204 }
205 crate::state::types::NewsFeedSource::AurPackageUpdate => {
206 self.news_filter_show_aur_updates
207 }
208 crate::state::types::NewsFeedSource::AurComment => {
209 self.news_filter_show_aur_comments
210 }
211 })
212 .cloned()
213 .collect();
214
215 // Apply installed-only filter for advisories when enabled.
216 // When "[Advisories All]" is active (news_filter_show_advisories = true,
217 // news_filter_installed_only = false), this block does not run, allowing
218 // all advisories to be shown regardless of installed status.
219 if self.news_filter_installed_only {
220 let installed: std::collections::HashSet<String> =
221 crate::index::explicit_names().into_iter().collect();
222 filtered.retain(|it| {
223 !matches!(
224 it.source,
225 crate::state::types::NewsFeedSource::SecurityAdvisory
226 ) || it.packages.iter().any(|pkg| installed.contains(pkg))
227 });
228 }
229
230 if !matches!(self.news_filter_read_status, NewsReadFilter::All) {
231 filtered.retain(|it| {
232 let is_read = self.news_read_ids.contains(&it.id)
233 || it
234 .url
235 .as_ref()
236 .is_some_and(|u| self.news_read_urls.contains(u));
237 matches!(self.news_filter_read_status, NewsReadFilter::Read) && is_read
238 || matches!(self.news_filter_read_status, NewsReadFilter::Unread) && !is_read
239 });
240 }
241
242 if !query.is_empty() {
243 filtered.retain(|it| {
244 let hay = format!(
245 "{} {} {}",
246 it.title,
247 it.summary.clone().unwrap_or_default(),
248 it.packages.join(" ")
249 )
250 .to_lowercase();
251 hay.contains(&query)
252 });
253 }
254
255 if let Some(max_days) = self.news_max_age_days
256 && let Some(cutoff_date) = Utc::now()
257 .date_naive()
258 .checked_sub_days(chrono::Days::new(u64::from(max_days)))
259 {
260 filtered.retain(|it| {
261 NaiveDate::parse_from_str(&it.date, "%Y-%m-%d").map_or(true, |d| d >= cutoff_date)
262 });
263 }
264
265 let is_read = |it: &NewsFeedItem| {
266 self.news_read_ids.contains(&it.id)
267 || it
268 .url
269 .as_ref()
270 .is_some_and(|u| self.news_read_urls.contains(u))
271 };
272
273 match self.news_sort_mode {
274 NewsSortMode::DateDesc => filtered.sort_by(|a, b| b.date.cmp(&a.date)),
275 NewsSortMode::DateAsc => filtered.sort_by(|a, b| a.date.cmp(&b.date)),
276 NewsSortMode::Title => {
277 filtered.sort_by(|a, b| {
278 a.title
279 .to_lowercase()
280 .cmp(&b.title.to_lowercase())
281 .then(b.date.cmp(&a.date))
282 });
283 }
284 NewsSortMode::SourceThenTitle => filtered.sort_by(|a, b| {
285 a.source
286 .cmp(&b.source)
287 .then(b.date.cmp(&a.date))
288 .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
289 }),
290 NewsSortMode::SeverityThenDate => filtered.sort_by(|a, b| {
291 let sa = severity_rank(a.severity);
292 let sb = severity_rank(b.severity);
293 sb.cmp(&sa)
294 .then(b.date.cmp(&a.date))
295 .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
296 }),
297 NewsSortMode::UnreadThenDate => filtered.sort_by(|a, b| {
298 let ra = is_read(a);
299 let rb = is_read(b);
300 ra.cmp(&rb)
301 .then(b.date.cmp(&a.date))
302 .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
303 }),
304 }
305
306 self.news_results = filtered;
307 if self.news_results.is_empty() {
308 self.news_selected = 0;
309 self.news_list_state.select(None);
310 } else {
311 self.news_selected = self
312 .news_selected
313 .min(self.news_results.len().saturating_sub(1));
314 self.news_list_state.select(Some(self.news_selected));
315 }
316 }
317}