Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
cbbece2
fix(server): keep a message's time to the millisecond
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
850b1cb
Merge remote-tracking branch 'origin/main' into HEAD
mbeisser1 Oct 6, 2026
87c5cf0
refactor(server): one function writes the stored message time form
mbeisser1 Oct 6, 2026
2d1f638
docs(server): name the stored time writer once, and where it lives
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
fc1ca2b
Merge remote-tracking branch 'origin/main' into HEAD
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
630d89f
Merge remote-tracking branch 'origin/fix/1096-millisecond-message-tim…
mbeisser1 Oct 6, 2026
49bb13b
Merge remote-tracking branch 'origin/main' into feat/1923-time-precision
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
8597d7c
fix(import): keep a backup's date to the millisecond, as a message time
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
dcc9d24
feat(ir): each message says whether its time has milliseconds
mbeisser1 Oct 6, 2026
fea14d2
style: rewrap two paragraphs, and fixtures carry the stored date form
mbeisser1 Oct 6, 2026
5905982
feat(server): store a message's time precision and answer it
mbeisser1 Oct 6, 2026
5b3b889
feat(dedupe): a whole-second copy of a message is the duplicate of it…
mbeisser1 Oct 6, 2026
d33c404
chore(api): regenerate the OpenAPI document and web types for time_pr…
mbeisser1 Oct 6, 2026
9fd576e
docs: describe a message's time precision and the whole-second twin rule
mbeisser1 Oct 6, 2026
de31ed4
Merge remote-tracking branch 'origin/feat/1924-backup-taken-at' into …
mbeisser1 Oct 6, 2026
5fe0a14
docs: name schema version 12 where the docs still named an older one
mbeisser1 Oct 6, 2026
19ff774
Merge origin/main into feat/1923-time-precision
mbeisser1 Oct 6, 2026
99660f3
docs(export): each projection function keeps its own doc comment
mbeisser1 Oct 6, 2026
0353434
docs(changelog): the time precision entry names no mail headers
mbeisser1 Oct 6, 2026
5859552
refactor(ir, api-types): both TimePrecision enums parse the same way
mbeisser1 Oct 6, 2026
7093bcb
fix(dedupe): a stored time_precision the dedupe cannot read fails the…
mbeisser1 Oct 6, 2026
1b5c35d
refactor(dedupe): the twin split moves each message instead of clonin…
mbeisser1 Oct 6, 2026
04273c3
docs(dedupe): a twin is shown with milliseconds unless another source…
mbeisser1 Oct 6, 2026
e209647
docs(common-message): SMS Backup & Restore XML cannot carry the time …
mbeisser1 Oct 6, 2026
6bfdb84
fix(import): one message held at whole seconds and at .000 millisecon…
mbeisser1 Oct 6, 2026
d6f19fb
fix(import): a millisecond copy raises the flag only at the stored me…
mbeisser1 Oct 6, 2026
f833bc8
docs: the twin rule's caveat reaches the CHANGELOG, and its passages …
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ released versions carry their date on the heading.

### Features

