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

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

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

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

View File

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

View File

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

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

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