opml/
lib.rs

1//! This crate provides an API to parse and create [OPML documents].
2//!
3//! [OPML documents]: http://opml.org/spec2.opml
4//!
5//! ## Parsing
6//!
7//! To parse an OPML document use [`OPML::from_str`] or [`OPML::from_reader`].
8//!
9//! Parsing will result in an error if:
10//! * the XML is malformed,
11//! * the included OPML version is not supported (currently all OPML versions
12//!   (1.0, 1.1 and 2.0) are supported) or,
13//! * if the [`Body`] element contains no child [`Outline`] elements,
14//!   [as per the spec].
15//!
16//! [as per the spec]: http://opml.org/spec2.opml#1629042198000
17//!
18//! ```rust
19//! use opml::OPML;
20//!
21//! let xml = r#"<opml version="2.0"><head/><body><outline text="Outline"/></body></opml>"#;
22//! let document = OPML::from_str(xml).unwrap();
23//!
24//! assert_eq!(document.version, "2.0");
25//! ```
26//!
27//! ## Creating
28//!
29//! To create an OPML document from scratch, use [`OPML::default()`] or the good
30//! old `OPML { /* ... */ }` syntax.
31
32#![forbid(unsafe_code)]
33#![warn(missing_docs, clippy::missing_docs_in_private_items)]
34
35use hard_xml::{XmlRead, XmlWrite};
36use serde::{Deserialize, Serialize};
37use thiserror::Error;
38
39/// All possible errors.
40#[derive(Debug, Error)]
41pub enum Error {
42  /// [From the spec], "a `<body>` contains one or more `<outline>` elements".
43  ///
44  /// [From the spec]: http://opml.org/spec2.opml#1629042198000
45  #[error("OPML body has no <outline> elements")]
46  BodyHasNoOutlines,
47
48  /// Wrapper for [`std::io::Error`].
49  #[error("Failed to read file")]
50  IoError(#[from] std::io::Error),
51
52  /// The version string in the XML is not supported.
53  #[error("Unsupported OPML version: {0:?}")]
54  UnsupportedVersion(String),
55
56  /// The input string is not valid XML.
57  #[error("Failed to process XML file")]
58  XmlError(#[from] hard_xml::XmlError),
59}
60
61/// The top-level [`OPML`] element.
62#[derive(
63  XmlWrite, XmlRead, PartialEq, Eq, Debug, Clone, Serialize, Deserialize,
64)]
65#[xml(tag = "opml")]
66pub struct OPML {
67  /// The version attribute from the element, valid values are `1.0`, `1.1` and
68  /// `2.0`.
69  #[xml(attr = "version")]
70  pub version: String,
71
72  /// The [`Head`] child element. Contains the metadata of the OPML document.
73  #[xml(child = "head")]
74  pub head: Option<Head>,
75
76  /// The [`Body`] child element. Contains all the [`Outline`] elements.
77  #[xml(child = "body")]
78  pub body: Body,
79}
80
81impl OPML {
82  /// Deprecated, use [`OPML::from_str`] instead.
83  #[deprecated(note = "Use from_str instead", since = "1.1.0")]
84  pub fn new(xml: &str) -> Result<Self, Error> {
85    Self::from_str(xml).map_err(Into::into)
86  }
87
88  /// Parses an OPML document.
89  ///
90  /// # Example
91  ///
92  /// ```rust
93  /// use opml::{OPML, Outline};
94  ///
95  /// let xml = r#"<opml version="2.0"><head/><body><outline text="Outline"/></body></opml>"#;
96  /// let document = OPML::from_str(xml).unwrap();
97  ///
98  /// let mut expected = OPML::default();
99  /// expected.body.outlines.push(Outline {
100  ///   text: "Outline".to_string(),
101  ///   ..Outline::default()
102  /// });
103  ///
104  /// assert_eq!(document, expected);
105  /// ```
106  #[allow(clippy::should_implement_trait)]
107  pub fn from_str(xml: &str) -> Result<Self, Error> {
108    let opml = <OPML as XmlRead>::from_str(xml)?;
109
110    // SPEC: The version attribute is a version string, of the form, x.y, where
111    // x and y are both numeric strings.
112    let valid_versions = ["1.0", "1.1", "2.0"];
113
114    if !valid_versions.contains(&opml.version.as_str()) {
115      return Err(Error::UnsupportedVersion(opml.version));
116    }
117
118    // SPEC: A `<body>` contains one or more `<outline>` elements.
119    if opml.body.outlines.is_empty() {
120      return Err(Error::BodyHasNoOutlines);
121    }
122
123    Ok(opml)
124  }
125
126  /// Parses an OPML document from a reader.
127  ///
128  /// # Example
129  ///
130  /// ```rust,no_run
131  /// use opml::OPML;
132  ///
133  /// let mut file = std::fs::File::open("file.opml").unwrap();
134  /// let document = OPML::from_reader(&mut file).unwrap();
135  /// ```
136  pub fn from_reader<R>(reader: &mut R) -> Result<Self, Error>
137  where
138    R: std::io::Read,
139  {
140    let mut s = String::new();
141    reader.read_to_string(&mut s)?;
142    Self::from_str(&s).map_err(Into::into)
143  }
144
145  /// Helper function to add an [`Outline`] element with `text` and `xml_url`
146  /// attributes to the [`Body`]. Useful for creating feed lists quickly.
147  /// This function also exists as [`Outline::add_feed`] for grouped lists.
148  ///
149  /// # Example
150  ///
151  /// ```rust
152  /// use opml::{OPML, Outline};
153  ///
154  /// let mut opml = OPML::default();
155  /// opml.add_feed("Feed Name", "https://example.com/");
156  /// let added_feed = opml.body.outlines.first().unwrap();
157  ///
158  /// let expected_feed = &Outline {
159  ///   text: "Feed Name".to_string(),
160  ///   xml_url: Some("https://example.com/".to_string()),
161  ///   ..Outline::default()
162  /// };
163  ///
164  /// assert_eq!(added_feed, expected_feed);
165  /// ```
166  pub fn add_feed(&mut self, text: &str, url: &str) -> &mut Self {
167    self.body.outlines.push(Outline {
168      text: text.to_string(),
169      xml_url: Some(url.to_string()),
170      ..Outline::default()
171    });
172
173    self
174  }
175
176  /// Deprecated, use [`OPML::to_string`] instead.
177  #[deprecated(note = "Use to_string instead", since = "1.1.0")]
178  pub fn to_xml(&self) -> Result<String, Error> {
179    self.to_string()
180  }
181
182  /// Converts the struct to an XML document.
183  ///
184  /// # Example
185  ///
186  /// ```rust
187  /// use opml::OPML;
188  ///
189  /// let opml = OPML::default();
190  /// let xml = opml.to_string().unwrap();
191  ///
192  /// let expected = r#"<opml version="2.0"><head/><body/></opml>"#;
193  /// assert_eq!(xml, expected);
194  /// ```
195  pub fn to_string(&self) -> Result<String, Error> {
196    Ok(XmlWrite::to_string(self)?)
197  }
198
199  /// Converts the struct to an XML document and writes it using the writer.
200  ///
201  /// # Example
202  ///
203  /// ```rust,no_run
204  /// use opml::OPML;
205  ///
206  /// let opml = OPML::default();
207  /// let mut file = std::fs::File::create("file.opml").unwrap();
208  /// let xml = opml.to_writer(&mut file).unwrap();
209  /// ```
210  pub fn to_writer<W>(&self, writer: &mut W) -> Result<(), Error>
211  where
212    W: std::io::Write,
213  {
214    let xml_string = self.to_string()?;
215    writer.write_all(xml_string.as_bytes())?;
216    Ok(())
217  }
218}
219
220impl Default for OPML {
221  fn default() -> Self {
222    OPML {
223      version: "2.0".to_string(),
224      head: Some(Head::default()),
225      body: Body::default(),
226    }
227  }
228}
229
230/// The [`Head`] child element of [`OPML`]. Contains the metadata of the OPML
231/// document.
232#[derive(
233  XmlWrite,
234  XmlRead,
235  PartialEq,
236  Eq,
237  Debug,
238  Clone,
239  Default,
240  Serialize,
241  Deserialize,
242)]
243#[xml(tag = "head")]
244pub struct Head {
245  /// The title of the document.
246  #[xml(flatten_text = "title")]
247  pub title: Option<String>,
248
249  /// A date-time (RFC822) indicating when the document was created.
250  #[xml(flatten_text = "dateCreated")]
251  pub date_created: Option<String>,
252
253  /// A date-time (RFC822) indicating when the document was last modified.
254  #[xml(flatten_text = "dateModified")]
255  pub date_modified: Option<String>,
256
257  /// The name of the document owner.
258  #[xml(flatten_text = "ownerName")]
259  pub owner_name: Option<String>,
260
261  /// The email address of the document owner.
262  #[xml(flatten_text = "ownerEmail")]
263  pub owner_email: Option<String>,
264
265  /// A link to the website of the document owner.
266  #[xml(flatten_text = "ownerId")]
267  pub owner_id: Option<String>,
268
269  /// A link to the documentation of the OPML format used for this document.
270  #[xml(flatten_text = "docs")]
271  pub docs: Option<String>,
272
273  /// A comma-separated list of line numbers that are expanded. The line numbers
274  /// in the list tell you which headlines to expand. The order is important.
275  /// For each element in the list, X, starting at the first summit, navigate
276  /// flatdown X times and expand. Repeat for each element in the list.
277  #[xml(flatten_text = "expansionState")]
278  pub expansion_state: Option<String>,
279
280  /// A number indicating which line of the outline is displayed on the top line
281  /// of the window. This number is calculated with the expansion state already
282  /// applied.
283  #[xml(flatten_text = "vertScrollState")]
284  pub vert_scroll_state: Option<i32>,
285
286  /// The pixel location of the top edge of the window.
287  #[xml(flatten_text = "windowTop")]
288  pub window_top: Option<i32>,
289
290  /// The pixel location of the left edge of the window.
291  #[xml(flatten_text = "windowLeft")]
292  pub window_left: Option<i32>,
293
294  /// The pixel location of the bottom edge of the window.
295  #[xml(flatten_text = "windowBottom")]
296  pub window_bottom: Option<i32>,
297
298  /// The pixel location of the right edge of the window.
299  #[xml(flatten_text = "windowRight")]
300  pub window_right: Option<i32>,
301}
302
303/// The [`Body`] child element of [`OPML`]. Contains all the [`Outline`]
304/// elements.
305#[derive(
306  XmlWrite,
307  XmlRead,
308  PartialEq,
309  Eq,
310  Debug,
311  Clone,
312  Default,
313  Serialize,
314  Deserialize,
315)]
316#[xml(tag = "body")]
317pub struct Body {
318  /// All the top-level [`Outline`] elements.
319  #[xml(child = "outline")]
320  pub outlines: Vec<Outline>,
321}
322
323/// The [`Outline`] element.
324#[derive(
325  XmlWrite,
326  XmlRead,
327  PartialEq,
328  Eq,
329  Debug,
330  Clone,
331  Default,
332  Serialize,
333  Deserialize,
334)]
335#[xml(tag = "outline")]
336pub struct Outline {
337  /// Every outline element must have at least a text attribute, which is what
338  /// is displayed when an outliner opens the OPML document.
339  ///
340  /// Version 1.0 OPML documents may omit this attribute, so for compatibility
341  /// and strictness this attribute is "technically optional" as it will be
342  /// replaced by an empty String if it is omitted.
343  ///
344  /// Text attributes may contain encoded HTML markup.
345  #[xml(default, attr = "text")]
346  pub text: String,
347
348  /// A string that indicates how the other attributes of the [`Outline`]
349  /// should be interpreted.
350  #[xml(attr = "type")]
351  pub r#type: Option<String>,
352
353  /// Indicating whether the outline is commented or not. By convention if an
354  /// outline is commented, all subordinate outlines are considered to also be
355  /// commented.
356  #[xml(attr = "isComment")]
357  pub is_comment: Option<bool>,
358
359  /// Indicating whether a breakpoint is set on this outline. This attribute is
360  /// mainly necessary for outlines used to edit scripts.
361  #[xml(attr = "isBreakpoint")]
362  pub is_breakpoint: Option<bool>,
363
364  /// The date-time (RFC822) that this [`Outline`] element was created.
365  #[xml(attr = "created")]
366  pub created: Option<String>,
367
368  /// A string of comma-separated slash-delimited category strings, in the
369  /// format defined by the [RSS 2.0 category] element. To represent a "tag",
370  /// the category string should contain no slashes.
371  ///
372  /// [RSS 2.0 category]: https://cyber.law.harvard.edu/rss/rss.html#ltcategorygtSubelementOfLtitemgt
373  #[xml(attr = "category")]
374  pub category: Option<String>,
375
376  /// Child [`Outline`] elements of the current one.
377  #[xml(child = "outline")]
378  pub outlines: Vec<Outline>,
379
380  /// The HTTP address of the feed.
381  #[xml(attr = "xmlUrl")]
382  pub xml_url: Option<String>,
383
384  /// The top-level description element from the feed.
385  #[xml(attr = "description")]
386  pub description: Option<String>,
387
388  /// The top-level link element from the feed.
389  #[xml(attr = "htmlUrl")]
390  pub html_url: Option<String>,
391
392  /// The top-level language element from the feed.
393  #[xml(attr = "language")]
394  pub language: Option<String>,
395
396  /// The top-level title element from the feed.
397  #[xml(attr = "title")]
398  pub title: Option<String>,
399
400  /// The version of the feed's format (such as RSS 0.91, 2.0, ...).
401  #[xml(attr = "version")]
402  pub version: Option<String>,
403
404  /// A link that can point to another OPML document or to something that can
405  /// be displayed in a web browser.
406  #[xml(attr = "url")]
407  pub url: Option<String>,
408}
409
410impl Outline {
411  /// Helper function to add an [`Outline`] element with `text` and `xml_url`
412  /// attributes as a child element, useful for creating grouped lists. This
413  /// function also exists as [`OPML::add_feed`] for non-grouped lists.
414  ///
415  /// # Example
416  ///
417  /// ```rust
418  /// use opml::Outline;
419  ///
420  /// let mut group = Outline::default();
421  /// group.add_feed("Feed Name", "https://example.com/");
422  /// let added_feed = group.outlines.first().unwrap();
423  ///
424  /// let expected_feed = &Outline {
425  ///   text: "Feed Name".to_string(),
426  ///   xml_url: Some("https://example.com/".to_string()),
427  ///   ..Outline::default()
428  /// };
429  ///
430  /// assert_eq!(added_feed, expected_feed);
431  /// ```
432  pub fn add_feed(&mut self, name: &str, url: &str) -> &mut Self {
433    self.outlines.push(Outline {
434      text: name.to_string(),
435      xml_url: Some(url.to_string()),
436      ..Outline::default()
437    });
438
439    self
440  }
441}