wallabag_api/types/entry.rs
1// Copyright 2018 Samuel Walladge <samuel@swalladge.net>
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::utils::serde::parse_hashmap_with_null_values;
9use crate::utils::serde::parse_intbool;
10
11use super::annotations::Annotations;
12use super::common::ID;
13use super::tags::Tags;
14
15/// type alias: a list of entries as returned from some endpoints
16pub type Entries = Vec<Entry>;
17
18/// A struct representing an entry from wallabag (a full saved article including
19/// all annotations and tags; annotations and tags do not need to be requested
20/// separately).
21///
22/// Most fields are controlled by the server. When creating an entry, the server will send a
23/// request to the given url and use the response to populate many of the fields. This response is
24/// what `headers`, `http_status`, `mimetype`, etc. are referring to.
25#[derive(Deserialize, Serialize, Debug)]
26pub struct Entry {
27 /// Annotation objects for this entry.
28 pub annotations: Option<Annotations>,
29
30 /// Content. Should be HTML if present.
31 pub content: Option<String>,
32
33 /// The timestamp of when the entry was created on the server.
34 pub created_at: DateTime<Utc>,
35
36 /// The resolved domain name of the url. Could be None if the server couldn't resolve the url.
37 pub domain_name: Option<String>,
38
39 /// A map of header name -> header value. These appear to be headers from the original source
40 /// url.
41 #[serde(deserialize_with = "parse_hashmap_with_null_values")]
42 pub headers: Option<HashMap<String, String>>,
43
44 /// I'm guessing this is the status the server got when retrieving the content from the url.
45 pub http_status: Option<String>,
46
47 /// ID of the entry. Should be an integer. Should also be unique, so can use this directly as
48 /// the local id if storing the entry in a DB.
49 pub id: ID,
50
51 /// The archived (or read) status of the entry. These boolean options are sometimes represented
52 /// as 0 or 1 from the API, which makes parsing in a strongly typed language annoying.
53 #[serde(deserialize_with = "parse_intbool")]
54 pub is_archived: bool,
55
56 /// The public shared status of the entry. If this is true, then there should be a public link
57 /// to this exact entry on the server. The link will be based around the value of the `uid`
58 /// field and (TODO: confirm if this can be relied on) formatted as BASE_URL/share/UID.
59 #[serde(deserialize_with = "parse_intbool")]
60 pub is_public: bool,
61
62 /// The starred status of the entry.
63 #[serde(deserialize_with = "parse_intbool")]
64 pub is_starred: bool,
65
66 /// The language of the entry - probably generated by the server from inspecting the response.
67 pub language: Option<String>,
68
69 /// The mimetype of the entry - probably generated by the server from inspecting the response.
70 /// Not sure about the support status for other mimetypes. Observed behaviour suggests that the
71 /// server converts everything to HTML - eg. a text/plain mimetype content will be plain text
72 /// surrounded by `<pre>` tags.
73 pub mimetype: Option<String>,
74
75 /// Supposedly the original url given will be stored here. If a shortened link is submitted to
76 /// the server, the short link will be here, but the resolved link will be in URL. Observed
77 /// behaviour is that this field is never set.
78 pub origin_url: Option<String>,
79
80 /// Optional url for an image related to the entry. Eg. for displaying as a background image to
81 /// the entry tile.
82 pub preview_picture: Option<String>,
83
84 /// Data about when the entry was published (scraped from the original web page).
85 pub published_at: Option<DateTime<Utc>>,
86
87 /// Data about who published the entry (scraped from the original web page).
88 pub published_by: Option<Vec<Option<String>>>,
89
90 /// Estimated reading time in minutes. Generated by the server, probably based off your set
91 /// reading speed or a default.
92 pub reading_time: u32,
93
94 /// Timestamp of when the entry was starred, if it is starred. Unstarring an entry sets this to
95 /// None.
96 pub starred_at: Option<DateTime<Utc>>,
97
98 /// A list of tag objects associated with this entry.
99 pub tags: Tags,
100
101 /// An optional title for the entry.
102 pub title: Option<String>,
103
104 /// This will be only set by the server as a unique id to identify the entry if it has been
105 /// shared. For example if you share via public link on framabag and the uid is FOO, then the
106 /// public url will be framabag.org/share/FOO
107 pub uid: Option<String>,
108
109 /// Timestamp when the entry was last updated. This is bumped for any change to any field
110 /// attached to the entry except for annotations.
111 ///
112 /// TODO: check if entry updates if a tag is globally edited (eg. renamed)
113 pub updated_at: DateTime<Utc>,
114
115 /// Resolved url of the entry. If the origin_url redirected to a different url (eg. via a
116 /// shortened link), the final url will be stored here.
117 pub url: Option<String>,
118
119 /// Email of the user who owns this entry. Currently `user_*` fields are redundant since you
120 /// can only access entries that belong to you. Entry sharing between users is planned for the
121 /// future so this may become relevant soon.
122 pub user_email: String,
123
124 /// ID of the user who owns this entry.
125 pub user_id: ID,
126
127 /// username of the user who owns this entry.
128 pub user_name: String,
129}
130
131/// A struct representing a deleted entry from wallabag (a full saved article including
132/// annotations and tags). The only difference from the full entry is that this
133/// doesn't have an id. Only used internally because a full entry gets
134/// reconstituted before being returned to the client.
135#[derive(Deserialize, Debug)]
136pub(crate) struct DeletedEntry {
137 pub annotations: Option<Annotations>,
138 pub content: Option<String>,
139 pub created_at: DateTime<Utc>,
140 pub domain_name: Option<String>,
141 pub headers: Option<HashMap<String, String>>,
142 pub http_status: Option<String>,
143
144 #[serde(deserialize_with = "parse_intbool")]
145 pub is_archived: bool,
146
147 #[serde(deserialize_with = "parse_intbool")]
148 pub is_public: bool,
149
150 #[serde(deserialize_with = "parse_intbool")]
151 pub is_starred: bool,
152 pub language: Option<String>,
153 pub mimetype: Option<String>,
154 pub origin_url: Option<String>,
155 pub preview_picture: Option<String>,
156 pub published_at: Option<DateTime<Utc>>,
157 pub published_by: Option<Vec<Option<String>>>,
158 pub reading_time: u32,
159 pub starred_at: Option<DateTime<Utc>>,
160 pub tags: Tags,
161 pub title: Option<String>,
162 pub uid: Option<String>,
163 pub updated_at: DateTime<Utc>,
164 pub url: Option<String>,
165 pub user_email: String,
166 pub user_id: ID,
167 pub user_name: String,
168}
169
170/// This is implemented so that an Entry can be used interchangeably with an ID
171/// for some client methods. For convenience.
172impl From<Entry> for ID {
173 fn from(entry: Entry) -> Self {
174 entry.id
175 }
176}
177
178/// This is implemented so that an &Entry can be used interchangeably with an ID
179/// for some client methods. For convenience.
180impl From<&Entry> for ID {
181 fn from(entry: &Entry) -> Self {
182 entry.id
183 }
184}
185
186/// Internal struct for retrieving a list of entries from the api when
187/// paginated.
188#[derive(Deserialize, Debug)]
189pub(crate) struct PaginatedEntries {
190 pub limit: u32,
191 pub page: u32,
192 pub pages: u32,
193 pub total: u32,
194 #[serde(rename = "_embedded")]
195 pub embedded: EmbeddedEntries,
196}
197
198/// Entries as stored in `PaginatedEntries`.
199#[derive(Deserialize, Debug)]
200pub(crate) struct EmbeddedEntries {
201 pub items: Entries,
202}
203
204/// Represents a page of Entries returned. Includes both the payload and metadata about the page.
205#[derive(Debug)]
206pub struct EntriesPage {
207 /// Number of entries returned per page. This is set by the server; useful to know if you're
208 /// accepting the server default because this will inform what the server default is.
209 pub per_page: u32,
210
211 /// The current page number of results.
212 pub current_page: u32,
213
214 /// Total number of pages in the set.
215 pub total_pages: u32,
216
217 /// Total number of entries in the query set.
218 pub total_entries: u32,
219
220 /// The list of entries returned.
221 pub entries: Entries,
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_entry_header_with_string_value() {
230 let text = r###"{
231 "is_archived": 1,
232 "is_starred": 0,
233 "user_name": "sibben",
234 "user_email": "detlef@posteo.org",
235 "user_id": 15568,
236 "tags": [],
237 "is_public": false,
238 "id": 10849650,
239 "uid": null,
240 "title": "Klettenwurzel\u00f6l",
241 "url": "https:\/\/oelerini.com\/klettenwurzeloel",
242 "hashed_url": "baff1dd17cb2cc15578cb9b6955971dfb8ada45a",
243 "origin_url": null,
244 "given_url": "https:\/\/oelerini.com\/klettenwurzeloel",
245 "hashed_given_url": "baff1dd17cb2cc15578cb9b6955971dfb8ada45a",
246 "archived_at": "2020-02-12T10:20:58+0100",
247 "content": "Dummy content",
248 "created_at": "2019-01-14T18:16:36+0100",
249 "updated_at": "2020-02-12T10:20:58+0100",
250 "published_at": null,
251 "published_by": null,
252 "starred_at": null,
253 "annotations": [],
254 "mimetype": "text\/html",
255 "language": "de",
256 "reading_time": 12,
257 "domain_name": "oelerini.com",
258 "preview_picture": "https:\/\/oelerini.com\/img\/klettenwurzeloel.jpg",
259 "http_status": null,
260 "headers": {
261 "content-type": "text\/html"
262 },
263 "_links": {
264 "self": {
265 "href": "\/api\/entries\/10849650"
266 }
267 }
268 }
269 "###;
270 let entry: Entry = serde_json::from_str(&text).unwrap();
271 assert_eq!(
272 entry.headers,
273 Some(HashMap::from([("content-type".into(), "text/html".into())]))
274 );
275 }
276
277 #[test]
278 fn test_entry_header_with_null_value() {
279 let text = r###"{
280 "is_archived": 1,
281 "is_starred": 0,
282 "user_name": "sibben",
283 "user_email": "detlef@posteo.org",
284 "user_id": 15568,
285 "tags": [],
286 "is_public": false,
287 "id": 10849669,
288 "uid": null,
289 "title": "Erfahrungen beim Jurtenaufbau Es geht auch zu zweit!",
290 "url": "https:\/\/www.jurte.com\/de\/berichte\/ammertal.html",
291 "hashed_url": "f3c65c41ecef84d95e7a7afc12dbebdd04a8a471",
292 "origin_url": null,
293 "given_url": "https:\/\/www.jurte.com\/de\/berichte\/ammertal.html",
294 "hashed_given_url": "f3c65c41ecef84d95e7a7afc12dbebdd04a8a471",
295 "archived_at": "2020-02-12T10:20:59+0100",
296 "content": "wallabag can't retrieve contents for this article. Please <a href=\"http:\/\/doc.wallabag.org\/en\/user\/errors_during_fetching.html#how-can-i-help-to-fix-that\">troubleshoot this issue<\/a>.",
297 "created_at": "2018-08-23T22:28:58+0200",
298 "updated_at": "2020-02-12T10:20:59+0100",
299 "published_at": null,
300 "published_by": null,
301 "starred_at": null,
302 "annotations": [],
303 "mimetype": null,
304 "language": null,
305 "reading_time": 0,
306 "domain_name": "www.jurte.com",
307 "preview_picture": "https:\/\/www.jurte.com\/images\/ammertal\/aushub_192.jpg",
308 "http_status": null,
309 "headers": {
310 "content-type": null
311 },
312 "_links": {
313 "self": {
314 "href": "\/api\/entries\/10849669"
315 }
316 }
317 }
318 "###;
319 let entry: Entry = serde_json::from_str(&text).unwrap();
320 assert_eq!(entry.headers, Some(HashMap::from([])));
321 }
322}