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 && self.updated_at == other.updated_at
154 }
155}
156
157impl TryFrom<DbRecord> for RawRecord {
158 type Error = DbRecordError;
159
160 fn try_from(rec: DbRecord) -> Result<Self, Self::Error> {
161 let DbRecord {
162 id,
163 url,
164 client_datetime,
165 client_datetime_raw,
166 timestamp_flora,
167 title,
168 language,
169 document,
170 updated_at,
171 } = rec;
172
173 let id = id.ok_or(DbRecordErrorRepr::MissingId)?;
174 Ok(Self {
175 id,
176 url,
177 client_datetime,
178 client_datetime_raw,
179 timestamp_flora,
180 title,
181 language,
182 document,
183 updated_at,
184 })
185 }
186}
187
188#[derive(Debug, PartialEq)]
190pub struct DbRecordMeta {
191 pub(crate) id: i64,
193
194 pub(crate) url: String,
196
197 pub(crate) client_datetime: DateTime<FixedOffset>,
199
200 pub(crate) timestamp_flora: i64,
202
203 pub(crate) title: String,
205
206 pub(crate) language: Option<String>,
208
209 pub(crate) updated_at: Option<i64>,
211}
212
213impl From<DbRecordMeta> for RecordMeta {
214 fn from(record: DbRecordMeta) -> Self {
215 let DbRecordMeta {
216 id,
217 url,
218 client_datetime,
219 timestamp_flora,
220 title,
221 language,
222 updated_at,
223 } = record;
224
225 let client_datetime = ClientDateTimeRecord::new(&client_datetime.to_rfc3339());
226
227 RecordMeta {
228 id,
229 url,
230 title,
231 client_datetime,
232 timestamp_flora,
233 language: language.map(LanguageRecord::new),
234 updated_at,
235 }
236 }
237}
238
239#[cfg(test)]
240pub(crate) mod tests {
241 use anyhow::{Context, Result};
242 use omry_archiving::{Document, Marshal, record::RecordParams};
243 use pretty_assertions::assert_eq;
244 use std::env;
245 use std::fs::{self, File};
246 use std::io::prelude::*;
247 use std::path::PathBuf;
248
249 use super::*;
250
251 const TEST_ARCHIVE_PATH: &str = "test_data/example.postcard";
252 const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
253
254 fn get_test_path(path: &str) -> Result<PathBuf> {
256 let workspace_dir_path = env::var(WORKSPACE_DIR_KEY)
257 .context("WORKSPACE_DIR_KEY is not set")
258 .map(fs::canonicalize)??;
259 Ok(workspace_dir_path.join(path))
260 }
261
262 fn load_test_archive_holder() -> Result<Document> {
263 let path = get_test_path(TEST_ARCHIVE_PATH)?;
264 let mut file = File::open(&path)?;
265 let mut buf = vec![];
266 file.read_to_end(&mut buf)?;
267
268 let archive_holder = Document::from_bytes(&buf)?;
269 Ok(archive_holder)
270 }
271
272 pub(crate) fn test_record_params() -> Result<RecordParams> {
273 let document = load_test_archive_holder()?;
274
275 let url = "https://example.org/".to_string();
276 let title = "Example Domain";
277 let client_datetime = "2024-10-11T13:49:46-05:00";
278
279 #[allow(clippy::unreadable_literal)]
280 let timestamp_flora = Some(1728695243i64);
281 let language = Some("en".to_string());
282
283 Ok(RecordParams {
284 id: None,
285 url,
286 title: title.to_string(),
287 client_datetime: client_datetime.to_string(),
288 timestamp_flora,
289 language,
290 document,
291 updated_at: None,
292 })
293 }
294
295 #[allow(clippy::expect_used)]
296 #[test]
297 fn can_convert_record_to_db_record() -> Result<()> {
298 let record = Record::new(test_record_params()?);
299 let same_record = Record::new(test_record_params()?);
300 let primary_language_tag = same_record.language().map(|lang| {
301 lang.primary()
302 .expect("Couldn't compute the primary language tag")
303 });
304
305 let db_record: DbRecord = record.try_into()?;
306 assert_eq!(db_record.title, same_record.title());
307 assert_eq!(db_record.language, primary_language_tag);
308 Ok(())
309 }
310
311 #[test]
312 fn same_record_is_equal() -> anyhow::Result<()> {
313 let record: DbRecord = Record::new(test_record_params()?).try_into()?;
314 assert_eq!(record, record);
315 Ok(())
316 }
317
318 #[test]
319 fn different_records_are_not_equal() -> Result<()> {
320 let record = Record::new(test_record_params()?);
321 let mut other_record_params = test_record_params()?;
322 other_record_params.url = "https://example.com".into();
323 let other_record = Record::new(other_record_params);
324
325 let record: DbRecord = record.try_into()?;
326 let other_record = other_record.try_into()?;
327 assert_ne!(record, other_record);
328 Ok(())
329 }
330
331 #[test]
332 fn record_can_roundtrip_through_db_record() -> Result<()> {
333 let record = Record::new(test_record_params()?);
334 let db_record: DbRecord = record.try_into()?;
335 let roundtripped_record = db_record.to_record()?;
336
337 let same_record = Record::new(test_record_params()?);
338 assert_eq!(same_record, roundtripped_record);
339 Ok(())
340 }
341}