init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View 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

View 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)
}
}

View 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}"
))),
}
}

View 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())
}
}

View 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};