serde_opml/
lib.rs

1use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
2
3fn deserialize_vec_u32<'de, D>(deserializer: D) -> Result<Vec<u32>, D::Error>
4where
5    D: Deserializer<'de>,
6{
7    let s = String::deserialize(deserializer)?;
8    let numbers = s
9        .split(',')
10        .map(|num| num.trim())
11        .filter(|num| !num.is_empty())
12        .map(|num| num.parse::<u32>().map_err(de::Error::custom))
13        .collect::<Result<Vec<_>, _>>()?;
14    Ok(numbers)
15}
16
17fn serialize_vec_u32_as_comma<S>(value: &[u32], serializer: S) -> Result<S::Ok, S::Error>
18where
19    S: Serializer,
20{
21    let joined = value
22        .iter()
23        .map(|num| num.to_string())
24        .collect::<Vec<_>>()
25        .join(",");
26    serializer.serialize_str(&joined)
27}
28
29fn deserialize_vec_str<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
30where
31    D: Deserializer<'de>,
32{
33    let s = String::deserialize(deserializer)?;
34    let numbers = s.split(',').map(|i| i.to_string()).collect::<Vec<_>>();
35    Ok(numbers)
36}
37
38fn serialize_vec_str_as_comma<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
39where
40    S: Serializer,
41{
42    let joined = value.join(",");
43    serializer.serialize_str(&joined)
44}
45
46#[derive(PartialEq, Debug, Deserialize, Serialize)]
47#[serde(rename = "opml")]
48pub struct Opml {
49    #[serde(rename = "@version")]
50    pub version: String,
51    pub head: Head,
52    pub body: Body,
53}
54
55#[derive(PartialEq, Debug, Deserialize, Serialize)]
56#[serde(rename = "head")]
57pub struct Head {
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub title: Option<String>,
60    #[serde(rename = "dateCreated", skip_serializing_if = "Option::is_none")]
61    pub date_created: Option<String>,
62    #[serde(rename = "dateModified", skip_serializing_if = "Option::is_none")]
63    pub date_modified: Option<String>,
64    #[serde(rename = "ownerName", skip_serializing_if = "Option::is_none")]
65    pub owner_name: Option<String>,
66    #[serde(rename = "ownerEmail", skip_serializing_if = "Option::is_none")]
67    pub owner_email: Option<String>,
68
69    #[serde(
70        rename = "expansionState",
71        deserialize_with = "deserialize_vec_u32",
72        serialize_with = "serialize_vec_u32_as_comma",
73        skip_serializing_if = "Vec::is_empty",
74        default
75    )]
76    pub expansion_state: Vec<u32>,
77
78    #[serde(rename = "vertScrollState", skip_serializing_if = "Option::is_none")]
79    pub vert_scroll_state: Option<i32>,
80    #[serde(rename = "windowTop", skip_serializing_if = "Option::is_none")]
81    pub window_top: Option<i32>,
82    #[serde(rename = "windowLeft", skip_serializing_if = "Option::is_none")]
83    pub window_left: Option<i32>,
84    #[serde(rename = "windowBottom", skip_serializing_if = "Option::is_none")]
85    pub window_bottom: Option<i32>,
86    #[serde(rename = "windowRight", skip_serializing_if = "Option::is_none")]
87    pub window_right: Option<i32>,
88}
89
90#[derive(Debug, Deserialize, PartialEq, Serialize)]
91#[serde(rename = "body")]
92pub struct Body {
93    #[serde(rename = "outline")]
94    outlines: Vec<Outline>,
95}
96
97#[derive(PartialEq, Debug, Deserialize, Serialize)]
98#[serde(rename = "outline")]
99pub struct Outline {
100    #[serde(rename = "@text")]
101    pub text: String,
102
103    #[serde(
104        rename = "@category",
105        skip_serializing_if = "Vec::is_empty",
106        deserialize_with = "deserialize_vec_str",
107        serialize_with = "serialize_vec_str_as_comma",
108        default
109    )]
110    pub category: Vec<String>,
111
112    #[serde(rename = "@created", skip_serializing_if = "Option::is_none")]
113    pub created: Option<String>,
114
115    #[serde(rename = "@isComment", skip_serializing_if = "Option::is_none")]
116    pub is_comment: Option<bool>,
117
118    #[serde(rename = "@isBreakpoint", skip_serializing_if = "Option::is_none")]
119    pub is_breakpoint: Option<bool>,
120
121    #[serde(rename = "@description", skip_serializing_if = "Option::is_none")]
122    pub description: Option<String>,
123
124    #[serde(rename = "@htmlUrl", skip_serializing_if = "Option::is_none")]
125    pub html_url: Option<String>,
126
127    #[serde(rename = "@language", skip_serializing_if = "Option::is_none")]
128    pub language: Option<String>,
129
130    #[serde(rename = "@title", skip_serializing_if = "Option::is_none")]
131    pub title: Option<String>,
132
133    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
134    pub ty: Option<String>,
135
136    #[serde(rename = "@version", skip_serializing_if = "Option::is_none")]
137    pub version: Option<String>,
138
139    #[serde(rename = "@xmlUrl", skip_serializing_if = "Option::is_none")]
140    pub xml_url: Option<String>,
141
142    #[serde(rename = "@url", skip_serializing_if = "Option::is_none")]
143    pub url: Option<String>,
144
145    #[serde(rename = "outline", skip_serializing_if = "Vec::is_empty", default)]
146    outlines: Vec<Outline>,
147}
148
149#[cfg(test)]
150mod test {
151
152    use quick_xml::{de::from_str, se::to_string};
153
154    use crate::{Head, Opml, Outline};
155
156    #[test]
157    fn test_outline() {
158        let s = r#"<outline text="The Mets are the best team in baseball." category="/Philosophy/Baseball/Mets,/Tourism/New York" created="Mon, 31 Oct 2005 18:21:33 GMT"/>"#;
159        let outline: Outline = from_str(s).unwrap();
160        assert!(outline.text == "The Mets are the best team in baseball.");
161        println!("{:?}", outline.category);
162
163        assert!(outline.category.len() == 2);
164        assert!(outline.created.unwrap() == "Mon, 31 Oct 2005 18:21:33 GMT");
165
166        let s = r#"	<outline text="x" type="link" url="http://hosting.opml.org/dave/mySites.opml" isComment="true" isBreakpoint="true"
167           htmlUrl="http://www.infoworld.com/news/index.html" language="unknown"
168      title="x" version="RSS2"
169      xmlUrl="http://www.infoworld.com/rss/news.xml"
170      >
171			<outline text="x" isBreakpoint="true"/>
172			<outline text="x"/>
173			</outline>"#;
174        let outline: Outline = from_str(s).unwrap();
175        assert_eq!(
176            outline.url.unwrap(),
177            "http://hosting.opml.org/dave/mySites.opml"
178        );
179        assert_eq!(outline.title.unwrap(), "x");
180        assert_eq!(outline.ty.unwrap(), "link");
181        assert_eq!(outline.version.unwrap(), "RSS2");
182        assert!(outline.is_comment.unwrap());
183        assert!(outline.is_breakpoint.unwrap());
184        assert!(outline.outlines[0].is_breakpoint.unwrap());
185        assert_eq!(outline.outlines[1].text, "x");
186    }
187    #[test]
188    fn test_head() {
189        let s = r#"<head>
190    <title>states.opml</title>
191    <dateCreated>Tue, 15 Mar 2005 16:35:45 GMT</dateCreated>
192    <dateModified>Thu, 14 Jul 2005 23:41:05 GMT</dateModified>
193    <ownerName>Dave Winer</ownerName>
194    <ownerEmail>dave@scripting.com</ownerEmail>
195    <expansionState>1, 6, 13, 16,18,20</expansionState>
196    <vertScrollState>1</vertScrollState>
197    <windowTop>106</windowTop>
198    <windowLeft>106</windowLeft>
199    <windowBottom>558</windowBottom>
200    <windowRight>479</windowRight>
201  </head>"#;
202        let head: Head = from_str(s).unwrap();
203        assert_eq!(head.title.unwrap(), "states.opml");
204        assert_eq!(head.date_created.unwrap(), "Tue, 15 Mar 2005 16:35:45 GMT");
205        assert_eq!(head.date_modified.unwrap(), "Thu, 14 Jul 2005 23:41:05 GMT");
206        assert_eq!(head.owner_name.unwrap(), "Dave Winer");
207        assert_eq!(head.owner_email.unwrap(), "dave@scripting.com");
208        assert_eq!(head.expansion_state.len(), 6);
209        assert_eq!(head.vert_scroll_state.unwrap(), 1);
210        assert_eq!(head.window_top.unwrap(), 106);
211        assert_eq!(head.window_left.unwrap(), 106);
212        assert_eq!(head.window_bottom.unwrap(), 558);
213        assert_eq!(head.window_right.unwrap(), 479);
214    }
215
216    #[test]
217    fn test_assets() {
218        for name in std::fs::read_dir("assets").unwrap() {
219            let txt = std::fs::read_to_string(name.unwrap().path()).unwrap();
220            let opml: Opml = from_str(&txt).unwrap();
221            assert!(opml.version == "2.0");
222            let xml = to_string(&opml).unwrap();
223            let opml2: Opml = from_str(&xml).unwrap();
224            assert_eq!(opml, opml2);
225        }
226    }
227}