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}