e77e854d2e
- Add tonic/prost gRPC client connecting to music-agregator service - Add config.yaml for configurable server host/port - Add build.rs for proto compilation from music-agregator - Update Artist/Album models to match proto with MonitorState enum - Convert album list from GetArtists response - Fix album click selection with correct layout offsets - Improve monitor state icons for better visibility
654 lines
21 KiB
Rust
654 lines
21 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use ratatui::{
|
|
Frame,
|
|
layout::{Constraint, Layout, Rect},
|
|
style::{Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{List, ListItem, ListState, Paragraph},
|
|
};
|
|
|
|
use crate::data::{Album, AlbumStatus, Artist, MonitorState, Track};
|
|
use crate::theme;
|
|
use crate::ui::pane::{Pane, section_divider};
|
|
use crate::ui::progress_bar::progress_bar;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum LibraryFocus {
|
|
#[default]
|
|
Artists,
|
|
Albums,
|
|
Tracks,
|
|
}
|
|
|
|
pub struct LibraryState {
|
|
pub artists: Vec<Artist>,
|
|
pub focus: LibraryFocus,
|
|
pub artist_state: ListState,
|
|
pub album_state: ListState,
|
|
pub track_state: ListState,
|
|
}
|
|
|
|
impl LibraryState {
|
|
pub fn new(artists: Vec<Artist>) -> Self {
|
|
let mut artist_state = ListState::default();
|
|
let mut album_state = ListState::default();
|
|
let mut track_state = ListState::default();
|
|
|
|
if !artists.is_empty() {
|
|
artist_state.select(Some(0));
|
|
if !artists[0].albums.is_empty() {
|
|
album_state.select(Some(0));
|
|
track_state.select(Some(0));
|
|
}
|
|
}
|
|
|
|
Self {
|
|
artists,
|
|
focus: LibraryFocus::Artists,
|
|
artist_state,
|
|
album_state,
|
|
track_state,
|
|
}
|
|
}
|
|
|
|
pub fn selected_artist(&self) -> Option<&Artist> {
|
|
self.artist_state
|
|
.selected()
|
|
.and_then(|i| self.artists.get(i))
|
|
}
|
|
|
|
pub fn selected_album(&self) -> Option<&Album> {
|
|
self.selected_artist()
|
|
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
|
|
}
|
|
|
|
pub fn move_up(&mut self) {
|
|
match self.focus {
|
|
LibraryFocus::Artists => {
|
|
if let Some(i) = self.artist_state.selected() {
|
|
if i > 0 {
|
|
self.artist_state.select(Some(i - 1));
|
|
self.reset_album_selection();
|
|
}
|
|
}
|
|
}
|
|
LibraryFocus::Albums => {
|
|
if let Some(i) = self.album_state.selected() {
|
|
if i > 0 {
|
|
self.album_state.select(Some(i - 1));
|
|
self.reset_track_selection();
|
|
}
|
|
}
|
|
}
|
|
LibraryFocus::Tracks => {
|
|
if let Some(i) = self.track_state.selected() {
|
|
if i > 0 {
|
|
self.track_state.select(Some(i - 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn move_down(&mut self) {
|
|
match self.focus {
|
|
LibraryFocus::Artists => {
|
|
let max = self.artists.len().saturating_sub(1);
|
|
if let Some(i) = self.artist_state.selected() {
|
|
if i < max {
|
|
self.artist_state.select(Some(i + 1));
|
|
self.reset_album_selection();
|
|
}
|
|
}
|
|
}
|
|
LibraryFocus::Albums => {
|
|
let max = self
|
|
.selected_artist()
|
|
.map(|a| a.albums.len().saturating_sub(1))
|
|
.unwrap_or(0);
|
|
if let Some(i) = self.album_state.selected() {
|
|
if i < max {
|
|
self.album_state.select(Some(i + 1));
|
|
self.reset_track_selection();
|
|
}
|
|
}
|
|
}
|
|
LibraryFocus::Tracks => {
|
|
let max = self.track_count().saturating_sub(1);
|
|
if let Some(i) = self.track_state.selected() {
|
|
if i < max {
|
|
self.track_state.select(Some(i + 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn track_count(&self) -> usize {
|
|
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
|
|
}
|
|
|
|
pub fn focus_left(&mut self) {
|
|
match self.focus {
|
|
LibraryFocus::Artists => {}
|
|
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
|
|
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
|
|
}
|
|
}
|
|
|
|
pub fn focus_right(&mut self) {
|
|
match self.focus {
|
|
LibraryFocus::Artists => {
|
|
if self.selected_artist().is_some() {
|
|
self.focus = LibraryFocus::Albums;
|
|
}
|
|
}
|
|
LibraryFocus::Albums => {
|
|
if self.selected_album().is_some() {
|
|
self.focus = LibraryFocus::Tracks;
|
|
}
|
|
}
|
|
LibraryFocus::Tracks => {}
|
|
}
|
|
}
|
|
|
|
pub fn cycle_focus(&mut self) {
|
|
self.focus = match self.focus {
|
|
LibraryFocus::Artists => LibraryFocus::Albums,
|
|
LibraryFocus::Albums => LibraryFocus::Tracks,
|
|
LibraryFocus::Tracks => LibraryFocus::Artists,
|
|
};
|
|
}
|
|
|
|
fn reset_album_selection(&mut self) {
|
|
if let Some(artist) = self.selected_artist() {
|
|
if !artist.albums.is_empty() {
|
|
self.album_state.select(Some(0));
|
|
} else {
|
|
self.album_state.select(None);
|
|
}
|
|
}
|
|
self.reset_track_selection();
|
|
}
|
|
|
|
fn reset_track_selection(&mut self) {
|
|
if self.selected_album().is_some() {
|
|
self.track_state.select(Some(0));
|
|
} else {
|
|
self.track_state.select(None);
|
|
}
|
|
}
|
|
|
|
pub fn artist_count(&self) -> usize {
|
|
self.artists.len()
|
|
}
|
|
|
|
pub fn selected_artist_index(&self) -> Option<usize> {
|
|
self.artist_state.selected()
|
|
}
|
|
}
|
|
|
|
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
|
|
if !monitored {
|
|
return ('◌', Style::default().fg(theme::GRAY));
|
|
}
|
|
match status {
|
|
AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)),
|
|
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
|
|
AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)),
|
|
AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)),
|
|
}
|
|
}
|
|
|
|
fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) {
|
|
match state {
|
|
MonitorState::Monitored => match status {
|
|
AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)),
|
|
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
|
|
AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)),
|
|
AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
|
|
},
|
|
MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
|
|
MonitorState::Excluded => ('x', Style::default().fg(theme::RED)),
|
|
MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)),
|
|
}
|
|
}
|
|
|
|
fn track_icon(have: bool) -> (char, Style) {
|
|
if have {
|
|
('✓', Style::default().fg(theme::GREEN))
|
|
} else {
|
|
('✗', Style::default().fg(theme::RED))
|
|
}
|
|
}
|
|
|
|
fn artist_status(artist: &Artist) -> AlbumStatus {
|
|
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
|
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
|
if have == total {
|
|
AlbumStatus::Complete
|
|
} else if have == 0 {
|
|
AlbumStatus::Wanted
|
|
} else {
|
|
AlbumStatus::Partial
|
|
}
|
|
}
|
|
|
|
fn fmt_size(gb: f64) -> String {
|
|
if gb >= 1.0 {
|
|
format!("{:.1} GB", gb)
|
|
} else {
|
|
format!("{} MB", (gb * 1024.0).round() as u32)
|
|
}
|
|
}
|
|
|
|
pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|
let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area);
|
|
|
|
render_artists_pane(frame, chunks[0], state);
|
|
render_detail_pane(frame, chunks[1], state);
|
|
}
|
|
|
|
fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|
let focused = state.focus == LibraryFocus::Artists;
|
|
let artist_count = state.artists.len();
|
|
|
|
let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum();
|
|
let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum();
|
|
|
|
let footer = Line::from(vec![
|
|
Span::styled(
|
|
format!("{} artists · {} alb", artist_count, total_albums),
|
|
Style::default().fg(theme::GRAY),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)),
|
|
]);
|
|
|
|
let artist_count_str = artist_count.to_string();
|
|
let pane = Pane::new("Artists")
|
|
.meta(&artist_count_str)
|
|
.focused(focused)
|
|
.footer(footer);
|
|
|
|
let block = pane.build_block();
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
let items: Vec<ListItem> = state
|
|
.artists
|
|
.iter()
|
|
.map(|artist| {
|
|
let status = artist_status(artist);
|
|
let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status);
|
|
|
|
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
|
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
|
|
|
let mut name_text = artist.name.clone();
|
|
|
|
let count_str = format!("{}/{}", have, total);
|
|
let name_width = inner.width as usize - 2 - count_str.len() - 2;
|
|
if name_text.len() > name_width {
|
|
name_text.truncate(name_width.saturating_sub(1));
|
|
name_text.push('…');
|
|
}
|
|
|
|
let padding = name_width.saturating_sub(name_text.len());
|
|
|
|
Line::from(vec![
|
|
Span::styled(format!("{} ", icon_char), icon_style),
|
|
Span::styled(name_text, Style::default().fg(theme::FG1)),
|
|
Span::raw(" ".repeat(padding)),
|
|
Span::styled(count_str, Style::default().fg(theme::GRAY)),
|
|
])
|
|
.into()
|
|
})
|
|
.collect();
|
|
|
|
let highlight_style = if focused {
|
|
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
|
} else {
|
|
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
|
};
|
|
|
|
let list = List::new(items).highlight_style(highlight_style);
|
|
frame.render_stateful_widget(list, inner, &mut state.artist_state);
|
|
}
|
|
|
|
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
|
|
|
|
let artist = state.selected_artist();
|
|
|
|
let meta = artist
|
|
.map(|a| {
|
|
format!(
|
|
"{} · {}",
|
|
a.country,
|
|
a.genres.first().map(|s| s.as_str()).unwrap_or("")
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let have_tracks: u16 = artist
|
|
.map(|a| a.albums.iter().map(|al| al.have).sum())
|
|
.unwrap_or(0);
|
|
let total_tracks: u16 = artist
|
|
.map(|a| a.albums.iter().map(|al| al.total).sum())
|
|
.unwrap_or(0);
|
|
|
|
let footer = if artist.is_some() {
|
|
Line::from(vec![Span::styled(
|
|
format!("{}/{} tracks", have_tracks, total_tracks),
|
|
Style::default().fg(theme::GRAY),
|
|
)])
|
|
} else {
|
|
Line::from("")
|
|
};
|
|
|
|
let pane = Pane::new("Detail")
|
|
.meta(&meta)
|
|
.focused(focused)
|
|
.footer(footer);
|
|
|
|
let block = pane.build_block();
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
let Some(artist) = artist else {
|
|
let msg = Paragraph::new(Span::styled(
|
|
"No artist selected",
|
|
Style::default().fg(theme::GRAY),
|
|
));
|
|
frame.render_widget(msg, inner);
|
|
return;
|
|
};
|
|
|
|
let chunks = Layout::vertical([
|
|
Constraint::Length(6),
|
|
Constraint::Length(1),
|
|
Constraint::Percentage(40),
|
|
Constraint::Length(1),
|
|
Constraint::Fill(1),
|
|
])
|
|
.split(inner);
|
|
|
|
render_artist_header(frame, chunks[0], artist);
|
|
|
|
let albums_count = artist.albums.len();
|
|
let albums_label = format!("{} releases", albums_count);
|
|
let album_divider = section_divider("albums", Some(&albums_label));
|
|
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
|
|
|
|
let selected_artist_idx = state.artist_state.selected();
|
|
if let Some(idx) = selected_artist_idx {
|
|
if let Some(artist) = state.artists.get(idx) {
|
|
let albums = artist.albums.clone();
|
|
let focus = state.focus;
|
|
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
|
|
}
|
|
}
|
|
|
|
let album_title = state
|
|
.selected_album()
|
|
.map(|a| a.title.clone())
|
|
.unwrap_or_default();
|
|
let track_counts = state
|
|
.selected_album()
|
|
.map(|a| format!("{}/{}", a.have, a.total))
|
|
.unwrap_or_default();
|
|
let track_label = format!("tracks · {}", album_title);
|
|
let track_divider = section_divider(&track_label, Some(&track_counts));
|
|
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
|
|
|
|
render_tracks_list(frame, chunks[4], state);
|
|
}
|
|
|
|
fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
|
|
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
|
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
|
|
|
let (status_icon, status_text, status_style) = match artist.monitor_state {
|
|
MonitorState::Monitored => (
|
|
Span::styled("● ", Style::default().fg(theme::GREEN)),
|
|
"Monitored",
|
|
Style::default().fg(theme::FG2),
|
|
),
|
|
MonitorState::Unmonitored => (
|
|
Span::styled("◌ ", Style::default().fg(theme::GRAY)),
|
|
"Unmonitored",
|
|
Style::default().fg(theme::GRAY),
|
|
),
|
|
MonitorState::Excluded => (
|
|
Span::styled("⊘ ", Style::default().fg(theme::RED)),
|
|
"Excluded",
|
|
Style::default().fg(theme::RED),
|
|
),
|
|
MonitorState::Unspecified => (
|
|
Span::styled("? ", Style::default().fg(theme::GRAY)),
|
|
"Unknown",
|
|
Style::default().fg(theme::GRAY),
|
|
),
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(Span::styled(
|
|
&artist.name,
|
|
Style::default()
|
|
.fg(theme::YELLOW)
|
|
.add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("status ", Style::default().fg(theme::GRAY)),
|
|
status_icon,
|
|
Span::styled(status_text, status_style),
|
|
Span::raw(" "),
|
|
Span::styled("path ", Style::default().fg(theme::GRAY)),
|
|
Span::styled(&artist.path, Style::default().fg(theme::AQUA)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("quality ", Style::default().fg(theme::GRAY)),
|
|
Span::styled(&artist.quality, Style::default().fg(theme::FG1)),
|
|
Span::raw(" "),
|
|
Span::styled("size ", Style::default().fg(theme::GRAY)),
|
|
Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("albums ", Style::default().fg(theme::GRAY)),
|
|
Span::styled(
|
|
artist.albums.len().to_string(),
|
|
Style::default().fg(theme::FG1),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
|
|
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
|
|
Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)),
|
|
]),
|
|
];
|
|
|
|
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0));
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_albums_list(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
albums: &[Album],
|
|
focus: LibraryFocus,
|
|
album_state: &mut ListState,
|
|
) {
|
|
let focused = focus == LibraryFocus::Albums;
|
|
|
|
let items: Vec<ListItem> = albums
|
|
.iter()
|
|
.map(|album| {
|
|
let (icon_char, icon_style) = status_icon(album.status, album.monitored);
|
|
let type_str = format!("[{}]", album.album_type);
|
|
let year_str = album.year.to_string();
|
|
let progress = progress_bar(album.have, album.total, 10, album.status);
|
|
let count_str = format!("{}/{}", album.have, album.total);
|
|
|
|
let quality_style = if album.quality == "—" {
|
|
Style::default().fg(theme::GRAY)
|
|
} else {
|
|
Style::default().fg(theme::AQUA)
|
|
};
|
|
|
|
let title_width =
|
|
area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8;
|
|
let mut title = album.title.clone();
|
|
if title.len() > title_width {
|
|
title.truncate(title_width.saturating_sub(1));
|
|
title.push('…');
|
|
}
|
|
|
|
let mut spans = vec![
|
|
Span::styled(format!("{} ", icon_char), icon_style),
|
|
Span::styled(title, Style::default().fg(theme::FG1)),
|
|
Span::raw(" "),
|
|
Span::styled(type_str, Style::default().fg(theme::GRAY)),
|
|
Span::raw(" "),
|
|
Span::styled(year_str, Style::default().fg(theme::GRAY)),
|
|
Span::raw(" "),
|
|
];
|
|
spans.extend(progress.spans);
|
|
spans.push(Span::raw(" "));
|
|
spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY)));
|
|
spans.push(Span::raw(" "));
|
|
spans.push(Span::styled(&album.quality, quality_style));
|
|
|
|
Line::from(spans).into()
|
|
})
|
|
.collect();
|
|
|
|
let highlight_style = if focused {
|
|
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
|
} else {
|
|
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
|
};
|
|
|
|
let list = List::new(items).highlight_style(highlight_style);
|
|
frame.render_stateful_widget(list, area, album_state);
|
|
}
|
|
|
|
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|
let focused = state.focus == LibraryFocus::Tracks;
|
|
let tracks = state.get_tracks();
|
|
|
|
if tracks.is_empty() {
|
|
let msg = Paragraph::new(Span::styled(
|
|
"(no album selected)",
|
|
Style::default().fg(theme::GRAY),
|
|
));
|
|
frame.render_widget(msg, area);
|
|
return;
|
|
}
|
|
|
|
let items: Vec<ListItem> = tracks
|
|
.iter()
|
|
.map(|track| {
|
|
let (icon_char, icon_style) = track_icon(track.have);
|
|
let num_str = format!("{:02}", track.number);
|
|
|
|
let title_style = if track.have {
|
|
Style::default().fg(theme::FG1)
|
|
} else {
|
|
Style::default().fg(theme::GRAY)
|
|
};
|
|
|
|
let quality_style = if track.have {
|
|
Style::default().fg(theme::AQUA)
|
|
} else {
|
|
Style::default().fg(theme::RED)
|
|
};
|
|
|
|
Line::from(vec![
|
|
Span::styled(format!("{} ", icon_char), icon_style),
|
|
Span::styled(num_str, Style::default().fg(theme::GRAY)),
|
|
Span::raw(" "),
|
|
Span::styled(&track.title, title_style),
|
|
Span::raw(" "),
|
|
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
|
|
Span::raw(" "),
|
|
Span::styled(&track.quality, quality_style),
|
|
])
|
|
.into()
|
|
})
|
|
.collect();
|
|
|
|
let highlight_style = if focused {
|
|
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
|
} else {
|
|
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
|
};
|
|
|
|
let list = List::new(items).highlight_style(highlight_style);
|
|
frame.render_stateful_widget(list, area, &mut state.track_state);
|
|
}
|
|
|
|
impl LibraryState {
|
|
pub fn get_tracks(&self) -> Vec<Track> {
|
|
let Some(album) = self.selected_album() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
tracks_for(album)
|
|
}
|
|
}
|
|
|
|
fn tracks_for(album: &Album) -> Vec<Track> {
|
|
let titles = [
|
|
"Opening",
|
|
"Curtain Call",
|
|
"Half-Light",
|
|
"Polaroid",
|
|
"Switchback",
|
|
"Slow Dancer",
|
|
"The Inheritance",
|
|
"Glassworks",
|
|
"Interlude",
|
|
"Aftermath",
|
|
"Static",
|
|
"Returner",
|
|
"Dust Bowl",
|
|
"Postcard",
|
|
"Late Reply",
|
|
"Honeymoon",
|
|
"Northern Lights",
|
|
"Cold Open",
|
|
"Coda",
|
|
"Reprise",
|
|
];
|
|
|
|
(0..album.total)
|
|
.map(|i| {
|
|
let idx = i as usize;
|
|
let m = 2 + ((idx * 7) % 5);
|
|
let s = (idx * 13) % 60;
|
|
let title_idx = idx % titles.len();
|
|
let title = if idx >= titles.len() {
|
|
format!("{} II", titles[title_idx])
|
|
} else {
|
|
titles[title_idx].to_string()
|
|
};
|
|
let have = i < album.have;
|
|
let quality = if have {
|
|
album.quality.clone()
|
|
} else {
|
|
"—".to_string()
|
|
};
|
|
|
|
Track {
|
|
number: i + 1,
|
|
title,
|
|
duration: format!("{}:{:02}", m, s),
|
|
have,
|
|
quality,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|