Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2a3b5c4
refactor(export): rename message-crate-pull to message-crate-export
mbeisser1 Oct 6, 2026
489dbfa
Merge remote-tracking branch 'origin/main' into HEAD
mbeisser1 Oct 6, 2026
19ac8e1
docs(changelog): say the Export record rename in plain words
mbeisser1 Oct 6, 2026
95e1b71
docs(adr): head the rename notes Amended, like ADR 0011
mbeisser1 Oct 6, 2026
53ee12a
docs(agents): the layout tree names export, not pull
mbeisser1 Oct 6, 2026
a08c6aa
refactor(export): the JSON Lines an Export writes go in .exported
mbeisser1 Oct 6, 2026
1599291
refactor(export): say Export where comments and tests still said pull
mbeisser1 Oct 6, 2026
5c8cc8d
refactor(export): call the step that writes JSON Lines the export step
mbeisser1 Oct 6, 2026
6c5703f
test(web): the Export job test says Export complete, not Pull
mbeisser1 Oct 6, 2026
5cc807f
Merge remote-tracking branch 'origin/main' into HEAD
mbeisser1 Oct 6, 2026
d8e0c55
feat(ir): the conversation file says when its backup was made
mbeisser1 Oct 6, 2026
c11e990
feat(import): the later backup decides a message's mark and text
mbeisser1 Oct 6, 2026
94debf3
feat(web): Import details show the backup a run read and when it was …
mbeisser1 Oct 6, 2026
b623800
docs: describe when a backup was made and how the import uses it
mbeisser1 Oct 6, 2026
c337dd5
test(sms-backup-restore): a JSON export is at schema version 11
mbeisser1 Oct 6, 2026
a7ea065
Merge remote-tracking branch 'origin/feat/1906-export-crate-name' int…
mbeisser1 Oct 6, 2026
c588bc8
Merge remote-tracking branch 'origin/main' into feat/1924-backup-take…
mbeisser1 Oct 6, 2026
10a0946
fix(search): a later backup that clears an Unsent mark gives the inde…
mbeisser1 Oct 6, 2026
47aff7c
fix(import): equal backup dates fall back to the rules for files with…
mbeisser1 Oct 6, 2026
5de3d2e
fix(imessage): a Mac read is dated by chat.db and its write-ahead log
mbeisser1 Oct 6, 2026
9cc6ce1
fix(export): a conversation file names a backup only when all its mes…
mbeisser1 Oct 6, 2026
973edb5
fix(sbr): an XML export writes the backup date back
mbeisser1 Oct 6, 2026
edeada4
docs: the changelog names no schema version, and two reasons catch up
mbeisser1 Oct 6, 2026
c431833
refactor(whatsapp): the JSON a run converts is a named struct, not a …
mbeisser1 Oct 6, 2026
f9bac68
refactor: name the backup order, and date an XML file by what it writes
mbeisser1 Oct 6, 2026
f4f5a07
Merge remote-tracking branch 'origin/main' into feat/1924-backup-take…
mbeisser1 Oct 6, 2026
fea14d2
style: rewrap two paragraphs, and fixtures carry the stored date form
mbeisser1 Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ released versions carry their date on the heading.

### Features

- 2026-10-05: **The newer backup decides when a message changed between two
backups of one phone.** Every message file now says when its backup was
made: an iPhone backup's own date, the date an SMS Backup & Restore file
records, an iMazing export's date, or when the backup's files were last
written for a source that records none. Importing two backups of the same
phone, in one import or two and in either order, gives each message the
newer backup's text, earlier versions and Deleted in the source app or
Unsent mark, so a message recovered after it was deleted loses its mark,
and a message unsent after the older backup reads as unsent. An older
backup imported after a newer one changes nothing. Import details under
Settings → Storage show the backup each import read and when it was made.
Message files exported before they said when their backup was made are
refused, and the backup must be exported again with this build.
- 2026-10-05: **A WhatsApp reply now names the message it quotes.** When
the quoted message is in the same chat of the same backup, the reply is
linked to it, as Apple Messages replies already were: a mail export threads
Expand Down Expand Up @@ -164,6 +177,10 @@ released versions carry their date on the heading.
- If you have a program that reads messages from the HTTP API, a message's
`timestamp` and an earlier version's `edited_at` now carry three digits of
milliseconds, such as `2015-03-12T18:04:22.250Z`, where they had none.
- Message files exported before they said when their backup was made are
refused when you import or convert them. Export the backup again with
this build. A program that reads the HTTP API finds the backup's date in
a message's `backup_taken_at` and an Import Run's `backup_taken_at`.

