442 lines
12 KiB
Rust
442 lines
12 KiB
Rust
//! This crate provides an API to parse and create [OPML documents].
|
|
//!
|
|
//! [OPML documents]: http://opml.org/spec2.opml
|
|
//!
|
|
//! ## Parsing
|
|
//!
|
|
//! To parse an OPML document use [`OPML::from_str`] or [`OPML::from_reader`].
|
|
//!
|
|
//! Parsing will result in an error if:
|
|
//! * the XML is malformed,
|
|
//! * the included OPML version is not supported (currently all OPML versions
|
|
//! (1.0, 1.1 and 2.0) are supported) or,
|
|
//! * if the [`Body`] element contains no child [`Outline`] elements,
|
|
//! [as per the spec].
|
|
//!
|
|
//! [as per the spec]: http://opml.org/spec2.opml#1629042198000
|
|
//!
|
|
//! ```rust
|
|
//! use opml::OPML;
|
|
//!
|
|
//! let xml = r#"<opml version="2.0"><head/><body><outline text="Outline"/></body></opml>"#;
|
|
//! let document = OPML::from_str(xml).unwrap();
|
|
//!
|
|
//! assert_eq!(document.version, "2.0");
|
|
//! ```
|
|
//!
|
|
//! ## Creating
|
|
//!
|
|
//! To create an OPML document from scratch, use [`OPML::default()`] or the good
|
|
//! old `OPML { /* ... */ }` syntax.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(missing_docs, clippy::missing_docs_in_private_items)]
|
|
|
|
use hard_xml::{XmlRead, XmlWrite};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
/// All possible errors.
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
/// [From the spec], "a `<body>` contains one or more `<outline>` elements".
|
|
///
|
|
/// [From the spec]: http://opml.org/spec2.opml#1629042198000
|
|
#[error("OPML body has no <outline> elements")]
|
|
BodyHasNoOutlines,
|
|
|
|
/// Wrapper for [`std::io::Error`].
|
|
#[error("Failed to read file")]
|
|
IoError(#[from] std::io::Error),
|
|
|
|
/// The version string in the XML is not supported.
|
|
#[error("Unsupported OPML version: {0:?}")]
|
|
UnsupportedVersion(String),
|
|
|
|
/// The input string is not valid XML.
|
|
#[error("Failed to process XML file")]
|
|
XmlError(#[from] hard_xml::XmlError),
|
|
}
|
|
|
|
/// The top-level [`OPML`] element.
|
|
#[derive(
|
|
XmlWrite, XmlRead, PartialEq, Eq, Debug, Clone, Serialize, Deserialize,
|
|
)]
|
|
#[xml(tag = "opml")]
|
|
pub struct OPML {
|
|
/// The version attribute from the element, valid values are `1.0`, `1.1` and
|
|
/// `2.0`.
|
|
#[xml(attr = "version")]
|
|
pub version: String,
|
|
|
|
/// The [`Head`] child element. Contains the metadata of the OPML document.
|
|
#[xml(child = "head")]
|
|
pub head: Option<Head>,
|
|
|
|
/// The [`Body`] child element. Contains all the [`Outline`] elements.
|
|
#[xml(child = "body")]
|
|
pub body: Body,
|
|
}
|
|
|
|
impl OPML {
|
|
/// Deprecated, use [`OPML::from_str`] instead.
|
|
#[deprecated(note = "Use from_str instead", since = "1.1.0")]
|
|
pub fn new(xml: &str) -> Result<Self, Error> {
|
|
Self::from_str(xml).map_err(Into::into)
|
|
}
|
|
|
|
/// Parses an OPML document.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use opml::{OPML, Outline};
|
|
///
|
|
/// let xml = r#"<opml version="2.0"><head/><body><outline text="Outline"/></body></opml>"#;
|
|
/// let document = OPML::from_str(xml).unwrap();
|
|
///
|
|
/// let mut expected = OPML::default();
|
|
/// expected.body.outlines.push(Outline {
|
|
/// text: "Outline".to_string(),
|
|
/// ..Outline::default()
|
|
/// });
|
|
///
|
|
/// assert_eq!(document, expected);
|
|
/// ```
|
|
#[allow(clippy::should_implement_trait)]
|
|
pub fn from_str(xml: &str) -> Result<Self, Error> {
|
|
let opml = <OPML as XmlRead>::from_str(xml)?;
|
|
|
|
// SPEC: The version attribute is a version string, of the form, x.y, where
|
|
// x and y are both numeric strings.
|
|
let valid_versions = vec!["1.0", "1.1", "2.0"];
|
|
|
|
if !valid_versions.contains(&opml.version.as_str()) {
|
|
return Err(Error::UnsupportedVersion(opml.version));
|
|
}
|
|
|
|
// SPEC: A `<body>` contains one or more `<outline>` elements.
|
|
if opml.body.outlines.is_empty() {
|
|
return Err(Error::BodyHasNoOutlines);
|
|
}
|
|
|
|
Ok(opml)
|
|
}
|
|
|
|
/// Parses an OPML document from a reader.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust,no_run
|
|
/// use opml::OPML;
|
|
///
|
|
/// let mut file = std::fs::File::open("file.opml").unwrap();
|
|
/// let document = OPML::from_reader(&mut file).unwrap();
|
|
/// ```
|
|
pub fn from_reader<R>(reader: &mut R) -> Result<Self, Error>
|
|
where
|
|
R: std::io::Read,
|
|
{
|
|
let mut s = String::new();
|
|
reader.read_to_string(&mut s)?;
|
|
Self::from_str(&s).map_err(Into::into)
|
|
}
|
|
|
|
/// Helper function to add an [`Outline`] element with `text` and `xml_url`
|
|
/// attributes to the [`Body`]. Useful for creating feed lists quickly.
|
|
/// This function also exists as [`Outline::add_feed`] for grouped lists.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use opml::{OPML, Outline};
|
|
///
|
|
/// let mut opml = OPML::default();
|
|
/// opml.add_feed("Feed Name", "https://example.com/");
|
|
/// let added_feed = opml.body.outlines.first().unwrap();
|
|
///
|
|
/// let expected_feed = &Outline {
|
|
/// text: "Feed Name".to_string(),
|
|
/// xml_url: Some("https://example.com/".to_string()),
|
|
/// ..Outline::default()
|
|
/// };
|
|
///
|
|
/// assert_eq!(added_feed, expected_feed);
|
|
/// ```
|
|
pub fn add_feed(&mut self, text: &str, url: &str) -> &mut Self {
|
|
self.body.outlines.push(Outline {
|
|
text: text.to_string(),
|
|
xml_url: Some(url.to_string()),
|
|
..Outline::default()
|
|
});
|
|
|
|
self
|
|
}
|
|
|
|
/// Deprecated, use [`OPML::to_string`] instead.
|
|
#[deprecated(note = "Use to_string instead", since = "1.1.0")]
|
|
pub fn to_xml(&self) -> Result<String, Error> {
|
|
self.to_string()
|
|
}
|
|
|
|
/// Converts the struct to an XML document.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use opml::OPML;
|
|
///
|
|
/// let opml = OPML::default();
|
|
/// let xml = opml.to_string().unwrap();
|
|
///
|
|
/// let expected = r#"<opml version="2.0"><head/><body/></opml>"#;
|
|
/// assert_eq!(xml, expected);
|
|
/// ```
|
|
pub fn to_string(&self) -> Result<String, Error> {
|
|
Ok(XmlWrite::to_string(self)?)
|
|
}
|
|
|
|
/// Converts the struct to an XML document and writes it using the writer.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust,no_run
|
|
/// use opml::OPML;
|
|
///
|
|
/// let opml = OPML::default();
|
|
/// let mut file = std::fs::File::create("file.opml").unwrap();
|
|
/// let xml = opml.to_writer(&mut file).unwrap();
|
|
/// ```
|
|
pub fn to_writer<W>(&self, writer: &mut W) -> Result<(), Error>
|
|
where
|
|
W: std::io::Write,
|
|
{
|
|
let xml_string = self.to_string()?;
|
|
writer.write_all(xml_string.as_bytes())?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for OPML {
|
|
fn default() -> Self {
|
|
OPML {
|
|
version: "2.0".to_string(),
|
|
head: Some(Head::default()),
|
|
body: Body::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The [`Head`] child element of [`OPML`]. Contains the metadata of the OPML
|
|
/// document.
|
|
#[derive(
|
|
XmlWrite,
|
|
XmlRead,
|
|
PartialEq,
|
|
Eq,
|
|
Debug,
|
|
Clone,
|
|
Default,
|
|
Serialize,
|
|
Deserialize,
|
|
)]
|
|
#[xml(tag = "head")]
|
|
pub struct Head {
|
|
/// The title of the document.
|
|
#[xml(flatten_text = "title")]
|
|
pub title: Option<String>,
|
|
|
|
/// A date-time (RFC822) indicating when the document was created.
|
|
#[xml(flatten_text = "dateCreated")]
|
|
pub date_created: Option<String>,
|
|
|
|
/// A date-time (RFC822) indicating when the document was last modified.
|
|
#[xml(flatten_text = "dateModified")]
|
|
pub date_modified: Option<String>,
|
|
|
|
/// The name of the document owner.
|
|
#[xml(flatten_text = "ownerName")]
|
|
pub owner_name: Option<String>,
|
|
|
|
/// The email address of the document owner.
|
|
#[xml(flatten_text = "ownerEmail")]
|
|
pub owner_email: Option<String>,
|
|
|
|
/// A link to the website of the document owner.
|
|
#[xml(flatten_text = "ownerId")]
|
|
pub owner_id: Option<String>,
|
|
|
|
/// A link to the documentation of the OPML format used for this document.
|
|
#[xml(flatten_text = "docs")]
|
|
pub docs: Option<String>,
|
|
|
|
/// A comma-separated list of line numbers that are expanded. The line numbers
|
|
/// in the list tell you which headlines to expand. The order is important.
|
|
/// For each element in the list, X, starting at the first summit, navigate
|
|
/// flatdown X times and expand. Repeat for each element in the list.
|
|
#[xml(flatten_text = "expansionState")]
|
|
pub expansion_state: Option<String>,
|
|
|
|
/// A number indicating which line of the outline is displayed on the top line
|
|
/// of the window. This number is calculated with the expansion state already
|
|
/// applied.
|
|
#[xml(flatten_text = "vertScrollState")]
|
|
pub vert_scroll_state: Option<i32>,
|
|
|
|
/// The pixel location of the top edge of the window.
|
|
#[xml(flatten_text = "windowTop")]
|
|
pub window_top: Option<i32>,
|
|
|
|
/// The pixel location of the left edge of the window.
|
|
#[xml(flatten_text = "windowLeft")]
|
|
pub window_left: Option<i32>,
|
|
|
|
/// The pixel location of the bottom edge of the window.
|
|
#[xml(flatten_text = "windowBottom")]
|
|
pub window_bottom: Option<i32>,
|
|
|
|
/// The pixel location of the right edge of the window.
|
|
#[xml(flatten_text = "windowRight")]
|
|
pub window_right: Option<i32>,
|
|
}
|
|
|
|
/// The [`Body`] child element of [`OPML`]. Contains all the [`Outline`]
|
|
/// elements.
|
|
#[derive(
|
|
XmlWrite,
|
|
XmlRead,
|
|
PartialEq,
|
|
Eq,
|
|
Debug,
|
|
Clone,
|
|
Default,
|
|
Serialize,
|
|
Deserialize,
|
|
)]
|
|
#[xml(tag = "body")]
|
|
pub struct Body {
|
|
/// All the top-level [`Outline`] elements.
|
|
#[xml(child = "outline")]
|
|
pub outlines: Vec<Outline>,
|
|
}
|
|
|
|
/// The [`Outline`] element.
|
|
#[derive(
|
|
XmlWrite,
|
|
XmlRead,
|
|
PartialEq,
|
|
Eq,
|
|
Debug,
|
|
Clone,
|
|
Default,
|
|
Serialize,
|
|
Deserialize,
|
|
)]
|
|
#[xml(tag = "outline")]
|
|
pub struct Outline {
|
|
/// Every outline element must have at least a text attribute, which is what
|
|
/// is displayed when an outliner opens the OPML document.
|
|
///
|
|
/// Version 1.0 OPML documents may omit this attribute, so for compatibility
|
|
/// and strictness this attribute is "technically optional" as it will be
|
|
/// replaced by an empty String if it is omitted.
|
|
///
|
|
/// Text attributes may contain encoded HTML markup.
|
|
#[xml(default, attr = "text")]
|
|
pub text: String,
|
|
|
|
/// A string that indicates how the other attributes of the [`Outline`]
|
|
/// should be interpreted.
|
|
#[xml(attr = "type")]
|
|
pub r#type: Option<String>,
|
|
|
|
/// Indicating whether the outline is commented or not. By convention if an
|
|
/// outline is commented, all subordinate outlines are considered to also be
|
|
/// commented.
|
|
#[xml(attr = "isComment")]
|
|
pub is_comment: Option<bool>,
|
|
|
|
/// Indicating whether a breakpoint is set on this outline. This attribute is
|
|
/// mainly necessary for outlines used to edit scripts.
|
|
#[xml(attr = "isBreakpoint")]
|
|
pub is_breakpoint: Option<bool>,
|
|
|
|
/// The date-time (RFC822) that this [`Outline`] element was created.
|
|
#[xml(attr = "created")]
|
|
pub created: Option<String>,
|
|
|
|
/// A string of comma-separated slash-delimited category strings, in the
|
|
/// format defined by the [RSS 2.0 category] element. To represent a "tag",
|
|
/// the category string should contain no slashes.
|
|
///
|
|
/// [RSS 2.0 category]: https://cyber.law.harvard.edu/rss/rss.html#ltcategorygtSubelementOfLtitemgt
|
|
#[xml(attr = "category")]
|
|
pub category: Option<String>,
|
|
|
|
/// Child [`Outline`] elements of the current one.
|
|
#[xml(child = "outline")]
|
|
pub outlines: Vec<Outline>,
|
|
|
|
/// The HTTP address of the feed.
|
|
#[xml(attr = "xmlUrl")]
|
|
pub xml_url: Option<String>,
|
|
|
|
/// The top-level description element from the feed.
|
|
#[xml(attr = "description")]
|
|
pub description: Option<String>,
|
|
|
|
/// The top-level link element from the feed.
|
|
#[xml(attr = "htmlUrl")]
|
|
pub html_url: Option<String>,
|
|
|
|
/// The top-level language element from the feed.
|
|
#[xml(attr = "language")]
|
|
pub language: Option<String>,
|
|
|
|
/// The top-level title element from the feed.
|
|
#[xml(attr = "title")]
|
|
pub title: Option<String>,
|
|
|
|
/// The version of the feed's format (such as RSS 0.91, 2.0, ...).
|
|
#[xml(attr = "version")]
|
|
pub version: Option<String>,
|
|
|
|
/// A link that can point to another OPML document or to something that can
|
|
/// be displayed in a web browser.
|
|
#[xml(attr = "url")]
|
|
pub url: Option<String>,
|
|
}
|
|
|
|
impl Outline {
|
|
/// Helper function to add an [`Outline`] element with `text` and `xml_url`
|
|
/// attributes as a child element, useful for creating grouped lists. This
|
|
/// function also exists as [`OPML::add_feed`] for non-grouped lists.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use opml::Outline;
|
|
///
|
|
/// let mut group = Outline::default();
|
|
/// group.add_feed("Feed Name", "https://example.com/");
|
|
/// let added_feed = group.outlines.first().unwrap();
|
|
///
|
|
/// let expected_feed = &Outline {
|
|
/// text: "Feed Name".to_string(),
|
|
/// xml_url: Some("https://example.com/".to_string()),
|
|
/// ..Outline::default()
|
|
/// };
|
|
///
|
|
/// assert_eq!(added_feed, expected_feed);
|
|
/// ```
|
|
pub fn add_feed(&mut self, name: &str, url: &str) -> &mut Self {
|
|
self.outlines.push(Outline {
|
|
text: name.to_string(),
|
|
xml_url: Some(url.to_string()),
|
|
..Outline::default()
|
|
});
|
|
|
|
self
|
|
}
|
|
}
|