diff --git a/podcasts-data/src/database.rs b/podcasts-data/src/database.rs index 72300ec..4d98811 100644 --- a/podcasts-data/src/database.rs +++ b/podcasts-data/src/database.rs @@ -48,12 +48,10 @@ lazy_static! { .unwrap(); } -#[cfg(test)] -extern crate tempdir; - #[cfg(test)] lazy_static! { - static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("podcasts_unit_test").unwrap() }; + pub(crate) static ref TEMPDIR: tempdir::TempDir = + { tempdir::TempDir::new("podcasts_unit_test").unwrap() }; static ref DB_PATH: PathBuf = TEMPDIR.path().join("podcasts.db"); } diff --git a/podcasts-data/src/opml.rs b/podcasts-data/src/opml.rs index 2edccc9..f3b2e11 100644 --- a/podcasts-data/src/opml.rs +++ b/podcasts-data/src/opml.rs @@ -23,16 +23,23 @@ use crate::errors::DataError; use crate::models::Source; -use xml::reader; +use dbqueries; +use xml::{ + common::XmlVersion, + reader, + writer::{events::XmlEvent, EmitterConfig}, +}; use std::collections::HashSet; use std::fs; -use std::io::Read; +use std::io::{Read, Write}; use std::path::Path; -// use std::fs::{File, OpenOptions}; +use std::fs::File; // use std::io::BufReader; +use failure::Error; + #[derive(Debug, Clone, PartialEq, Eq, Hash)] // FIXME: Make it a Diesel model /// Represents an `outline` xml element as per the `OPML` [specification][spec] @@ -75,6 +82,88 @@ pub fn import_from_file>(path: P) -> Result, DataErro import_to_db(content.as_slice()).map_err(From::from) } +/// Export a file to `P`, taking the feeds from the database and outputing +/// them in opml format. +pub fn export_from_db>(path: P, export_title: &str) -> Result<(), Error> { + let file = File::create(path)?; + export_to_file(&file, export_title) +} + +/// Export from `Source`s and `Show`s into `F` in OPML format +pub fn export_to_file(file: F, export_title: &str) -> Result<(), Error> { + let mut config = EmitterConfig::new().perform_indent(true); + config.perform_escaping = false; + + let mut writer = config.create_writer(file); + + let mut events: Vec> = Vec::new(); + + // Set up headers + let doc = XmlEvent::StartDocument { + version: XmlVersion::Version10, + encoding: Some("UTF-8"), + standalone: Some(false), + }; + events.push(doc); + + let opml: XmlEvent<'_> = XmlEvent::start_element("opml") + .attr("version", "2.0") + .into(); + events.push(opml); + + let head: XmlEvent<'_> = XmlEvent::start_element("head").into(); + events.push(head); + + let title_ev: XmlEvent<'_> = XmlEvent::start_element("title").into(); + events.push(title_ev); + + let title_chars: XmlEvent<'_> = XmlEvent::characters(export_title).into(); + events.push(title_chars); + + // Close & <head> + events.push(XmlEvent::end_element().into()); + events.push(XmlEvent::end_element().into()); + + let body: XmlEvent<'_> = XmlEvent::start_element("body").into(); + events.push(body); + + for event in events { + writer.write(event)?; + } + + // FIXME: Make this a model of a joined query (http://docs.diesel.rs/diesel/macro.joinable.html) + let shows = dbqueries::get_podcasts()?.into_iter().map(|show| { + let source = dbqueries::get_source_from_id(show.source_id()).unwrap(); + (source, show) + }); + + for (ref source, ref show) in shows { + let title = show.title(); + let link = show.link(); + let xml_url = source.uri(); + + let s_ev: XmlEvent<'_> = XmlEvent::start_element("outline") + .attr("text", title) + .attr("title", title) + .attr("type", "rss") + .attr("xmlUrl", xml_url) + .attr("htmlUrl", link) + .into(); + + let end_ev: XmlEvent<'_> = XmlEvent::end_element().into(); + writer.write(s_ev)?; + writer.write(end_ev)?; + } + + // Close <body> and <opml> + let end_bod: XmlEvent<'_> = XmlEvent::end_element().into(); + writer.write(end_bod)?; + let end_opml: XmlEvent<'_> = XmlEvent::end_element().into(); + writer.write(end_opml)?; + + Ok(()) +} + /// Extracts the `outline` elemnts from a reader `R` and returns a `HashSet` of `Opml` structs. pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> { let mut list = HashSet::new(); @@ -122,6 +211,43 @@ mod tests { use super::*; use chrono::Local; use failure::Error; + use futures::Future; + + use database::{truncate_db, TEMPDIR}; + use utils::get_feed; + + const URLS: &[(&str, &str)] = { + &[ + ( + "tests/feeds/2018-01-20-Intercepted.xml", + "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\ + com/InterceptedWithJeremyScahill", + ), + ( + "tests/feeds/2018-01-20-LinuxUnplugged.xml", + "https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\ + com/linuxunplugged", + ), + ( + "tests/feeds/2018-01-20-TheTipOff.xml", + "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff", + ), + ( + "tests/feeds/2018-01-20-StealTheStars.xml", + "https://web.archive.org/web/20180120104957if_/https://rss.art19.\ + com/steal-the-stars", + ), + ( + "tests/feeds/2018-01-20-GreaterThanCode.xml", + "https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\ + com/feed/podcast", + ), + ( + "tests/feeds/2019-01-27-ACC.xml", + "https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss" + ), + ] + }; #[test] fn test_extract() -> Result<(), Error> { @@ -184,4 +310,47 @@ mod tests { assert_eq!(extract_sources(sample1.as_bytes())?, map); Ok(()) } + + #[test] + fn text_export() -> Result<(), Error> { + truncate_db()?; + + URLS.iter().for_each(|&(path, url)| { + // Create and insert a Source into db + let s = Source::from_url(url).unwrap(); + let feed = get_feed(path, s.id()); + feed.index().wait().unwrap(); + }); + + let mut map: HashSet<Opml> = HashSet::new(); + let shows = dbqueries::get_podcasts()?.into_iter().map(|show| { + let source = dbqueries::get_source_from_id(show.source_id()).unwrap(); + (source, show) + }); + + for (ref source, ref show) in shows { + let title = show.title().to_string(); + // description is an optional field that we don't export + let description = String::new(); + let url = source.uri().to_string(); + + map.insert(Opml { + title, + description, + url, + }); + } + + let opml_path = TEMPDIR.path().join("podcasts.opml"); + export_from_db(opml_path.as_path(), "GNOME Podcasts Subscriptions")?; + let opml_file = File::open(opml_path.as_path())?; + assert_eq!(extract_sources(&opml_file)?, map); + + // extract_sources drains the reader its passed + let mut opml_file = File::open(opml_path.as_path())?; + let mut opml_str = String::new(); + opml_file.read_to_string(&mut opml_str)?; + assert_eq!(opml_str, include_str!("../tests/export_test.opml")); + Ok(()) + } } diff --git a/podcasts-data/tests/export_test.opml b/podcasts-data/tests/export_test.opml new file mode 100644 index 0000000..e2bd401 --- /dev/null +++ b/podcasts-data/tests/export_test.opml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<opml version="2.0"> + <head> + <title>GNOME Podcasts Subscriptions + + + + + + + + + + \ No newline at end of file diff --git a/podcasts-data/tests/feeds/2019-01-27-ACC.xml b/podcasts-data/tests/feeds/2019-01-27-ACC.xml new file mode 100644 index 0000000..6d4e773 --- /dev/null +++ b/podcasts-data/tests/feeds/2019-01-27-ACC.xml @@ -0,0 +1,176 @@ + + + + + David Harvey's Anti-Capitalist Chronicles + Thu, 17 Jan 2019 05:00:00 +0000 + Thu, 17 Jan 2019 05:06:40 +0000 + Libsyn WebEngine 2.0 + https://www.democracyatwork.info/acc + en + + https://www.democracyatwork.info/acc + info@democracyatwork.info (info@democracyatwork.info) + + + https://ssl-static.libsyn.com/p/assets/0/9/2/b/092b811513b710af/ACC_Logo_Final_LibsynSize.png + David Harvey's Anti-Capitalist Chronicles + + + David Harvey + capitalism,economics,marxism,politics + + + + clean + + + acc@democracyatwork.info + + + + episodic + + The significance of China in the Global Economy + The significance of China in the Global Economy + Thu, 17 Jan 2019 05:00:00 +0000 + + + + The Chinese economy is now the 2nd largest in the world. Prof. Harvey argues that China's expansion saved capitalism after the 2008 crash.

]]>
+ The Chinese economy is now the 2nd largest in the world. Prof. Harvey argues that China's expansion saved capitalism after the 2008 crash.

]]>
+ + 44:15 + clean + + + The Chinese economy is now the 2nd largest in the world. Prof. Harvey argues that China's expansion saved capitalism after the 2008 crash. + 1 + 7 + full + Democracy at Work +
+ + Does Socialism Affect Freedom? + Does Socialism Affect Freedom? + Thu, 03 Jan 2019 05:00:00 +0000 + + + + Does socialism require the surrender of individual freedom? The realm of freedom begins when the realm of necessity is left behind. Is freedom of the market real freedom?  And what about justice?  Prof. Harvey tries to answer these questions and more.

]]>
+ Does socialism require the surrender of individual freedom? The realm of freedom begins when the realm of necessity is left behind. Is freedom of the market real freedom?  And what about justice?  Prof. Harvey tries to answer these questions and more.

]]>
+ + 25:02 + clean + + + Does socialism require the surrender of individual freedom? The realm of freedom begins when the realm of necessity is left behind. Is freedom of the market real freedom?  And what about justice?  Prof. Harvey tries to answer these questions and more. + 1 + 6 + full + Democracy at Work +
+ + The Value of Everything + "The Value of Everything + Thu, 13 Dec 2018 05:00:00 +0000 + + + + Prof. Harvey talks about Mariana Mazzucato's new book "The Value of Everything: Making and Taking in the Global Economy."

]]>
+ Prof. Harvey talks about Mariana Mazzucato's new book "The Value of Everything: Making and Taking in the Global Economy."

]]>
+ + 25:19 + clean + + + Prof. Harvey talks about Mariana Mazzucato's new book "The Value of Everything: Making and Taking in the Global Economy." + 1 + 5 + full + Democracy at Work +
+ + The Brazilian Elections + The Brazilian Elections + Thu, 29 Nov 2018 05:00:00 +0000 + + + + Prof. Harvey talks about the recent Brazilian elections and the growing alliance between Neo-liberalism and Right-Wing Populism.

]]>
+ Prof. Harvey talks about the recent Brazilian elections and the growing alliance between Neo-liberalism and Right-Wing Populism.

]]>
+ + 30:23 + clean + + + Prof. Harvey talks about the recent Brazilian elections and the growing alliance between Neo-liberalism and Right-Wing Populism. + 1 + 4 + full + Democracy at Work +
+ + The Financialization of Power + The Financialization of Power + Thu, 15 Nov 2018 13:10:00 +0000 + + + + Financial services become part of GDP in the 1970s and legitimize the power of financial institutions. 

]]>
+ Financial services become part of GDP in the 1970s and legitimize the power of financial institutions. 

]]>
+ + 19:27 + no + + + Financial services become part of GDP in the 1970s and legitimize the power of financial institutions.  + 1 + 3 + full + Democracy at Work +
+ + Contradictions of Neo-Liberalism + The Contradictions of Neo-Liberalism + Thu, 15 Nov 2018 13:05:00 +0000 + + + + The crash of 2008 challenges Neo-Liberalism.

]]>
+ The crash of 2008 challenges Neo-Liberalism.

]]>
+ + 18:58 + clean + + + The crash of 2008 challenge Neo-Liberalism. + 1 + 2 + full + Democracy at Work +
+ + A brief history of Neo-Liberalism + A brief history of Neo-Liberalism + Mon, 12 Nov 2018 22:01:39 +0000 + + + + Prof. David Harvey's pilot episode. He provides a quick history of the rise and growth of Neo-Liberalism. 

Support the show on Patreon and get early access to episodes and more: https://www.patreon.com/davidharveyacc

]]>
+ Prof. David Harvey's pilot episode. He provides a quick history of the rise and growth of Neo-Liberalism. 

Support the show on Patreon and get early access to episodes and more: https://www.patreon.com/davidharveyacc

]]>
+ + 19:35 + clean + + + Prof. David Harvey's pilot episode. He provides a quick history of the rise and growth of Neo-Liberalism. + +Support the show on Patreon and get early access to episodes and more: https://www.patreon.com/davidharveyacc + 1 + 1 + full + Democracy at Work +
+
+
diff --git a/podcasts-data/tests/feeds/notes.md b/podcasts-data/tests/feeds/notes.md index 41114e5..4f90dcf 100644 --- a/podcasts-data/tests/feeds/notes.md +++ b/podcasts-data/tests/feeds/notes.md @@ -42,4 +42,10 @@ Raw file: https://web.archive.org/web/20180120104741if_/https://www.greaterthanc Web view: https://web.archive.org/web/20180328083913/https://ellinofreneia.sealabs.net/audio/podcast.rss -Raw file: https://web.archive.org/web/20180328083913if_/https://ellinofreneia.sealabs.net/audio/podcast.rss \ No newline at end of file +Raw file: https://web.archive.org/web/20180328083913if_/https://ellinofreneia.sealabs.net/audio/podcast.rss + +#### David Harvey's Anti-Capitalist Chronicles + +Web view: https://web.archive.org/web/20190127005213/https://anticapitalistchronicles.libsyn.com/rss + +Raw file: https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss diff --git a/podcasts-gtk/resources/gtk/hamburger.ui b/podcasts-gtk/resources/gtk/hamburger.ui index 27c921a..c1f90c5 100644 --- a/podcasts-gtk/resources/gtk/hamburger.ui +++ b/podcasts-gtk/resources/gtk/hamburger.ui @@ -12,10 +12,10 @@ _Import Shows win.import - - - - + + _Export Shows + win.export +
@@ -38,3 +38,4 @@
+ diff --git a/podcasts-gtk/src/app.rs b/podcasts-gtk/src/app.rs index 63f4261..c5bf72a 100644 --- a/podcasts-gtk/src/app.rs +++ b/podcasts-gtk/src/app.rs @@ -266,6 +266,10 @@ impl App { weak_win.upgrade().map(|win| utils::on_import_clicked(&win, &sender)); })); + action(&self.window, "export", clone!(sender, weak_win => move |_, _| { + weak_win.upgrade().map(|win| utils::on_export_clicked(&win, &sender)); + })); + // Create the action that shows a `gtk::AboutDialog` action(&self.window, "about", clone!(weak_win => move |_, _| { weak_win.upgrade().map(|win| about_dialog(&win)); diff --git a/podcasts-gtk/src/utils.rs b/podcasts-gtk/src/utils.rs index 5679313..765a050 100644 --- a/podcasts-gtk/src/utils.rs +++ b/podcasts-gtk/src/utils.rs @@ -414,6 +414,48 @@ pub(crate) fn on_import_clicked(window: >k::ApplicationWindow, sender: &Sender } } +pub(crate) fn on_export_clicked(window: >k::ApplicationWindow, sender: &Sender) { + use glib::translate::ToGlib; + use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType}; + + // Create the FileChooser Dialog + let dialog = FileChooserNative::new( + Some(i18n("Export shows to...").as_str()), + Some(window), + FileChooserAction::Save, + Some(i18n("_Export").as_str()), + Some(i18n("_Cancel").as_str()), + ); + + // Do not show hidden(.thing) files + dialog.set_show_hidden(false); + + // Set a filter to show only xml files + let filter = FileFilter::new(); + FileFilterExt::set_name(&filter, Some(i18n("OPML file").as_str())); + filter.add_mime_type("application/xml"); + filter.add_mime_type("text/xml"); + dialog.add_filter(&filter); + + let resp = dialog.run(); + debug!("Dialog Response {}", resp); + if resp == ResponseType::Accept.to_glib() { + if let Some(filename) = dialog.get_filename() { + debug!("File selected: {:?}", filename); + + rayon::spawn(clone!(sender => move || { + if opml::export_from_db(filename, i18n("GNOME Podcasts Subscriptions").as_str()).is_err() { + let text = i18n("Failed to export podcasts"); + sender.send(Action::ErrorNotification(text)); + } + })) + } else { + let text = i18n("Selected file could not be accessed."); + sender.send(Action::ErrorNotification(text)); + } + } +} + #[cfg(test)] mod tests { use super::*;