## [0.10.1] - 2026-10-05

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions crates/core/message-crate-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ message-csv = { path = "../../libs/csv", optional = true }
message-ir = { path = "../../libs/ir" }
media = { path = "../../libs/media" }
obfuscate = { path = "../../libs/obfuscate" }
serde_json = { workspace = true, optional = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }

[features]
# The CSV reader is only for `testutil`, which reads exports back in tests.
testutil = ["dep:message-csv"]
# The CSV and JSON readers are only for `testutil`, which reads exports back
# in tests.
testutil = ["dep:message-csv", "dep:serde_json"]

[dev-dependencies]
message-csv = { path = "../../libs/csv" }
serde_json = { workspace = true }
media = { path = "../../libs/media", features = ["testutil"] }

[lints]
Expand Down
2 changes: 2 additions & 0 deletions crates/core/message-crate-core/src/attachment_jobs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ fn staging_a_conversation_writes_the_files_counts_them_and_frees_the_bytes() {
tool_version: "0.1.0".into(),
owner_identity: Some("+15555550100".into()),
owner_display_name: None,
backup_taken_at_unix_ms: None,
},
conversation: ConversationMeta {
chat_identifier: "+15555550101".into(),
Expand Down Expand Up @@ -710,6 +711,7 @@ fn staging_frees_the_bytes_the_documents_were_carrying() {
tool_version: "0.1.0".into(),
owner_identity: None,
owner_display_name: None,
backup_taken_at_unix_ms: None,
},
conversation: ConversationMeta {
chat_identifier: "+15555550101".into(),
Expand Down
4 changes: 2 additions & 2 deletions crates/core/message-crate-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ pub use exporters::{
};
pub use pipeline::{
CSV_NOT_READ, ExportReport, IssueSink, NAME_ONLY_CHAT_NOTE, NOTE, RunIssue, RunResult,
discover_files, emit_issue, export_meta, prepare_outputs, project_conversation,
unreadable_parts_note,
discover_files, emit_issue, export_meta, file_modified_unix_ms, newest_file_modified_unix_ms,
prepare_outputs, project_conversation, unreadable_parts_note,
};
pub use process::{
CancelFlag, Cancelled, LogSink, check_cancel, emit_log, is_cancelled, parallel_for_each,
Expand Down
50 changes: 49 additions & 1 deletion crates/core/message-crate-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,27 +467,75 @@ pub fn prepare_outputs(
Ok((resolved, output))
}

/// Standard export metadata: source / tool / version plus the owner identity.
/// Standard export metadata: source / tool / version, the owner identity,
/// and when the backup was made.
pub fn export_meta(
Comment thread
mbeisser1 marked this conversation as resolved.
source: &str,
tool: &str,
tool_version: &str,
owner_identity: Option<String>,
owner_display_name: Option<String>,
backup_taken_at_unix_ms: Option<i64>,
) -> message_ir::ExportMeta {
message_ir::ExportMeta {
source: source.to_string(),
tool: tool.to_string(),
tool_version: tool_version.to_string(),
owner_identity,
owner_display_name,
backup_taken_at_unix_ms,
}
}

/// When a file was last changed, in Unix milliseconds, or `None` when it
/// cannot be read or its time is before 1970. The backup date of a source
/// whose files record none of their own: the file was written when the
/// backup was made, or copied with its time kept.
pub fn file_modified_unix_ms(path: &std::path::Path) -> Option<i64> {
Comment thread
mbeisser1 marked this conversation as resolved.
let modified = fs::metadata(path).ok()?.modified().ok()?;
let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
i64::try_from(since.as_millis()).ok()
}

/// The latest [`file_modified_unix_ms`] of `paths`, or `None` when none has
/// one: the backup date of a source read from several files, which is as
/// new as the newest of them.
pub fn newest_file_modified_unix_ms<'a>(
paths: impl IntoIterator<Item = &'a std::path::Path>,
) -> Option<i64> {
paths.into_iter().filter_map(file_modified_unix_ms).max()
}

#[cfg(test)]
mod tests {
use super::*;

/// A source with no date of its own is as new as its newest file, and
/// a file that is not there adds nothing.
#[test]
fn a_backup_read_from_files_is_as_new_as_the_newest() {
use crate::testutil::{TEST_BACKUP_TAKEN_AT_UNIX_MS, set_modified_unix_ms};
let dir = tempfile::tempdir().unwrap();
let older = dir.path().join("older.csv");
let newer = dir.path().join("newer.csv");
fs::write(&older, "a").unwrap();
fs::write(&newer, "b").unwrap();
set_modified_unix_ms(&older, TEST_BACKUP_TAKEN_AT_UNIX_MS - 86_400_000);
set_modified_unix_ms(&newer, TEST_BACKUP_TAKEN_AT_UNIX_MS);

assert_eq!(
file_modified_unix_ms(&older),
Some(TEST_BACKUP_TAKEN_AT_UNIX_MS - 86_400_000)
);
let missing = dir.path().join("missing.csv");
assert_eq!(file_modified_unix_ms(&missing), None);
assert_eq!(
newest_file_modified_unix_ms([older.as_path(), newer.as_path(), missing.as_path()]),
Some(TEST_BACKUP_TAKEN_AT_UNIX_MS)
);
assert_eq!(newest_file_modified_unix_ms([missing.as_path()]), None);
}

#[test]
fn the_not_sms_or_mms_line_names_the_format_and_the_count() {
let mut report = ExportReport::default();
Expand Down
41 changes: 41 additions & 0 deletions crates/core/message-crate-core/src/testutil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,44 @@ pub fn assert_run_wrote_jsonl(
.collect::<Vec<_>>()
.join("\n")
}

/// The backup date the exporters' tests give a source: 2026-09-30T18:45:12Z,
/// in Unix milliseconds. A date of the test's own, so a test that reads it
/// back cannot pass by reading the time it ran at.
pub const TEST_BACKUP_TAKEN_AT_UNIX_MS: i64 = 1_790_793_912_000;

/// Set `path`'s modification time to `unix_ms`, the way a backup file keeps
/// the time it was written.
///
/// # Panics
///
/// Panics when the file cannot be opened or its time cannot be set.
pub fn set_modified_unix_ms(path: &Path, unix_ms: i64) {
let at = std::time::UNIX_EPOCH
+ std::time::Duration::from_millis(u64::try_from(unix_ms).expect("a time after 1970"));
fs::File::options()
.write(true)
.open(path)
.expect("open the file to date")
.set_modified(at)
.expect("set the file's modification time");
}

/// Every JSON Lines file's `export.backup_taken_at_unix_ms` under `dir`, in
/// file name order.
///
/// # Panics
///
/// Panics when a file cannot be read or its first line is not JSON.
pub fn jsonl_backup_dates(dir: &Path) -> Vec<Option<i64>> {
jsonl_names(dir)
.iter()
.map(|name| {
let text = fs::read_to_string(dir.join(name)).expect("read jsonl");
let header: serde_json::Value =
serde_json::from_str(text.lines().next().expect("a header line"))
.expect("the header is JSON");
header["export"]["backup_taken_at_unix_ms"].as_i64()
})
.collect()
}
17 changes: 13 additions & 4 deletions crates/exporters/go-sms-pro-exporter/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,14 +455,21 @@ pub(crate) fn convert_export(args: ConvertExportArgs<'_>) -> Result<ExportReport
report: ExportReport::with_issues(issues.cloned()),
skips: SkipDetails::default(),
};
for xml_path in sorted_files(input_dir, &is_xml_file)? {
let xml_paths = sorted_files(input_dir, &is_xml_file)?;
let pdu_paths = sorted_files(input_dir, &is_pdu_file)?;
for xml_path in &xml_paths {
message_crate_core::check_cancel(cancel)?;
ingest.ingest_xml(&xml_path);
ingest.ingest_xml(xml_path);
}
for pdu_path in sorted_files(input_dir, &is_pdu_file)? {
for pdu_path in &pdu_paths {
message_crate_core::check_cancel(cancel)?;
ingest.ingest_pdu(&pdu_path)?;
ingest.ingest_pdu(pdu_path)?;
}
// GO SMS Pro writes no date of its own, so the backup is as new as the
// newest file it wrote.
let backup_taken_at_unix_ms = message_crate_core::newest_file_modified_unix_ms(
xml_paths.iter().chain(&pdu_paths).map(PathBuf::as_path),
);
message_crate_core::check_cancel(cancel)?;
let Ingest {
conversations,
Expand All @@ -478,6 +485,7 @@ pub(crate) fn convert_export(args: ConvertExportArgs<'_>) -> Result<ExportReport
EXPORT_TOOL_VERSION,
Some(owner_identity),
None,
backup_taken_at_unix_ms,
),
};
let mut documents = Vec::new();
Expand Down Expand Up @@ -838,6 +846,7 @@ mod tests {
EXPORT_TOOL_VERSION,
Some("+15555550100".into()),
None,
None,
),
};
let mut convo = PendingConversation::new("+15555550122", false, None, Vec::new());
Expand Down
38 changes: 38 additions & 0 deletions crates/exporters/go-sms-pro-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,41 @@ fn jsonl_drains_the_write_queue_and_a_second_run_resumes_it() {
})
});
}

/// GO SMS Pro writes no date of its own, so the conversation file says the
/// newest modification time of the files it wrote, XML and PDU alike.
#[test]
fn the_backup_date_is_the_newest_files_modification_time() {
use message_crate_core::testutil::{
TEST_BACKUP_TAKEN_AT_UNIX_MS, jsonl_backup_dates, set_modified_unix_ms,
};
let tmp = tempfile::tempdir().expect("tempdir");
let input = tmp.path().join("backup");
write_backup(&input);
set_modified_unix_ms(
&input.join("gosms_sys_smoke.xml"),
TEST_BACKUP_TAKEN_AT_UNIX_MS - 60_000,
);
set_modified_unix_ms(
&input.join("I_1609459200_1_0.pdu"),
TEST_BACKUP_TAKEN_AT_UNIX_MS,
);
let output = tmp.path().join("out");
let cache = tempfile::tempdir().unwrap();
convert_export(ConvertExportArgs {
input_dir: &input,
output_dir: &output,
scratch_dir: cache.path(),
owner_phones: &["+15555550100".into()],
transforms: ExportTransforms::none(),
output_format: OutputFormat::Jsonl,
cancel: None,
resume: false,
issues: None,
})
.expect("convert");
assert_eq!(
jsonl_backup_dates(&output),
vec![Some(TEST_BACKUP_TAKEN_AT_UNIX_MS)]
);
}
11 changes: 10 additions & 1 deletion crates/exporters/imazing-exporter/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,18 @@ pub(crate) fn convert_export(args: ConvertExportArgs<'_>) -> Result<ExportReport
whatsapp_directories: HashSet::new(),
report: ExportReport::with_issues(issues.cloned()),
};
for (csv_index, discovered) in discover_csv_files(input)?.iter().enumerate() {
let discovered_files = discover_csv_files(input)?;
for (csv_index, discovered) in discovered_files.iter().enumerate() {
message_crate_core::check_cancel(cancel)?;
ingest.ingest_file(csv_index, discovered)?;
}
// An iMazing export records no date inside it, so the backup is as new
// as the newest CSV iMazing wrote: the export date.
let backup_taken_at_unix_ms = message_crate_core::newest_file_modified_unix_ms(
discovered_files
.iter()
.map(|discovered| discovered.path.as_path()),
);
if copy_attachments {
ingest.attach_unnamed_files()?;
ingest.tell_apart_files_of_one_name();
Expand All @@ -158,6 +166,7 @@ pub(crate) fn convert_export(args: ConvertExportArgs<'_>) -> Result<ExportReport
EXPORT_TOOL_VERSION,
None,
None,
backup_taken_at_unix_ms,
);
let mut documents = Vec::new();
let mut sources = Vec::new();
Expand Down
31 changes: 31 additions & 0 deletions crates/exporters/imazing-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,34 @@ fn jsonl_drains_the_write_queue_and_a_second_run_resumes_it() {
});
assert_eq!(report.conversations, 1);
}

/// An iMazing export records no date inside it, so the conversation file
/// says when iMazing wrote the CSV: the export date.
#[test]
fn the_backup_date_is_the_export_date_of_the_csv() {
use message_crate_core::testutil::{
TEST_BACKUP_TAKEN_AT_UNIX_MS, jsonl_backup_dates, set_modified_unix_ms,
};
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let input = tempfile::tempdir().expect("tempdir");
let messages = input.path().join("messages.csv");
fs::copy(fixture.join("messages.csv"), &messages).expect("copy fixture");
set_modified_unix_ms(&messages, TEST_BACKUP_TAKEN_AT_UNIX_MS);

let output = tempfile::tempdir().expect("tempdir");
convert_export(ConvertExportArgs {
input: &messages,
output: output.path(),
timezone: Some("UTC"),
transforms: ExportTransforms::none(),
output_format: OutputFormat::Jsonl,
cancel: None,
resume: false,
issues: None,
})
.expect("convert");
assert_eq!(
jsonl_backup_dates(output.path()),
vec![Some(TEST_BACKUP_TAKEN_AT_UNIX_MS)]
);
}
Loading
Loading