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