13
crates/adapters/importer/Cargo.toml
Normal file
13
crates/adapters/importer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "importer"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
csv.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zip.workspace = true
|
||||
tracing.workspace = true
|
||||
133
crates/adapters/importer/src/csv_generic.rs
Normal file
133
crates/adapters/importer/src/csv_generic.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
pub struct CsvImportConfig {
|
||||
pub date_column: usize,
|
||||
pub time_column: usize,
|
||||
pub mood_column: usize,
|
||||
pub activities_column: Option<usize>,
|
||||
pub note_column: Option<usize>,
|
||||
pub activities_separator: String,
|
||||
pub mood_mapping: Vec<(String, u8)>,
|
||||
pub delimiter: u8,
|
||||
}
|
||||
|
||||
impl Default for CsvImportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
date_column: 0,
|
||||
time_column: 1,
|
||||
mood_column: 2,
|
||||
activities_column: Some(3),
|
||||
note_column: Some(4),
|
||||
activities_separator: "|".into(),
|
||||
mood_mapping: vec![
|
||||
("1".into(), 1),
|
||||
("2".into(), 2),
|
||||
("3".into(), 3),
|
||||
("4".into(), 4),
|
||||
("5".into(), 5),
|
||||
("awful".into(), 1),
|
||||
("bad".into(), 2),
|
||||
("meh".into(), 3),
|
||||
("good".into(), 4),
|
||||
("rad".into(), 5),
|
||||
],
|
||||
delimiter: b',',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CsvImportAdapter {
|
||||
config: CsvImportConfig,
|
||||
}
|
||||
|
||||
impl CsvImportAdapter {
|
||||
pub fn new(config: CsvImportConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn map_mood(&self, value: &str) -> Result<u8, DomainError> {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
|
||||
if let Ok(num) = normalized.parse::<u8>()
|
||||
&& (1..=5).contains(&num)
|
||||
{
|
||||
return Ok(num);
|
||||
}
|
||||
|
||||
self.config
|
||||
.mood_mapping
|
||||
.iter()
|
||||
.find(|(label, _)| label.to_lowercase() == normalized)
|
||||
.map(|(_, value)| *value)
|
||||
.ok_or_else(|| DomainError::InvalidInput(format!("unknown mood value: {value}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportSourcePort for CsvImportAdapter {
|
||||
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}")))?;
|
||||
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.delimiter(self.config.delimiter)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for result in reader.records() {
|
||||
let record =
|
||||
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||
|
||||
let mood_str = record.get(self.config.mood_column).unwrap_or("").trim();
|
||||
let mood = self.map_mood(mood_str)?;
|
||||
|
||||
let date = record
|
||||
.get(self.config.date_column)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let time = record
|
||||
.get(self.config.time_column)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let activities = match self.config.activities_column {
|
||||
Some(col) => {
|
||||
let raw = record.get(col).unwrap_or("").trim();
|
||||
if raw.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
raw.split(&self.config.activities_separator)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let note = self
|
||||
.config
|
||||
.note_column
|
||||
.and_then(|col| record.get(col))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
rows.push(ImportedRow {
|
||||
mood,
|
||||
date,
|
||||
time,
|
||||
activities,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(row_count = rows.len(), "parsed CSV import");
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
69
crates/adapters/importer/src/daylio.rs
Normal file
69
crates/adapters/importer/src/daylio.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
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}")))?;
|
||||
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for result in reader.records() {
|
||||
let record =
|
||||
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||
|
||||
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 note = record
|
||||
.get(7)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
rows.push(ImportedRow {
|
||||
mood,
|
||||
date,
|
||||
time,
|
||||
activities,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(row_count = rows.len(), "parsed Daylio export");
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_daylio_mood(mood: &str) -> Result<u8, DomainError> {
|
||||
match mood.to_lowercase().as_str() {
|
||||
"awful" => Ok(1),
|
||||
"bad" => Ok(2),
|
||||
"meh" => Ok(3),
|
||||
"good" => Ok(4),
|
||||
"rad" => Ok(5),
|
||||
other => Err(DomainError::InvalidInput(format!(
|
||||
"unknown Daylio mood: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
pub struct KmoodZipImportAdapter;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportData {
|
||||
entries: Vec<EntryData>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EntryData {
|
||||
mood: u8,
|
||||
logged_at: String,
|
||||
activities: Vec<String>,
|
||||
content: Option<String>,
|
||||
photos: Vec<String>,
|
||||
voice_memos: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct KmoodImportEntry {
|
||||
pub row: ImportedRow,
|
||||
pub photo_ids: Vec<String>,
|
||||
pub voice_memo_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct KmoodImportResult {
|
||||
pub entries: Vec<KmoodImportEntry>,
|
||||
pub photos: HashMap<String, Vec<u8>>,
|
||||
pub voice_memos: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl KmoodZipImportAdapter {
|
||||
pub fn extract(data: &[u8]) -> Result<KmoodImportResult, DomainError> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut archive = ZipArchive::new(cursor)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid zip file: {e}")))?;
|
||||
|
||||
let json_data = read_file_from_zip(&mut archive, "data.json")?
|
||||
.ok_or_else(|| DomainError::InvalidInput("missing data.json in archive".into()))?;
|
||||
|
||||
let export: ExportData = serde_json::from_slice(&json_data)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid data.json: {e}")))?;
|
||||
|
||||
let mut photos = HashMap::new();
|
||||
let mut voice_memos = HashMap::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("zip read error: {e}")))?;
|
||||
|
||||
let name = file.name().to_string();
|
||||
|
||||
if let Some(id) = name.strip_prefix("photos/")
|
||||
&& !id.is_empty()
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to read photo: {e}")))?;
|
||||
photos.insert(id.to_string(), buf);
|
||||
} else if let Some(id) = name.strip_prefix("voice_memos/")
|
||||
&& !id.is_empty()
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to read voice memo: {e}"))
|
||||
})?;
|
||||
voice_memos.insert(id.to_string(), buf);
|
||||
}
|
||||
}
|
||||
|
||||
let entries: Vec<KmoodImportEntry> = export
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let (date, time) = split_datetime(&e.logged_at);
|
||||
KmoodImportEntry {
|
||||
row: ImportedRow {
|
||||
mood: e.mood,
|
||||
date,
|
||||
time,
|
||||
activities: e.activities,
|
||||
note: e.content,
|
||||
},
|
||||
photo_ids: e.photos,
|
||||
voice_memo_ids: e.voice_memos,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
entries = entries.len(),
|
||||
photos = photos.len(),
|
||||
voice_memos = voice_memos.len(),
|
||||
"extracted k-mood archive"
|
||||
);
|
||||
|
||||
Ok(KmoodImportResult {
|
||||
entries,
|
||||
photos,
|
||||
voice_memos,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file_from_zip(
|
||||
archive: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>, DomainError> {
|
||||
let mut file = match archive.by_name(name) {
|
||||
Ok(f) => f,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(e) => return Err(DomainError::InvalidInput(format!("zip error: {e}"))),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to read {name}: {e}")))?;
|
||||
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
fn split_datetime(rfc3339: &str) -> (String, String) {
|
||||
if let Some(t_pos) = rfc3339.find('T') {
|
||||
let date = rfc3339[..t_pos].to_string();
|
||||
let time = rfc3339[t_pos + 1..].to_string();
|
||||
(date, time)
|
||||
} else {
|
||||
(rfc3339.to_string(), "00:00".to_string())
|
||||
}
|
||||
}
|
||||
7
crates/adapters/importer/src/lib.rs
Normal file
7
crates/adapters/importer/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod csv_generic;
|
||||
mod daylio;
|
||||
mod kmood_zip;
|
||||
|
||||
pub use csv_generic::{CsvImportAdapter, CsvImportConfig};
|
||||
pub use daylio::DaylioImportAdapter;
|
||||
pub use kmood_zip::{KmoodImportEntry, KmoodImportResult, KmoodZipImportAdapter};
|
||||
Reference in New Issue
Block a user