diff --git a/musicus/res/ui/work_editor.ui b/musicus/res/ui/work_editor.ui index 0f01693..c587094 100644 --- a/musicus/res/ui/work_editor.ui +++ b/musicus/res/ui/work_editor.ui @@ -3,183 +3,234 @@ - + True False - vertical - + True False - Work + vertical - - Cancel - True - True - True - - - - - Save - True - False - True - True - - - - end - 1 - - - - - False - True - 0 - - - - - True - True - - - + True False - 18 - 12 - 6 + Work - + + Cancel True True True - True - - - True - False - start - Select … - end - - - - 1 - 1 - + + Save + True + False + True + True + + + + end + 1 + + + + + False + True + 0 + + + + + True + False + False + + + + + + False + True + 1 + + + + + True + True + False + + + + True + False + 18 + 12 + 6 + + + True + True + True + True + + + True + False + start + Select … + end + + + + + 1 + 1 + + + + + True + False + end + Composer + + + 0 + 1 + + + + + True + True + + + 1 + 0 + + + + + True + False + end + Title + + + 0 + 0 + + + + + True + False + end + Publish + + + 0 + 2 + + + + + True + True + start + True + + + 1 + 2 + + + + + True False - end - Composer + Overview - 0 - 1 - - - - - True - True - - - 1 - 0 - - - - - True - False - end - Title - - - 0 - 0 - - - - - - - True - False - Overview - - - False - - - - - True - False - 18 - 6 - - - True - True - in - - - - - - True - True - 0 + False True False - 0 - vertical + 18 6 - + True True - True + in - - True - False - list-add-symbolic - + - False + True True 0 - + True - True - True + False + 0 + vertical + 6 - + True - False - list-remove-symbolic + True + True + + + True + False + list-add-symbolic + + + + False + True + 0 + + + + + True + True + True + + + True + False + list-remove-symbolic + + + + + False + True + 1 + @@ -190,84 +241,162 @@ - False - True 1 - - - 1 - - - - - True - False - Instruments - - - 1 - False - - - - - True - False - 18 - 6 - - + + True - True - in - - - + False + Instruments - True - True - 0 + 1 + False True False - vertical + 18 6 - + True True - True + in - - True - False - list-add-symbolic - + - False + True True 0 - + True - True - True + False + vertical + 6 - + True - False - folder-new-symbolic + True + True + + + True + False + list-add-symbolic + + + + False + True + 0 + + + + + True + True + True + + + True + False + folder-new-symbolic + + + + + False + True + 1 + + + + + True + True + True + + + True + False + edit-symbolic + + + + + False + True + 2 + + + + + True + True + True + + + True + False + list-remove-symbolic + + + + + False + True + 3 + + + + + True + True + True + + + True + False + go-down-symbolic + + + + + False + True + end + 4 + + + + + True + True + True + + + True + False + go-up-symbolic + + + + + False + True + end + 5 + @@ -276,111 +405,67 @@ 1 - - - True - True - True - - - True - False - edit-symbolic - - - - - False - True - 2 - - - - - True - True - True - - - True - False - list-remove-symbolic - - - - - False - True - 3 - - - - - True - True - True - - - True - False - go-down-symbolic - - - - - False - True - end - 4 - - - - - True - True - True - - - True - False - go-up-symbolic - - - - - False - True - end - 5 - - - False - True - 1 + 2 + + + + + True + False + Structure + + + 2 + False + True + True 2 - - - True - False - Structure - - - 2 - False - - - True - True + content + + + + + True + False + vertical + + + True + False + Ensemble + + + False + True + 0 + + + + + True + False + True + True + + + False + True + 1 + + + + + loading 1 diff --git a/musicus/res/ui/work_selector.ui b/musicus/res/ui/work_selector.ui index 4396b8f..7e6a000 100644 --- a/musicus/res/ui/work_selector.ui +++ b/musicus/res/ui/work_selector.ui @@ -70,6 +70,171 @@ 0 + + + True + False + True + + + True + False + vertical + 6 + + + True + True + edit-find-symbolic + False + False + Search composers … + + + False + True + 0 + + + + + Show works from the server + True + True + False + True + True + + + False + True + 1 + + + + + + + False + True + 1 + + + + + True + False + False + crossfade + True + + + True + False + True + + + loading + + + + + True + True + + + + + + content + 1 + + + + + True + False + center + center + 18 + vertical + 18 + + + True + False + 0.5019607843137255 + 80 + network-error-symbolic + + + False + True + 0 + + + + + True + False + 0.5019607843137255 + An error occured! + + + + + + False + True + 1 + + + + + True + False + 0.5019607843137255 + The server was not reachable or responded with an error. Please check your internet connection. + center + True + 40 + + + False + True + 2 + + + + + Try again + True + True + True + center + + + + False + True + 3 + + + + + error + 2 + + + + + True + True + 2 + + sidebar diff --git a/musicus/res/ui/work_selector_screen.ui b/musicus/res/ui/work_selector_screen.ui index 55f31fe..ee1e7ae 100644 --- a/musicus/res/ui/work_selector_screen.ui +++ b/musicus/res/ui/work_selector_screen.ui @@ -38,6 +38,9 @@ True False + False + crossfade + True True @@ -48,6 +51,97 @@ loading + + + True + True + + + + + + content + 1 + + + + + True + False + center + center + 18 + vertical + 18 + + + True + False + 0.5019607843137255 + 80 + network-error-symbolic + + + False + True + 0 + + + + + True + False + 0.5019607843137255 + An error occured! + + + + + + False + True + 1 + + + + + True + False + 0.5019607843137255 + The server was not reachable or responded with an error. Please check your internet connection. + center + True + 40 + + + False + True + 2 + + + + + Try again + True + True + True + center + + + + False + True + 3 + + + + + error + 2 + + True diff --git a/musicus/src/backend/client/mod.rs b/musicus/src/backend/client/mod.rs index 11be303..490035a 100644 --- a/musicus/src/backend/client/mod.rs +++ b/musicus/src/backend/client/mod.rs @@ -16,6 +16,9 @@ pub use instruments::*; pub mod persons; pub use persons::*; +pub mod works; +pub use works::*; + /// Credentials used for login. #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] diff --git a/musicus/src/backend/client/works.rs b/musicus/src/backend/client/works.rs new file mode 100644 index 0000000..786299f --- /dev/null +++ b/musicus/src/backend/client/works.rs @@ -0,0 +1,18 @@ +use super::Backend; +use crate::database::Work; +use anyhow::Result; + +impl Backend { + /// Get all available works from the server. + pub async fn get_works(&self, composer_id: &str) -> Result> { + let body = self.get(&format!("persons/{}/works", composer_id)).await?; + let works: Vec = serde_json::from_str(&body)?; + Ok(works) + } + + /// Post a new work to the server and return the ID. + pub async fn post_work(&self, data: &Work) -> Result<()> { + self.post("works", serde_json::to_string(data)?).await?; + Ok(()) + } +} diff --git a/musicus/src/dialogs/person_selector.rs b/musicus/src/dialogs/person_selector.rs index 379f0e9..be31301 100644 --- a/musicus/src/dialogs/person_selector.rs +++ b/musicus/src/dialogs/person_selector.rs @@ -133,9 +133,9 @@ impl PersonSelector { search.is_empty() || name.contains(&search) })); - this.list.set_selected(clone!(@strong this => move |work| { + this.list.set_selected(clone!(@strong this => move |person| { if let Some(cb) = &*this.selected_cb.borrow() { - cb(work.clone()); + cb(person.clone()); } this.window.close(); diff --git a/musicus/src/dialogs/work/work_editor.rs b/musicus/src/dialogs/work/work_editor.rs index f83334b..76c40cb 100644 --- a/musicus/src/dialogs/work/work_editor.rs +++ b/musicus/src/dialogs/work/work_editor.rs @@ -4,6 +4,7 @@ use crate::backend::*; use crate::database::*; use crate::dialogs::*; use crate::widgets::*; +use anyhow::Result; use gettextrs::gettext; use glib::clone; use gtk::prelude::*; @@ -21,12 +22,14 @@ enum PartOrSection { /// A widget for editing and creating works. pub struct WorkEditor { - pub widget: gtk::Box, + pub widget: gtk::Stack, backend: Rc, parent: gtk::Window, save_button: gtk::Button, title_entry: gtk::Entry, + info_bar: gtk::InfoBar, composer_label: gtk::Label, + upload_switch: gtk::Switch, instrument_list: Rc>, part_list: Rc>, id: String, @@ -49,12 +52,14 @@ impl WorkEditor { let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_editor.ui"); - get_widget!(builder, gtk::Box, widget); + get_widget!(builder, gtk::Stack, widget); get_widget!(builder, gtk::Button, cancel_button); get_widget!(builder, gtk::Button, save_button); + get_widget!(builder, gtk::InfoBar, info_bar); get_widget!(builder, gtk::Entry, title_entry); get_widget!(builder, gtk::Button, composer_button); get_widget!(builder, gtk::Label, composer_label); + get_widget!(builder, gtk::Switch, upload_switch); get_widget!(builder, gtk::ScrolledWindow, instruments_scroll); get_widget!(builder, gtk::Button, add_instrument_button); get_widget!(builder, gtk::Button, remove_instrument_button); @@ -100,8 +105,10 @@ impl WorkEditor { parent: parent.clone().upcast(), save_button, id, + info_bar, title_entry, composer_label, + upload_switch, instrument_list, part_list, composer: RefCell::new(composer), @@ -119,41 +126,24 @@ impl WorkEditor { } })); - this.save_button.connect_clicked(clone!(@strong this => move |_| { - let mut section_count: usize = 0; - let mut parts = Vec::new(); - let mut sections = Vec::new(); - - for (index, pos) in this.structure.borrow().iter().enumerate() { - match pos { - PartOrSection::Part(part) => parts.push(part.clone()), - PartOrSection::Section(section) => { - let mut section = section.clone(); - section.before_index = index - section_count; - sections.push(section); - section_count += 1; + this.save_button + .connect_clicked(clone!(@strong this => move |_| { + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + clone.widget.set_visible_child_name("loading"); + match clone.clone().save().await { + Ok(_) => { + // We already called the callback. + } + Err(_) => { + clone.info_bar.set_revealed(true); + clone.widget.set_visible_child_name("content"); + } } - } - } - let work = Work { - id: this.id.clone(), - title: this.title_entry.get_text().to_string(), - composer: this.composer.borrow().clone().expect("Tried to create work without composer!"), - instruments: this.instruments.borrow().clone(), - parts: parts, - sections: sections, - }; - - let c = glib::MainContext::default(); - let clone = this.clone(); - c.spawn_local(async move { - clone.backend.db().update_work(work.clone().into()).await.unwrap(); - if let Some(cb) = &*clone.saved_cb.borrow() { - cb(work); - } - }); - })); + }); + })); composer_button.connect_clicked(clone!(@strong this => move |_| { let dialog = PersonSelector::new(this.backend.clone(), &this.parent); @@ -362,4 +352,55 @@ impl WorkEditor { self.composer_label.set_text(&person.name_fl()); self.save_button.set_sensitive(true); } + + /// Save the work and possibly upload it to the server. + async fn save(self: Rc) -> Result<()> { + let mut section_count: usize = 0; + let mut parts = Vec::new(); + let mut sections = Vec::new(); + + for (index, pos) in self.structure.borrow().iter().enumerate() { + match pos { + PartOrSection::Part(part) => parts.push(part.clone()), + PartOrSection::Section(section) => { + let mut section = section.clone(); + section.before_index = index - section_count; + sections.push(section); + section_count += 1; + } + } + } + + let work = Work { + id: self.id.clone(), + title: self.title_entry.get_text().to_string(), + composer: self + .composer + .borrow() + .clone() + .expect("Tried to create work without composer!"), + instruments: self.instruments.borrow().clone(), + parts: parts, + sections: sections, + }; + + let upload = self.upload_switch.get_active(); + if upload { + self.backend.post_work(&work).await?; + } + + self.backend + .db() + .update_work(work.clone().into()) + .await + .unwrap(); + + self.backend.library_changed(); + + if let Some(cb) = &*self.saved_cb.borrow() { + cb(work.clone()); + } + + Ok(()) + } } diff --git a/musicus/src/dialogs/work/work_selector.rs b/musicus/src/dialogs/work/work_selector.rs index dd0ad73..2685ceb 100644 --- a/musicus/src/dialogs/work/work_selector.rs +++ b/musicus/src/dialogs/work/work_selector.rs @@ -2,6 +2,7 @@ use super::work_selector_person_screen::*; use crate::backend::Backend; use crate::database::*; use crate::widgets::*; +use gettextrs::gettext; use glib::clone; use gtk::prelude::*; use gtk_macros::get_widget; @@ -14,6 +15,9 @@ pub struct WorkSelector { pub widget: libhandy::Leaflet, backend: Rc, sidebar_box: gtk::Box, + server_check_button: gtk::CheckButton, + stack: gtk::Stack, + list: Rc>, selected_cb: RefCell ()>>>, add_cb: RefCell ()>>>, navigator: Rc, @@ -29,10 +33,15 @@ impl WorkSelector { get_widget!(builder, libhandy::Leaflet, widget); get_widget!(builder, gtk::Button, add_button); get_widget!(builder, gtk::Box, sidebar_box); + get_widget!(builder, gtk::CheckButton, server_check_button); + get_widget!(builder, gtk::SearchEntry, search_entry); + get_widget!(builder, gtk::Stack, stack); + get_widget!(builder, gtk::ScrolledWindow, scroll); + get_widget!(builder, gtk::Button, try_again_button); get_widget!(builder, gtk::Box, empty_screen); - let person_list = PersonList::new(backend.clone()); - sidebar_box.pack_start(&person_list.widget, true, true, 0); + let list = List::::new(&gettext("No persons found.")); + scroll.add(&list.widget); let navigator = Navigator::new(&empty_screen); widget.add(&navigator.widget); @@ -41,6 +50,9 @@ impl WorkSelector { widget, backend, sidebar_box, + server_check_button, + stack, + list, selected_cb: RefCell::new(None), add_cb: RefCell::new(None), navigator, @@ -54,22 +66,95 @@ impl WorkSelector { } })); - person_list.set_selected(clone!(@strong this => move |person| { - let person_screen = WorkSelectorPersonScreen::new( - this.backend.clone(), - person.clone(), - ); + search_entry.connect_search_changed(clone!(@strong this => move |_| { + this.list.invalidate_filter(); + })); - person_screen.set_selected_cb(clone!(@strong this => move |work| { - if let Some(cb) = &*this.selected_cb.borrow() { - cb(work); + let load_online = Rc::new(clone!(@strong this => move || { + this.stack.set_visible_child_name("loading"); + + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + match clone.backend.get_persons().await { + Ok(persons) => { + clone.list.show_items(persons); + clone.stack.set_visible_child_name("content"); + } + Err(_) => { + clone.list.show_items(Vec::new()); + clone.stack.set_visible_child_name("error"); + } } + }); + })); + + let load_local = Rc::new(clone!(@strong this => move || { + this.stack.set_visible_child_name("loading"); + + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + let persons = clone.backend.db().get_persons().await.unwrap(); + clone.list.show_items(persons); + clone.stack.set_visible_child_name("content"); + }); + })); + + this.server_check_button.connect_toggled( + clone!(@strong this, @strong load_local, @strong load_online => move |_| { + if this.server_check_button.get_active() { + load_online(); + } else { + load_local(); + } + }), + ); + + this.list.set_make_widget(|person: &Person| { + let label = gtk::Label::new(Some(&person.name_lf())); + label.set_halign(gtk::Align::Start); + label.set_margin_start(6); + label.set_margin_end(6); + label.set_margin_top(6); + label.set_margin_bottom(6); + label.upcast() + }); + + this.list + .set_filter(clone!(@strong search_entry => move |person: &Person| { + let search = search_entry.get_text().to_string().to_lowercase(); + let name = person.name_fl().to_lowercase(); + search.is_empty() || name.contains(&search) })); - this.navigator.clone().push(person_screen); - this.widget.set_visible_child(&this.navigator.widget); + this.list + .set_selected(clone!(@strong this => move |person| { + let online = this.server_check_button.get_active(); + + let person_screen = WorkSelectorPersonScreen::new( + this.backend.clone(), + person.clone(), + online, + ); + + person_screen.set_selected_cb(clone!(@strong this => move |work| { + if let Some(cb) = &*this.selected_cb.borrow() { + cb(work); + } + })); + + this.navigator.clone().replace(person_screen); + this.widget.set_visible_child(&this.navigator.widget); + })); + + try_again_button.connect_clicked(clone!(@strong load_online => move |_| { + load_online(); })); + // Initialize + load_online(); + this.navigator.set_back_cb(clone!(@strong this => move || { this.widget.set_visible_child(&this.sidebar_box); })); diff --git a/musicus/src/dialogs/work/work_selector_person_screen.rs b/musicus/src/dialogs/work/work_selector_person_screen.rs index ff9ce16..2b119c8 100644 --- a/musicus/src/dialogs/work/work_selector_person_screen.rs +++ b/musicus/src/dialogs/work/work_selector_person_screen.rs @@ -12,6 +12,8 @@ use std::rc::Rc; /// A screen within the work selector that presents a list of works by a person. pub struct WorkSelectorPersonScreen { backend: Rc, + person: Person, + online: bool, widget: gtk::Box, stack: gtk::Stack, work_list: Rc>, @@ -21,7 +23,7 @@ pub struct WorkSelectorPersonScreen { impl WorkSelectorPersonScreen { /// Create a new work selector person screen. - pub fn new(backend: Rc, person: Person) -> Rc { + pub fn new(backend: Rc, person: Person, online: bool) -> Rc { // Create UI let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_selector_screen.ui"); @@ -30,14 +32,18 @@ impl WorkSelectorPersonScreen { get_widget!(builder, libhandy::HeaderBar, header); get_widget!(builder, gtk::Button, back_button); get_widget!(builder, gtk::Stack, stack); + get_widget!(builder, gtk::ScrolledWindow, scroll); + get_widget!(builder, gtk::Button, try_again_button); header.set_title(Some(&person.name_fl())); let work_list = List::new(&gettext("No works found.")); - stack.add_named(&work_list.widget, "content"); + scroll.add(&work_list.widget); let this = Rc::new(Self { backend, + person, + online, widget, stack, work_list, @@ -54,6 +60,37 @@ impl WorkSelectorPersonScreen { } })); + let load_online = Rc::new(clone!(@strong this => move || { + this.stack.set_visible_child_name("loading"); + + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + match clone.backend.get_works(&clone.person.id).await { + Ok(works) => { + clone.work_list.show_items(works); + clone.stack.set_visible_child_name("content"); + } + Err(_) => { + clone.work_list.show_items(Vec::new()); + clone.stack.set_visible_child_name("error"); + } + } + }); + })); + + let load_local = Rc::new(clone!(@strong this => move || { + this.stack.set_visible_child_name("loading"); + + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + let works = clone.backend.db().get_works(&clone.person.id).await.unwrap(); + clone.work_list.show_items(works); + clone.stack.set_visible_child_name("content"); + }); + })); + this.work_list.set_make_widget(|work: &Work| { let label = gtk::Label::new(Some(&work.title)); label.set_ellipsize(pango::EllipsizeMode::End); @@ -67,24 +104,22 @@ impl WorkSelectorPersonScreen { this.work_list .set_selected(clone!(@strong this => move |work| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - if let Some(cb) = &*this.selected_cb.borrow() { - cb(work.clone()); - } + if let Some(cb) = &*this.selected_cb.borrow() { + cb(work.clone()); } })); + try_again_button.connect_clicked(clone!(@strong load_online => move |_| { + load_online(); + })); + // Initialize - let context = glib::MainContext::default(); - let clone = this.clone(); - context.spawn_local(async move { - let works = clone.backend.db().get_works(&person.id).await.unwrap(); - - clone.work_list.show_items(works); - clone.stack.set_visible_child_name("content"); - }); + if this.online { + load_online(); + } else { + load_local(); + } this } diff --git a/musicus/src/meson.build b/musicus/src/meson.build index 59d302a..bf0ad72 100644 --- a/musicus/src/meson.build +++ b/musicus/src/meson.build @@ -34,7 +34,10 @@ run_command( sources = files( 'backend/client/mod.rs', + 'backend/client/ensembles.rs', + 'backend/client/instruments.rs', 'backend/client/persons.rs', + 'backend/client/works.rs', 'backend/library.rs', 'backend/mod.rs', 'backend/secure.rs', diff --git a/musicus_server/src/main.rs b/musicus_server/src/main.rs index 98b39d7..efd35e7 100644 --- a/musicus_server/src/main.rs +++ b/musicus_server/src/main.rs @@ -39,6 +39,10 @@ async fn main() -> std::io::Result<()> { .service(update_instrument) .service(delete_instrument) .service(get_instruments) + .service(get_work) + .service(update_work) + .service(delete_work) + .service(get_works) }); server.bind("127.0.0.1:8087")?.run().await diff --git a/musicus_server/src/routes/works.rs b/musicus_server/src/routes/works.rs index e69de29..ad8a8e9 100644 --- a/musicus_server/src/routes/works.rs +++ b/musicus_server/src/routes/works.rs @@ -0,0 +1,74 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Work}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing work. +#[get("/works/{id}")] +pub async fn get_work( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_work(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new work or update an existin one. The user must be authorized to do that. +#[post("/works")] +pub async fn update_work( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_work(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/persons/{id}/works")] +pub async fn get_works( + db: web::Data, + composer_id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_works(&conn, &composer_id.into_inner())?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/works/{id}")] +pub async fn delete_work( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_work(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +}