@@ -5,7 +5,14 @@ version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
api-types.workspace = true
|
||||
chrono.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
chrono.workspace = true
|
||||
|
||||
185
crates/adapters/exporter/src/backup.rs
Normal file
185
crates/adapters/exporter/src/backup.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use chrono::Weekday;
|
||||
|
||||
use api_types::dimension::DimensionPayload;
|
||||
use domain::activity::Activity;
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::errors::DomainError;
|
||||
use domain::metric::DailyMetric;
|
||||
use domain::ports::UserBackup;
|
||||
use domain::reminder::Reminder;
|
||||
|
||||
use super::shared::{io_err, json_err, zip_err};
|
||||
|
||||
pub const BACKUP_FORMAT_VERSION: u32 = 2;
|
||||
pub const BACKUP_MANIFEST: &str = "backup.json";
|
||||
|
||||
pub struct ZipBackupWriter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::BackupWriterPort for ZipBackupWriter {
|
||||
async fn write(&self, backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
|
||||
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
zip.start_file(BACKUP_MANIFEST, options).map_err(zip_err)?;
|
||||
zip.write_all(&manifest(backup)?).map_err(io_err)?;
|
||||
|
||||
for photo in &backup.media.photos {
|
||||
zip.start_file(format!("photos/{}", photo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&photo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
for memo in &backup.media.voice_memos {
|
||||
zip.start_file(format!("voice_memos/{}", memo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&memo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
Ok(zip.finish().map_err(zip_err)?.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
|
||||
let manifest = BackupManifest {
|
||||
version: BACKUP_FORMAT_VERSION,
|
||||
entries: backup.entries.iter().map(BackedUpEntry::from).collect(),
|
||||
metrics: backup.metrics.iter().map(BackedUpMetric::from).collect(),
|
||||
cycle_starts: backup
|
||||
.cycle_starts
|
||||
.iter()
|
||||
.map(|start| start.date().to_string())
|
||||
.collect(),
|
||||
activities: backup
|
||||
.activities
|
||||
.iter()
|
||||
.map(BackedUpActivity::from)
|
||||
.collect(),
|
||||
reminders: backup
|
||||
.reminders
|
||||
.iter()
|
||||
.map(BackedUpReminder::from)
|
||||
.collect(),
|
||||
tracks_cycle: backup.preferences.tracks_cycle(),
|
||||
};
|
||||
|
||||
serde_json::to_vec_pretty(&manifest).map_err(json_err)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupManifest {
|
||||
pub version: u32,
|
||||
pub entries: Vec<BackedUpEntry>,
|
||||
pub metrics: Vec<BackedUpMetric>,
|
||||
pub cycle_starts: Vec<String>,
|
||||
pub activities: Vec<BackedUpActivity>,
|
||||
pub reminders: Vec<BackedUpReminder>,
|
||||
pub tracks_cycle: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackedUpEntry {
|
||||
pub mood: u8,
|
||||
pub logged_at: String,
|
||||
pub dimensions: Vec<DimensionPayload>,
|
||||
}
|
||||
|
||||
impl From<&ComposedEntry> for BackedUpEntry {
|
||||
fn from(composed: &ComposedEntry) -> Self {
|
||||
Self {
|
||||
mood: composed.entry.mood().value(),
|
||||
logged_at: composed.entry.logged_at().to_rfc3339(),
|
||||
dimensions: composed
|
||||
.dimensions
|
||||
.iter()
|
||||
.map(DimensionPayload::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackedUpMetric {
|
||||
pub date: String,
|
||||
pub kind: String,
|
||||
pub value: i64,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&DailyMetric> for BackedUpMetric {
|
||||
fn from(metric: &DailyMetric) -> Self {
|
||||
Self {
|
||||
date: metric.date().to_string(),
|
||||
kind: metric.kind().name().to_string(),
|
||||
value: metric.value().count(),
|
||||
provider: metric
|
||||
.source()
|
||||
.provider()
|
||||
.map(|name| name.value().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackedUpActivity {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl From<&Activity> for BackedUpActivity {
|
||||
fn from(activity: &Activity) -> Self {
|
||||
Self {
|
||||
id: activity.id().value().to_string(),
|
||||
name: activity.name().value().to_string(),
|
||||
category: activity.category().map(|c| c.value().to_string()),
|
||||
archived: activity.is_archived(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackedUpReminder {
|
||||
pub enabled: bool,
|
||||
pub monday: Option<String>,
|
||||
pub tuesday: Option<String>,
|
||||
pub wednesday: Option<String>,
|
||||
pub thursday: Option<String>,
|
||||
pub friday: Option<String>,
|
||||
pub saturday: Option<String>,
|
||||
pub sunday: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Reminder> for BackedUpReminder {
|
||||
fn from(reminder: &Reminder) -> Self {
|
||||
let at = |day| {
|
||||
reminder
|
||||
.schedule()
|
||||
.time_for(day)
|
||||
.map(|time| time.format("%H:%M").to_string())
|
||||
};
|
||||
|
||||
Self {
|
||||
enabled: reminder.is_enabled(),
|
||||
monday: at(Weekday::Mon),
|
||||
tuesday: at(Weekday::Tue),
|
||||
wednesday: at(Weekday::Wed),
|
||||
thursday: at(Weekday::Thu),
|
||||
friday: at(Weekday::Fri),
|
||||
saturday: at(Weekday::Sat),
|
||||
sunday: at(Weekday::Sun),
|
||||
}
|
||||
}
|
||||
}
|
||||
71
crates/adapters/exporter/src/extract.rs
Normal file
71
crates/adapters/exporter/src/extract.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::SharedExtract;
|
||||
|
||||
const HEADING: &str = "# Mood journal";
|
||||
const PREAMBLE: &str = "A shareable extract: mood, what was written, and what was tagged. It deliberately carries \
|
||||
nothing else — no places, no health readings, no cycle records — and it is not a backup.";
|
||||
|
||||
pub struct MarkdownExtractWriter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ExtractWriterPort for MarkdownExtractWriter {
|
||||
async fn write(&self, extract: &SharedExtract) -> Result<Vec<u8>, DomainError> {
|
||||
let names: HashMap<String, String> = extract
|
||||
.activities
|
||||
.iter()
|
||||
.map(|activity| {
|
||||
(
|
||||
activity.id().value().to_string(),
|
||||
activity.name().value().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut entries: Vec<&ComposedEntry> = extract.entries.iter().collect();
|
||||
entries.sort_by_key(|composed| *composed.entry.logged_at());
|
||||
|
||||
let mut document = format!("{HEADING}\n\n{PREAMBLE}\n");
|
||||
let mut current_day = String::new();
|
||||
|
||||
for composed in entries {
|
||||
let logged_at = composed.entry.logged_at();
|
||||
let day = logged_at.format("%A %-d %B %Y").to_string();
|
||||
|
||||
if day != current_day {
|
||||
document.push_str(&format!("\n## {day}\n"));
|
||||
current_day = day;
|
||||
}
|
||||
|
||||
document.push_str(&format!(
|
||||
"\n**{}** — {:?}\n",
|
||||
logged_at.format("%H:%M"),
|
||||
composed.entry.mood()
|
||||
));
|
||||
|
||||
let tagged = tags(composed, &names);
|
||||
if !tagged.is_empty() {
|
||||
document.push_str(&format!("\n_{}_\n", tagged.join(", ")));
|
||||
}
|
||||
|
||||
if let Some(content) = composed.content() {
|
||||
document.push_str(&format!("\n{}\n", content.value()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(document.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(composed: &ComposedEntry, names: &HashMap<String, String>) -> Vec<String> {
|
||||
composed
|
||||
.activities()
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let id = id.value().to_string();
|
||||
names.get(&id).cloned().unwrap_or(id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UserExport;
|
||||
|
||||
pub struct JsonExportAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ExportPort for JsonExportAdapter {
|
||||
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = ZipWriter::new(buf);
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let json = build_data_json(data)?;
|
||||
zip.start_file("data.json", options).map_err(zip_err)?;
|
||||
zip.write_all(&json).map_err(io_err)?;
|
||||
|
||||
for photo in &data.photos {
|
||||
zip.start_file(format!("photos/{}", photo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&photo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
for memo in &data.voice_memos {
|
||||
zip.start_file(format!("voice_memos/{}", memo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&memo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
let cursor = zip.finish().map_err(zip_err)?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_data_json(data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||
let export = ExportData {
|
||||
version: "1.0",
|
||||
entries: data.entries.iter().map(EntryExport::from).collect(),
|
||||
activities: data.activities.iter().map(ActivityExport::from).collect(),
|
||||
reminder_count: data.reminders.len(),
|
||||
};
|
||||
|
||||
serde_json::to_vec_pretty(&export)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("json serialization failed: {e}")))
|
||||
}
|
||||
|
||||
fn zip_err(e: zip::result::ZipError) -> DomainError {
|
||||
DomainError::InvalidInput(format!("zip error: {e}"))
|
||||
}
|
||||
|
||||
fn io_err(e: std::io::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("io error: {e}"))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportData<'a> {
|
||||
version: &'a str,
|
||||
entries: Vec<EntryExport>,
|
||||
activities: Vec<ActivityExport>,
|
||||
reminder_count: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EntryExport {
|
||||
id: String,
|
||||
mood: u8,
|
||||
mood_label: String,
|
||||
logged_at: String,
|
||||
activities: Vec<String>,
|
||||
content: Option<String>,
|
||||
photos: Vec<String>,
|
||||
voice_memos: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&domain::entry::MoodEntry> for EntryExport {
|
||||
fn from(entry: &domain::entry::MoodEntry) -> Self {
|
||||
Self {
|
||||
id: entry.id().value().to_string(),
|
||||
mood: entry.mood().value(),
|
||||
mood_label: format!("{:?}", entry.mood()),
|
||||
logged_at: entry.logged_at().to_rfc3339(),
|
||||
activities: entry
|
||||
.activities()
|
||||
.iter()
|
||||
.map(|a| a.value().to_string())
|
||||
.collect(),
|
||||
content: entry.content().map(|c| c.value().to_string()),
|
||||
photos: entry
|
||||
.photos()
|
||||
.iter()
|
||||
.map(|p| p.value().to_string())
|
||||
.collect(),
|
||||
voice_memos: entry
|
||||
.voice_memos()
|
||||
.iter()
|
||||
.map(|v| v.value().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActivityExport {
|
||||
id: String,
|
||||
name: String,
|
||||
category: Option<String>,
|
||||
archived: bool,
|
||||
}
|
||||
|
||||
impl From<&domain::activity::Activity> for ActivityExport {
|
||||
fn from(activity: &domain::activity::Activity) -> Self {
|
||||
Self {
|
||||
id: activity.id().value().to_string(),
|
||||
name: activity.name().value().to_string(),
|
||||
category: activity.category().map(|c| c.value().to_string()),
|
||||
archived: activity.is_archived(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
mod json_export;
|
||||
mod backup;
|
||||
mod extract;
|
||||
mod shared;
|
||||
|
||||
pub use json_export::JsonExportAdapter;
|
||||
pub use backup::{
|
||||
BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMetric,
|
||||
BackedUpReminder, BackupManifest, ZipBackupWriter,
|
||||
};
|
||||
pub use extract::MarkdownExtractWriter;
|
||||
|
||||
13
crates/adapters/exporter/src/shared.rs
Normal file
13
crates/adapters/exporter/src/shared.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub fn zip_err(error: zip::result::ZipError) -> DomainError {
|
||||
DomainError::InvalidInput(format!("zip error: {error}"))
|
||||
}
|
||||
|
||||
pub fn io_err(error: std::io::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("io error: {error}"))
|
||||
}
|
||||
|
||||
pub fn json_err(error: serde_json::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("json error: {error}"))
|
||||
}
|
||||
132
crates/adapters/exporter/tests/extract_test.rs
Normal file
132
crates/adapters/exporter/tests/extract_test.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use std::io::Read;
|
||||
|
||||
use domain::activity::{Activity, ActivityName};
|
||||
use domain::dimension::{ComposedEntry, DimensionValue};
|
||||
use domain::entry::{Content, Mood, MoodEntry};
|
||||
use domain::location::Coordinates;
|
||||
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
|
||||
use domain::ports::{BackupMedia, BackupWriterPort, ExtractWriterPort, SharedExtract, UserBackup};
|
||||
use domain::song::Song;
|
||||
use domain::user::{UserId, UserPreferences};
|
||||
|
||||
use exporter::{MarkdownExtractWriter, ZipBackupWriter};
|
||||
|
||||
fn at(instant: &str) -> chrono::DateTime<chrono::FixedOffset> {
|
||||
chrono::DateTime::parse_from_rfc3339(instant).unwrap()
|
||||
}
|
||||
|
||||
fn a_revealing_entry(owner: &UserId, exercise: &Activity) -> ComposedEntry {
|
||||
ComposedEntry {
|
||||
entry: MoodEntry::new(owner.clone(), Mood::Rad, at("2026-08-20T21:30:00+02:00")),
|
||||
dimensions: vec![
|
||||
DimensionValue::Content(Content::new("Long walk by the river").unwrap()),
|
||||
DimensionValue::activities(vec![exercise.id().clone()]),
|
||||
DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap()),
|
||||
DimensionValue::Song(
|
||||
Song::new("Teardrop", "Massive Attack", Some("Mezzanine".into()), None).unwrap(),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_of(entries: Vec<ComposedEntry>, activities: Vec<Activity>) -> String {
|
||||
let bytes = MarkdownExtractWriter
|
||||
.write(&SharedExtract {
|
||||
entries,
|
||||
activities,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
String::from_utf8(bytes).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_extract_carries_the_journal_a_person_would_want_to_read() {
|
||||
let owner = UserId::generate();
|
||||
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
|
||||
|
||||
let document = extract_of(vec![a_revealing_entry(&owner, &exercise)], vec![exercise]).await;
|
||||
|
||||
assert!(document.contains("Long walk by the river"), "{document}");
|
||||
assert!(document.contains("Rad"), "{document}");
|
||||
assert!(
|
||||
document.contains("long walk"),
|
||||
"the activity name is missing"
|
||||
);
|
||||
assert!(document.contains("Thursday 20 August 2026"), "{document}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_extract_discloses_no_place_and_no_song() {
|
||||
let owner = UserId::generate();
|
||||
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
|
||||
|
||||
let document = extract_of(vec![a_revealing_entry(&owner, &exercise)], vec![exercise]).await;
|
||||
|
||||
for secret in ["52.2", "21.0", "Massive Attack", "Teardrop", "Mezzanine"] {
|
||||
assert!(
|
||||
!document.contains(secret),
|
||||
"the extract leaked {secret}:\n{document}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_extract_says_what_it_is_not() {
|
||||
let document = extract_of(Vec::new(), Vec::new()).await;
|
||||
|
||||
assert!(document.contains("not a backup"), "{document}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_backup_carries_everything_the_extract_leaves_out() {
|
||||
let owner = UserId::generate();
|
||||
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
|
||||
|
||||
let backup = UserBackup {
|
||||
entries: vec![a_revealing_entry(&owner, &exercise)],
|
||||
metrics: vec![DailyMetric::new(
|
||||
owner.clone(),
|
||||
domain::entry::Date::from_persistence("2026-08-20".parse().unwrap()),
|
||||
MetricValue::Steps(Steps::new(8_412).unwrap()),
|
||||
Source::Manual,
|
||||
)],
|
||||
cycle_starts: vec![domain::cycle::CycleStartRestore::on(
|
||||
domain::entry::Date::from_persistence("2026-08-01".parse().unwrap()),
|
||||
)],
|
||||
activities: vec![exercise],
|
||||
reminders: Vec::new(),
|
||||
preferences: UserPreferences::off_by_default(owner),
|
||||
media: BackupMedia {
|
||||
photos: Vec::new(),
|
||||
voice_memos: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let archive = ZipBackupWriter.write(&backup).await.unwrap();
|
||||
let manifest = manifest_of(&archive);
|
||||
|
||||
for expected in [
|
||||
"Long walk by the river",
|
||||
"52.2297",
|
||||
"Massive Attack",
|
||||
"8412",
|
||||
"2026-08-01",
|
||||
"long walk",
|
||||
] {
|
||||
assert!(
|
||||
manifest.contains(expected),
|
||||
"the backup is missing {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_of(archive: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
|
||||
let mut file = zip.by_name("backup.json").expect("a backup has a manifest");
|
||||
let mut manifest = String::new();
|
||||
file.read_to_string(&mut manifest).unwrap();
|
||||
|
||||
manifest
|
||||
}
|
||||
Reference in New Issue
Block a user