83
crates/adapters/importer/src/backup_reader.rs
Normal file
83
crates/adapters/importer/src/backup_reader.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use api_types::dimension::DimensionPayload;
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{
|
||||
RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric, RestorableReminder,
|
||||
};
|
||||
|
||||
use super::kmood_backup::KmoodBackupReader;
|
||||
|
||||
pub struct KmoodBackupAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
|
||||
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError> {
|
||||
let read = KmoodBackupReader::read(data)?;
|
||||
let manifest = read.manifest;
|
||||
|
||||
Ok(RestorableContents {
|
||||
entries: manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|held| RestorableEntry {
|
||||
mood: held.mood,
|
||||
logged_at: held.logged_at,
|
||||
dimensions: readable_dimensions(held.dimensions),
|
||||
})
|
||||
.collect(),
|
||||
metrics: manifest
|
||||
.metrics
|
||||
.into_iter()
|
||||
.map(|held| RestorableMetric {
|
||||
date: held.date,
|
||||
kind: held.kind,
|
||||
value: held.value,
|
||||
provider: held.provider,
|
||||
})
|
||||
.collect(),
|
||||
cycle_starts: manifest.cycle_starts,
|
||||
activities: manifest
|
||||
.activities
|
||||
.into_iter()
|
||||
.map(|held| RestorableActivity {
|
||||
id: held.id,
|
||||
name: held.name,
|
||||
category: held.category,
|
||||
archived: held.archived,
|
||||
})
|
||||
.collect(),
|
||||
reminders: manifest
|
||||
.reminders
|
||||
.into_iter()
|
||||
.map(|held| RestorableReminder {
|
||||
enabled: held.enabled,
|
||||
times: [
|
||||
held.monday,
|
||||
held.tuesday,
|
||||
held.wednesday,
|
||||
held.thursday,
|
||||
held.friday,
|
||||
held.saturday,
|
||||
held.sunday,
|
||||
],
|
||||
})
|
||||
.collect(),
|
||||
tracks_cycle: manifest.tracks_cycle,
|
||||
photos: read.photos.into_iter().collect(),
|
||||
voice_memos: read.voice_memos.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn readable_dimensions(payloads: Vec<DimensionPayload>) -> Vec<DimensionValue> {
|
||||
payloads
|
||||
.into_iter()
|
||||
.filter_map(|payload| match payload.into_dimension() {
|
||||
Ok(dimension) => Some(dimension),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "a backed-up dimension could not be read");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,60 +1,100 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
const DATE: &str = "full_date";
|
||||
const TIME: &str = "time";
|
||||
const MOOD: &str = "mood";
|
||||
const ACTIVITIES: &str = "activities";
|
||||
const NOTE: &str = "note";
|
||||
const NOTE_TITLE: &str = "note_title";
|
||||
|
||||
pub struct DaylioImportAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportSourcePort for DaylioImportAdapter {
|
||||
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||
let content = std::str::from_utf8(data)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
|
||||
.map_err(|error| DomainError::InvalidInput(format!("invalid UTF-8: {error}")))?;
|
||||
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let columns = columns_of(&mut reader)?;
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for result in reader.records() {
|
||||
let record =
|
||||
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||
let record = result
|
||||
.map_err(|error| DomainError::InvalidInput(format!("CSV parse error: {error}")))?;
|
||||
|
||||
let mood_str = record.get(4).unwrap_or("").trim();
|
||||
let mood = map_daylio_mood(mood_str)?;
|
||||
|
||||
let date = record.get(0).unwrap_or("").trim().to_string();
|
||||
let time = record.get(3).unwrap_or("").trim().to_string();
|
||||
|
||||
let activities_str = record.get(5).unwrap_or("").trim();
|
||||
let activities = if activities_str.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
activities_str
|
||||
.split('|')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
let read = |name: &str| {
|
||||
columns
|
||||
.get(name)
|
||||
.and_then(|at| record.get(*at))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
};
|
||||
|
||||
let note = record
|
||||
.get(7)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
rows.push(ImportedRow {
|
||||
mood,
|
||||
date,
|
||||
time,
|
||||
activities,
|
||||
note,
|
||||
mood: map_daylio_mood(read(MOOD))?,
|
||||
date: read(DATE).to_string(),
|
||||
time: read(TIME).to_string(),
|
||||
activities: split_activities(read(ACTIVITIES)),
|
||||
note: whatever_was_written(read(NOTE_TITLE), read(NOTE)),
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(row_count = rows.len(), "parsed Daylio export");
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
fn columns_of<R: std::io::Read>(
|
||||
reader: &mut csv::Reader<R>,
|
||||
) -> Result<HashMap<String, usize>, DomainError> {
|
||||
let headers = reader
|
||||
.headers()
|
||||
.map_err(|error| DomainError::InvalidInput(format!("CSV has no header row: {error}")))?;
|
||||
|
||||
let columns: HashMap<String, usize> = headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(at, name)| (name.trim().to_lowercase(), at))
|
||||
.collect();
|
||||
|
||||
for required in [DATE, TIME, MOOD] {
|
||||
if !columns.contains_key(required) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"this does not look like a Daylio export: no {required} column"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
fn split_activities(written: &str) -> Vec<String> {
|
||||
written
|
||||
.split('|')
|
||||
.map(|activity| activity.trim().to_string())
|
||||
.filter(|activity| !activity.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn whatever_was_written(title: &str, note: &str) -> Option<String> {
|
||||
let written = [title, note]
|
||||
.iter()
|
||||
.filter(|part| !part.is_empty())
|
||||
.copied()
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n\n");
|
||||
|
||||
Some(written).filter(|written| !written.is_empty())
|
||||
}
|
||||
|
||||
fn map_daylio_mood(mood: &str) -> Result<u8, DomainError> {
|
||||
match mood.to_lowercase().as_str() {
|
||||
"awful" => Ok(1),
|
||||
|
||||
99
crates/adapters/importer/src/kmood_backup.rs
Normal file
99
crates/adapters/importer/src/kmood_backup.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use exporter::{BACKUP_MANIFEST, BackupManifest};
|
||||
|
||||
pub struct RestorableBackup {
|
||||
pub manifest: BackupManifest,
|
||||
pub photos: HashMap<String, Vec<u8>>,
|
||||
pub voice_memos: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct KmoodBackupReader;
|
||||
|
||||
impl KmoodBackupReader {
|
||||
pub fn read(data: &[u8]) -> Result<RestorableBackup, DomainError> {
|
||||
let mut archive = ZipArchive::new(Cursor::new(data))
|
||||
.map_err(|error| DomainError::InvalidInput(format!("not a zip archive: {error}")))?;
|
||||
|
||||
let manifest = read_named(&mut archive, BACKUP_MANIFEST)?.ok_or_else(|| {
|
||||
DomainError::InvalidInput(
|
||||
"this archive has no backup.json, so it is not a k-mood backup".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: BackupManifest = serde_json::from_slice(&manifest).map_err(|error| {
|
||||
DomainError::InvalidInput(format!("this backup cannot be read: {error}"))
|
||||
})?;
|
||||
|
||||
let (photos, voice_memos) = read_media(&mut archive)?;
|
||||
|
||||
tracing::info!(
|
||||
version = manifest.version,
|
||||
entries = manifest.entries.len(),
|
||||
metrics = manifest.metrics.len(),
|
||||
"read a k-mood backup"
|
||||
);
|
||||
|
||||
Ok(RestorableBackup {
|
||||
manifest,
|
||||
photos,
|
||||
voice_memos,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type Media = (HashMap<String, Vec<u8>>, HashMap<String, Vec<u8>>);
|
||||
|
||||
fn read_media(archive: &mut ZipArchive<Cursor<&[u8]>>) -> Result<Media, DomainError> {
|
||||
let mut photos = HashMap::new();
|
||||
let mut voice_memos = HashMap::new();
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| DomainError::InvalidInput(format!("zip read error: {error}")))?;
|
||||
|
||||
let name = file.name().to_string();
|
||||
|
||||
let destination = match (
|
||||
name.strip_prefix("photos/"),
|
||||
name.strip_prefix("voice_memos/"),
|
||||
) {
|
||||
(Some(id), _) if !id.is_empty() => (&mut photos, id.to_string()),
|
||||
(_, Some(id)) if !id.is_empty() => (&mut voice_memos, id.to_string()),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (into, id) = destination;
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(|error| {
|
||||
DomainError::InvalidInput(format!("could not read {name}: {error}"))
|
||||
})?;
|
||||
|
||||
into.insert(id, bytes);
|
||||
}
|
||||
|
||||
Ok((photos, voice_memos))
|
||||
}
|
||||
|
||||
fn read_named(
|
||||
archive: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>, DomainError> {
|
||||
let mut file = match archive.by_name(name) {
|
||||
Ok(file) => file,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(error) => return Err(DomainError::InvalidInput(format!("zip error: {error}"))),
|
||||
};
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| DomainError::InvalidInput(format!("could not read {name}: {error}")))?;
|
||||
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
mod backup_reader;
|
||||
mod csv_generic;
|
||||
mod daylio;
|
||||
mod kmood_backup;
|
||||
mod kmood_zip;
|
||||
|
||||
pub use backup_reader::KmoodBackupAdapter;
|
||||
pub use csv_generic::{CsvImportAdapter, CsvImportConfig};
|
||||
pub use daylio::DaylioImportAdapter;
|
||||
pub use kmood_backup::{KmoodBackupReader, RestorableBackup};
|
||||
pub use kmood_zip::{KmoodImportEntry, KmoodImportResult, KmoodZipImportAdapter};
|
||||
|
||||
Reference in New Issue
Block a user