1use chrono::{DateTime, FixedOffset};
3use omry_archiving::record::LanguageRecord;
4use omry_archiving::{
5 ClientDateTimeRecord, Document, Marshal, RawRecord, Record, RecordMeta, RecordParams, ToRecord,
6};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
11#[error(transparent)]
12pub struct DbRecordError(#[from] DbRecordErrorRepr);
13
14#[derive(Debug, Error)]
16enum DbRecordErrorRepr {
17 #[error("Error (de)serializing the web page: {0}")]
19 WebArchive(String),
20
21 #[error("This record is missing an ID.")]
22 MissingId,
23}
24
25#[derive(Debug)]
27pub struct DbRecord {
28 pub(crate) id: Option<i64>,
30
31 pub(crate) url: String,
33
34 pub(crate) client_datetime: DateTime<FixedOffset>,
36
37 pub(crate) client_datetime_raw: Option<String>,
39
40 pub(crate) timestamp_flora: i64,
42
43 pub(crate) title: String,
45
46 pub(crate) language: Option<String>,
48
49 pub(crate) document: Vec<u8>,
51
52 pub(crate) updated_at: Option<i64>,
54}
55
56impl DbRecord {
57 #[must_use]
62 pub fn id(&self) -> Option<i64> {
63 self.id
64 }
65}
66
67impl TryFrom<Record> for DbRecord {
68 type Error = DbRecordError;
69
70 fn try_from(value: Record) -> Result<Self, Self::Error> {
71 let id = value.id();
72 let url = value.url().to_string();
73 let title = value.title().to_string();
74
75 let (client_datetime, client_datetime_raw) = match value.client_datetime() {
76 ClientDateTimeRecord::Parsed(datetime) => (*datetime, None),
77 ClientDateTimeRecord::Interpolated { value, raw } => (*value, Some(raw.clone())),
78 };
79
80 let timestamp_flora = value.timestamp_archived();
81
82 let language = value.language().map(|lang| {
83 lang.primary()
84 .unwrap_or_else(|error| error.raw_data().to_string())
85 });
86
87 let web_archive_serialized = value.document().to_byte_vec().map_err(|error| {
88 DbRecordErrorRepr::WebArchive(format!("Couldn't serialize the archive: {error}"))
89 })?;
90
91 Ok(Self {
92 id,
93 url,
94 client_datetime,
95 client_datetime_raw,
96 timestamp_flora,
97 title,
98 language,
99 document: web_archive_serialized,
100 updated_at: value.updated_at(),
101 })
102 }
103}
104
105impl ToRecord for DbRecord {
106 type Error = DbRecordError;
107
108 fn to_record(&self) -> Result<Record, Self::Error> {
109 let document = Document::from_bytes(&self.document)
110 .map_err(|error| DbRecordErrorRepr::WebArchive(error.to_string()))?;
111
112 let params = RecordParams {
113 id: self.id,
114 url: self.url.clone(),
115 title: self.title.clone(),
116 client_datetime: self.client_datetime.to_rfc3339(),
117 timestamp_flora: Some(self.timestamp_flora),
118 language: self.language.clone(),
119 document,
120 updated_at: self.updated_at,
121 };
122
123 Ok(Record::new(params))
124 }
125}
126
127impl PartialEq for DbRecord {
136 fn eq(&self, other: &Self) -> bool {
137 let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
139 this_id == other_id
140 } else {
141 true
143 };
144
145 self.url == other.url
146 && self.client_datetime == other.client_datetime
147 && self.client_datetime_raw == other.client_datetime_raw
148 && self.timestamp_flora == other.timestamp_flora
149 && self.title == other.title
150 && self.language == other.language
151 && self.document == other.document
152 && ids_considered_equal
153 }
154}
155
156impl TryFrom<DbRecord> for RawRecord {
157 type Error = DbRecordError;
158
159 fn try_from(rec: DbRecord) -> Result<Self, Self::Error> {
160 let DbRecord {
161 id,
162 url,
163 client_datetime,
164 client_datetime_raw,
165 timestamp_flora,
166 title,
167 language,
168 document,
169 updated_at,
170 } = rec;
171
172 let id = id.ok_or(DbRecordErrorRepr::MissingId)?;
173 Ok(Self {
174 id,
175 url,
176 client_datetime,
177 client_datetime_raw,
178 timestamp_flora,
179 title,
180 language,
181 document,
182 updated_at,
183 })
184 }
185}
186
187#[derive(Debug, PartialEq)]
189pub struct DbRecordMeta {
190 pub(crate) id: i64,
192
193 pub(crate) url: String,
195
196 pub(crate) client_datetime: DateTime<FixedOffset>,
198
199 pub(crate) timestamp_flora: i64,
201
202 pub(crate) title: String,
204
205 pub(crate) language: Option<String>,
207
208 pub(crate) updated_at: Option<i64>,
210}
211
212impl From<DbRecordMeta> for RecordMeta {
213 fn from(record: DbRecordMeta) -> Self {
214 let DbRecordMeta {
215 id,
216 url,
217 client_datetime,
218 timestamp_flora,
219 title,
220 language,
221 updated_at,
222 } = record;
223
224 let client_datetime = ClientDateTimeRecord::new(&client_datetime.to_rfc3339());
225
226 RecordMeta {
227 id,
228 url,
229 title,
230 client_datetime,
231 timestamp_flora,
232 language: language.map(LanguageRecord::new),
233 updated_at,
234 }
235 }
236}
237
238#[cfg(test)]
239pub(crate) mod tests {
240 use anyhow::{Context, Result};
241 use omry_archiving::{Document, Marshal, record::RecordParams};
242 use pretty_assertions::assert_eq;
243 use std::env;
244 use std::fs::{self, File};
245 use std::io::prelude::*;
246 use std::path::PathBuf;
247
248 use super::*;
249
250 const TEST_ARCHIVE_PATH: &str = "test_data/example.postcard";
251 const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
252
253 fn get_test_path(path: &str) -> Result<PathBuf> {
255 let workspace_dir_path = env::var(WORKSPACE_DIR_KEY)
256 .context("WORKSPACE_DIR_KEY is not set")
257 .map(fs::canonicalize)??;
258 Ok(workspace_dir_path.join(path))
259 }
260
261 fn load_test_archive_holder() -> Result<Document> {
262 let path = get_test_path(TEST_ARCHIVE_PATH)?;
263 let mut file = File::open(&path)?;
264 let mut buf = vec![];
265 file.read_to_end(&mut buf)?;
266
267 let archive_holder = Document::from_bytes(&buf)?;
268 Ok(archive_holder)
269 }
270
271 pub(crate) fn test_record_params() -> Result<RecordParams> {
272 let document = load_test_archive_holder()?;
273
274 let url = "https://example.org/".to_string();
275 let title = "Example Domain";
276 let client_datetime = "2024-10-11T13:49:46-05:00";
277
278 #[allow(clippy::unreadable_literal)]
279 let timestamp_flora = Some(1728695243i64);
280 let language = Some("en".to_string());
281
282 Ok(RecordParams {
283 id: None,
284 url,
285 title: title.to_string(),
286 client_datetime: client_datetime.to_string(),
287 timestamp_flora,
288 language,
289 document,
290 updated_at: None,
291 })
292 }
293
294 #[allow(clippy::expect_used)]
295 #[test]
296 fn can_convert_record_to_db_record() -> Result<()> {
297 let record = Record::new(test_record_params()?);
298 let same_record = Record::new(test_record_params()?);
299 let primary_language_tag = same_record.language().map(|lang| {
300 lang.primary()
301 .expect("Couldn't compute the primary language tag")
302 });
303
304 let db_record: DbRecord = record.try_into()?;
305 assert_eq!(db_record.title, same_record.title());
306 assert_eq!(db_record.language, primary_language_tag);
307 Ok(())
308 }
309
310 #[test]
311 fn same_record_is_equal() -> anyhow::Result<()> {
312 let record: DbRecord = Record::new(test_record_params()?).try_into()?;
313 assert_eq!(record, record);
314 Ok(())
315 }
316
317 #[test]
318 fn different_records_are_not_equal() -> Result<()> {
319 let record = Record::new(test_record_params()?);
320 let mut other_record_params = test_record_params()?;
321 other_record_params.url = "https://example.com".into();
322 let other_record = Record::new(other_record_params);
323
324 let record: DbRecord = record.try_into()?;
325 let other_record = other_record.try_into()?;
326 assert_ne!(record, other_record);
327 Ok(())
328 }
329
330 #[test]
331 fn record_can_roundtrip_through_db_record() -> Result<()> {
332 let record = Record::new(test_record_params()?);
333 let db_record: DbRecord = record.try_into()?;
334 let roundtripped_record = db_record.to_record()?;
335
336 let same_record = Record::new(test_record_params()?);
337 assert_eq!(same_record, roundtripped_record);
338 Ok(())
339 }
340}