changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

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

View File

@@ -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),

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

View File

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