- 2026-10-06: **A message recorded once to the second and once to the
millisecond shows once.** Every message file now says whether each
message's time has milliseconds or only whole seconds, as its backup app
recorded it. iMazing, OpenExtract, GO SMS Pro's PDU files and SMS Backup+
mails timed only to the second record whole seconds; the other sources
Comment thread
mbeisser1 marked this conversation as resolved.
record milliseconds. When one backup app holds a message twice, once to
the second and once to the millisecond, such as two SMS Backup+ mails of
one message, hiding duplicates hides the whole-second copy and shows the
message at its time to the millisecond. When another backup app holds the
Comment thread
mbeisser1 marked this conversation as resolved.
message too, its copy may be the one shown, and it can have whole seconds
only. A time to the millisecond that ends in `.000` counts as
milliseconds. CSV exports carry it in a `time_precision` column, and mail
exports in an `X-ME-Time-Precision` header. Message files exported before
they said whether each time has milliseconds are refused, and the backup
must be exported again with this build.
- 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
Expand Down Expand Up @@ -181,6 +196,10 @@ released versions carry their date on the heading.
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`.
- Message files exported before each message said whether its time has
milliseconds are refused when you import or convert them. Export the
backup again with this build. A program that reads the HTTP API finds it
in a message's `time_precision`, `seconds` or `milliseconds`.

## [0.10.1] - 2026-10-05

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 @@ -480,6 +480,7 @@ fn staging_a_conversation_writes_the_files_counts_them_and_frees_the_bytes() {
messages: vec![IrMessage {
guid: "guid-1".into(),
timestamp_unix_ms: 1_400_773_261_000,
time_precision: message_ir::TimePrecision::Milliseconds,
direction: IrDirection::Incoming,
service: IrService::Sms,
message_kind: IrMessageKind::Mms,
Expand Down Expand Up @@ -723,6 +724,7 @@ fn staging_frees_the_bytes_the_documents_were_carrying() {
messages: vec![IrMessage {
guid: "guid-1".into(),
timestamp_unix_ms: 1_400_773_261_000,
time_precision: message_ir::TimePrecision::Milliseconds,
direction: IrDirection::Incoming,
service: IrService::Sms,
message_kind: IrMessageKind::Mms,
Expand Down
5 changes: 5 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 @@ -60,6 +60,8 @@ fn convert_smoke_writes_csv_not_json() {
("direction", "incoming"),
("sender_identity", "+14075550107"),
("timestamp_unix_ms", "1609459200000"),
// The XML records milliseconds, though these end in `.000`.
("time_precision", "milliseconds"),
("chat_identifier", "+14075550107"),
],
);
Expand All @@ -73,6 +75,7 @@ fn convert_smoke_writes_csv_not_json() {
("text", "smoke reply"),
("direction", "outgoing"),
("timestamp_unix_ms", "1609459260000"),
("time_precision", "milliseconds"),
],
);
// The PDU beside the XML lands in the same conversation.
Expand All @@ -82,6 +85,8 @@ fn convert_smoke_writes_csv_not_json() {
("text", "Hello one to one"),
("direction", "incoming"),
("sender_identity", "+14075550107"),
// A PDU file's name records whole seconds only.
("time_precision", "seconds"),
],
);
}
Expand Down
17 changes: 15 additions & 2 deletions crates/exporters/imazing-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ fn convert_messages_keys_the_chat_by_its_number() {
("direction", "incoming"),
("service", "sms"),
("sender_display_name", "Bob Sample"),
// iMazing writes whole seconds only.
("time_precision", "seconds"),
],
);
assert_csv_row(
&out,
&[
("text", "Hi Bob"),
("direction", "outgoing"),
("time_precision", "seconds"),
],
);
assert_csv_row(&out, &[("text", "Hi Bob"), ("direction", "outgoing")]);
// The third row is an iMessage carrying an attachment, so it proves both
// that the service column follows the source and that the attachment file
// name reached the row rather than only the directory.
Expand Down Expand Up @@ -91,7 +100,11 @@ fn convert_whatsapp_csv_direct() {
// parse that lost the column or put the flag on the wrong message fails.
assert_csv_row(
&out,
&[("text", "Hello on WhatsApp"), ("direction", "incoming")],
&[
("text", "Hello on WhatsApp"),
("direction", "incoming"),
("time_precision", "seconds"),
],
);
assert_csv_row(
&out,
Expand Down
6 changes: 5 additions & 1 deletion crates/exporters/imessage-ir-exporter/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use message_crate_core::{
use message_ir::{
ConversationDocument, ConversationMeta, ExportMeta, HandleType, IrAttachment,
IrConversationType, IrDirection, IrImessage, IrMessage, IrMessageKind, IrParticipant,
IrService, SCHEMA_VERSION, nonempty, owner_sender,
IrService, SCHEMA_VERSION, TimePrecision, nonempty, owner_sender,
};
use message_ir_format::FormatSink;
use message_staging::{
Expand Down Expand Up @@ -410,6 +410,9 @@ fn message_to_ir(
let message = IrMessage {
guid: record.guid,
timestamp_unix_ms: record.timestamp_unix_ms,
// `chat.db` records a message's time in nanoseconds (since macOS
// 10.13 and iOS 11).
time_precision: TimePrecision::Milliseconds,
Comment thread
mbeisser1 marked this conversation as resolved.
Comment thread
mbeisser1 marked this conversation as resolved.
direction,
service: IrService::parse(&record.service),
message_kind: IrMessageKind::parse(&record.message_kind),
Expand Down Expand Up @@ -1317,6 +1320,7 @@ mod tests {
IrMessage {
guid: format!("guid-{ts}"),
timestamp_unix_ms: ts,
time_precision: TimePrecision::Milliseconds,
direction: IrDirection::Incoming,
service: IrService::IMessage,
message_kind: IrMessageKind::IMessage,
Expand Down
16 changes: 15 additions & 1 deletion crates/exporters/imessage-ir-exporter/tests/helper_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use chat_db_fixture::{
};
use common::{config, helper_binary};
use message_crate_core::{ExporterConfig, OutputFormat};
use message_ir::{ConversationDocument, Deletion, IrDirection, IrMessage};
use message_ir::{ConversationDocument, Deletion, IrDirection, IrMessage, TimePrecision};
use message_ir_format::{
read_conversation_csv, read_conversation_eml_dir, read_conversation_jsonl,
read_conversation_mbox,
Expand Down Expand Up @@ -82,6 +82,20 @@ fn exports_a_mac_chat_db_through_the_helper_process() {
assert_eq!(staged.len(), 1, "{staged:?}");
assert_eq!(fs::read(&staged[0]).unwrap(), PHOTO_BYTES);
assert!(all.contains("\"attachments/"), "{all}");

// `chat.db` records times below the second, so every message says
// milliseconds.
for path in &files {
let doc = read_conversation_jsonl(path).unwrap();
for msg in &doc.messages {
assert_eq!(
msg.time_precision,
TimePrecision::Milliseconds,
"{}",
msg.guid
);
}
}
}

/// Every conversation file of a Mac `chat.db` export says the database's
Expand Down
3 changes: 3 additions & 0 deletions crates/exporters/openextract-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ fn convert_all_conversations_keys_the_chat_by_its_number() {
("sender_identity", "+15555550122"),
// 2020-01-01T17:00:00+00:00 in the source.
("timestamp_unix_ms", "1577898000000"),
// OpenExtract writes whole seconds only.
("time_precision", "seconds"),
],
);
// "Is From Me" is True on this row, and the direction column is where that
Expand All @@ -78,6 +80,7 @@ fn convert_all_conversations_keys_the_chat_by_its_number() {
("text", "Hi Sam"),
("direction", "outgoing"),
("timestamp_unix_ms", "1577898060000"),
("time_precision", "seconds"),
],
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/exporters/sms-backup-plus-exporter/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ mod tests {
messages: vec![IrMessage {
guid: "g".into(),
timestamp_unix_ms: 0,
time_precision: message_ir::TimePrecision::Milliseconds,
direction: IrDirection::Incoming,
service: IrService::Sms,
message_kind: IrMessageKind::Mms,
Expand Down
39 changes: 39 additions & 0 deletions crates/exporters/sms-backup-plus-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ fn convert_smoke_writes_csv_not_json() {
("text", "Hello from Alice"),
("direction", "incoming"),
("timestamp_unix_ms", "1609459200000"),
// `X-smssync-date` records milliseconds, though these end in
// `.000`.
("time_precision", "milliseconds"),
("chat_identifier", "+14075550107"),
],
);
Expand Down Expand Up @@ -316,13 +319,15 @@ fn two_messages_sharing_an_smssync_id_both_survive() {
("text", "Hello from Alex"),
("direction", "outgoing"),
("timestamp_unix_ms", "1609459200313"),
("time_precision", "milliseconds"),
],
);
assert_csv_row(
&out.join("+15555550122.csv"),
&[
("text", "Hello from Sam"),
("timestamp_unix_ms", "1609459300000"),
("time_precision", "milliseconds"),
],
);
// The same-chat pair: both messages, in one conversation file.
Expand Down Expand Up @@ -434,3 +439,37 @@ fn the_backup_date_is_the_newest_mail_files_modification_time() {
"{dates:?}"
);
}

/// A message with no `X-smssync-date` takes its time from the mail's `Date`,
/// which records whole seconds, and says so.
#[test]
fn a_message_timed_by_its_date_header_has_whole_seconds() {
let tmp = tempfile::tempdir().expect("tempdir");
let input = tmp.path().join("in");
let out = tmp.path().join("out");
fs::create_dir_all(&input).expect("input dir");
fs::write(
input.join("dated.eml"),
"From: dana@unknown.email\n\
To: me@example.com\n\
Subject: SMS with Dana\n\
Date: Fri, 01 Jan 2021 00:00:05 +0000\n\
X-smssync-type: 1\n\
X-smssync-address: +15555550133\n\
Content-Type: text/plain; charset=utf-8\n\
\n\
Timed by Date\n",
)
.expect("write fixture");

convert(&[input.as_path()], &out).expect("convert");

assert_csv_row(
&out.join("+15555550133.csv"),
&[
("text", "Timed by Date"),
("timestamp_unix_ms", "1609459205000"),
("time_precision", "seconds"),
],
);
}
9 changes: 6 additions & 3 deletions crates/exporters/sms-backup-restore-exporter/src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,10 @@ fn dedupe(messages: &mut Vec<PendingMessage>) -> u64 {
let before = messages.len();
let mut kept = kept.into_iter();
messages.retain_mut(|m| match kept.next().flatten() {
Some(ms) => {
if m.time().0 != ms {
Some((ms, precision)) => {
// A whole-second copy that keeps a millisecond copy's time keeps
// its precision too, even when that time ends in `.000`.
if m.time() != (ms, precision) {
m.date_ms = ms.to_string();
}
true
Expand Down Expand Up @@ -624,7 +626,7 @@ fn ir_message(
message: &PendingMessage,
owner: &(Option<String>, Option<String>),
) -> IrMessage {
let (timestamp_unix_ms, _) = message.time();
let (timestamp_unix_ms, time_precision) = message.time();
let digests = message.attachment_digests();
let (sender_identity, sender_display_name) = if message.is_from_me {
owner.clone()
Expand All @@ -643,6 +645,7 @@ fn ir_message(
})
.into_string(),
timestamp_unix_ms,
time_precision,
direction: if message.is_from_me {
IrDirection::Outgoing
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ fn convert_export_smoke_on_sample_fixture() {
("text", "hello"),
("direction", "incoming"),
("timestamp_unix_ms", "1400773261000"),
("time_precision", "milliseconds"),
("chat_identifier", "+15555550101"),
],
);
Expand All @@ -83,6 +84,7 @@ fn convert_export_smoke_on_sample_fixture() {
("text", "hey"),
("direction", "outgoing"),
("timestamp_unix_ms", "1400773321000"),
("time_precision", "milliseconds"),
],
);
// The `<mms>` is a different parse: its text lives in a `text/plain` part
Expand All @@ -95,6 +97,7 @@ fn convert_export_smoke_on_sample_fixture() {
("direction", "incoming"),
("message_kind", "mms"),
("timestamp_unix_ms", "1400773400000"),
("time_precision", "milliseconds"),
],
);

Expand Down Expand Up @@ -344,7 +347,7 @@ fn convert_export_json_and_jsonl_use_pristine_v4() {
.expect("expected .json");
let raw = fs::read_to_string(&json_path).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(doc["schema_version"], 11);
assert_eq!(doc["schema_version"], 12);
assert!(
doc["conversation"]["stats"]["message_count"]
.as_u64()
Expand Down Expand Up @@ -389,7 +392,7 @@ fn convert_export_json_and_jsonl_use_pristine_v4() {
let body = fs::read_to_string(&jsonl_path).unwrap();
let mut lines = body.lines();
let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(header["schema_version"], 11);
assert_eq!(header["schema_version"], 12);
assert!(header.get("messages").is_none());
assert!(
header["conversation"]["stats"]["message_count"]
Expand Down
12 changes: 12 additions & 0 deletions crates/exporters/whatsapp-exporter/tests/convert_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ fn messages_keep_their_time_and_their_identity() {
(9_999_999_999_000, "last second"),
]
);
// WhatsApp stores milliseconds, and the seconds wtsexporter writes carry
// them as a fraction, so every time has milliseconds, whole or not.
assert!(
doc.messages
.iter()
.all(|m| m.time_precision == message_ir::TimePrecision::Milliseconds),
"{:?}",
doc.messages
.iter()
.map(|m| m.time_precision)
.collect::<Vec<_>>()
);
assert_ne!(
doc.messages[1].guid, doc.messages[2].guid,
"the same text in the same second is two messages"
Expand Down
Loading
Loading