Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
[ OK ] CONTEXT BUFFER
[ -- ] AWAITING SESSION

TRACE // BUILD 0.31.0
TRACE // BUILD 0.32.0

· · · · · · · · · · · · · · · · · · · · · · · ·
```
Expand Down
8 changes: 7 additions & 1 deletion docs/10-BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,13 @@ What the audit found besides, worst first. Evidence is `file:line` as of
(`windows.rs`). `busy.rs` keeps `%LOCALAPPDATA%\TRACE\busy`, holding the
process id, for as long as either lasts; `update.ps1` reads it and leaves
a busy TRACE running, even with `-Yes`, and the in-app updater refuses.
7. **Regenerating overwrites body edits made outside TRACE.**
7. ~~**Regenerating overwrites body edits made outside TRACE.**~~ **Fixed
in 0.32.0.** `body_hash` in the frontmatter fingerprints the body as
TRACE wrote it (heading, line endings and trailing space left out);
`rewrite_note` copies a note whose body no longer matches, or that has
no hash, to `<name> (before regenerating …).md.bak` beside it, and the
job's outcome names the copy. ↻ asks first when the hash says edited.
Still not built: anything that merges the edits back. What it was:
`store::rewrite_note` keeps the title, tags, context and names from the
file and nothing else, by design — but nothing tells someone who edited
the Markdown in another app. *Fix:* hash the body when written; if it
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "trace",
"version": "0.31.0",
"version": "0.32.0",
"description": "Local-first meeting capture and intelligence. Conversations leave traces.",
"license": "MIT",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "trace"
version = "0.31.0"
version = "0.32.0"
description = "Local-first meeting capture and intelligence"
authors = ["Nick"]
license = "MIT"
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub enum Outcome {
dropped: usize,
fabricated: usize,
uncited: usize,
/// The file name of the copy kept of the note as it was, when its
/// body had been edited outside TRACE. Beside the note.
kept: Option<String>,
},
Failed {
message: String,
Expand Down Expand Up @@ -442,6 +445,7 @@ mod tests {
dropped: 0,
fabricated: 0,
uncited: 0,
kept: None,
},
);
let job = &a.snapshot()[0];
Expand Down Expand Up @@ -477,6 +481,7 @@ mod tests {
dropped: 0,
fabricated: 0,
uncited: 0,
kept: None,
},
);
a.finish(
Expand Down
19 changes: 13 additions & 6 deletions src-tauri/src/capture_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1116,12 +1116,15 @@ fn synthesize(
.and_then(|replay| {
store::rewrite_note(note_path, &replay.meeting).map_err(|e| e.to_string())
});
if let Err(e) = written {
fail(format!(
"the notes were written but the file could not be updated: {e}"
));
return;
}
let kept = match written {
Ok(kept) => kept,
Err(e) => {
fail(format!(
"the notes were written but the file could not be updated: {e}"
));
return;
}
};

let _ = app.emit(
EVENT_NOTES_GENERATED,
Expand All @@ -1133,6 +1136,10 @@ fn synthesize(
dropped: report.total_dropped(),
fabricated: report.fabricated,
uncited: report.uncited,
kept: kept
.as_deref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned()),
});
}

Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,19 @@ pub fn activity() -> Vec<crate::activity::Job> {
crate::activity::ACTIVITY.snapshot()
}

/// Whether the note's body was changed outside TRACE since TRACE wrote it.
///
/// Asked before regenerating, which writes the body afresh. False for a note
/// from before the hash, which cannot say: that one is copied regardless, but
/// asking "you edited this" of someone who did not would be crying wolf.
#[tauri::command]
pub fn note_edited(note_path: String) -> bool {
std::fs::read_to_string(note_path)
.ok()
.and_then(|text| store::markdown::body_edited(&text))
.unwrap_or(false)
}

/// Whether this note's journal still exists, so regeneration would work.
///
/// The UI asks before offering the control. Finding out that a button does
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ pub fn run() {
commands::reveal_notes_folder,
commands::regenerate_notes,
commands::can_regenerate,
commands::note_edited,
commands::activity,
commands::system_report,
commands::get_settings,
Expand Down
117 changes: 117 additions & 0 deletions src-tauri/src/store/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@
//!
//! Round-tripping is then lossless by construction rather than by effort,
//! because nothing regenerates the prose.
//!
//! # Except regeneration, which rewrites the whole body
//!
//! Regenerating replays the journal and writes the body afresh, so prose the
//! user changed in another editor would go with it. `body_hash` in the
//! frontmatter is what the body was when TRACE wrote it; a body that no
//! longer matches was edited outside TRACE, and `store::rewrite_note` keeps a
//! copy before replacing it.

use crate::meeting::{ActionItem, Meeting, MeetingType};

use super::StoreError;

/// Render a meeting as a Markdown document.
pub fn serialize(meeting: &Meeting) -> String {
let body = render_body(meeting);
let mut out = String::new();

out.push_str("---\n");
Expand Down Expand Up @@ -55,8 +64,15 @@ pub fn serialize(meeting: &Meeting) -> String {
}
push_list(&mut out, "participants", &meeting.participants);
push_list(&mut out, "tags", &meeting.tags);
push_field(&mut out, "body_hash", &hash_body(&body));
out.push_str("---\n\n");
out.push_str(&body);
out
}

/// Everything after the frontmatter.
fn render_body(meeting: &Meeting) -> String {
let mut out = String::new();
out.push_str(&format!("# {}\n", meeting.title));

if let Some(generated) = &meeting.generated {
Expand Down Expand Up @@ -109,6 +125,60 @@ pub fn serialize(meeting: &Meeting) -> String {
out
}

/// Whether a note's body has changed since TRACE wrote it.
///
/// `None` for a note written before 0.32, which has no hash to compare.
pub fn body_edited(markdown: &str) -> Option<bool> {
let stored = frontmatter_value(markdown, "body_hash")?;
Some(stored != hash_body(body_of(markdown)))
}

/// The text after the frontmatter, or all of it when there is none.
fn body_of(markdown: &str) -> &str {
let mut lines = markdown.split_inclusive('\n');
let Some(first) = lines.next().filter(|l| l.trim() == "---") else {
return markdown;
};
let mut offset = first.len();
for line in lines {
offset += line.len();
if line.trim() == "---" {
return &markdown[offset..];
}
}
markdown
}

/// A fingerprint of a body, blind to what is not an edit worth keeping.
///
/// The `# ` heading is left out because TRACE rewrites it itself on a rename,
/// and the title survives regeneration anyway. Line endings and trailing
/// whitespace are left out because editors change them on save without
/// anyone having edited anything.
///
/// FNV-1a rather than `DefaultHasher`, whose output may change between Rust
/// releases: this one is stored in files and compared years later.
fn hash_body(body: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut heading_skipped = false;
let lines = body.lines().map(str::trim_end).filter(|line| {
if !heading_skipped && line.starts_with("# ") {
heading_skipped = true;
return false;
}
!line.is_empty()
});
for line in lines {
for byte in line.bytes().chain(std::iter::once(b'\n')) {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
}
// Named, so no YAML reader takes an all-digit one for a number and
// rewrites it on save.
format!("fnv1a-{hash:016x}")
}

/// A checkbox line from `## Action items`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedActionItem {
Expand Down Expand Up @@ -528,6 +598,12 @@ mod tests {
m
}

fn generated_meeting() -> Meeting {
let mut m = meeting();
m.generated = Some(generated());
m
}

fn generated() -> GeneratedMeeting {
GeneratedMeeting {
summary: "We discussed pricing.".into(),
Expand Down Expand Up @@ -724,6 +800,47 @@ mod tests {
assert!(parse_action_items("").is_empty());
}

#[test]
fn a_note_as_written_is_not_edited() {
assert_eq!(body_edited(&serialize(&meeting())), Some(false));
}

#[test]
fn a_changed_line_in_the_body_is_an_edit() {
let md = serialize(&generated_meeting())
.replace("## Summary", "## Summary\n\nMy own paragraph.");
assert_eq!(body_edited(&md), Some(true));
}

#[test]
fn a_ticked_box_is_an_edit() {
// Regeneration writes the action items afresh, unticked, so a box
// ticked in another editor is lost like any other change.
let md = serialize(&generated_meeting());
assert!(md.contains("- [ ]"), "fixture needs an open action item");
assert_eq!(body_edited(&md.replacen("- [ ]", "- [x]", 1)), Some(true));
}

#[test]
fn what_trace_changes_itself_is_not_an_edit() {
let md = serialize(&meeting());
let md = replace_title(&md, "Renamed");
let md = replace_frontmatter_list(&md, "tags", &["client".to_string()]);
let md = replace_frontmatter_scalar(&md, "context", Some("an interview"));
assert_eq!(body_edited(&md), Some(false));
}

#[test]
fn an_editor_rewriting_line_endings_is_not_an_edit() {
let md = serialize(&meeting()).replace('\n', " \r\n");
assert_eq!(body_edited(&md), Some(false));
}

#[test]
fn a_note_from_before_the_hash_cannot_say() {
assert_eq!(body_edited("---\nid: x\n---\n\n# Old\n\nText\n"), None);
}

#[test]
fn meeting_type_is_readable_from_frontmatter() {
let mut m = meeting();
Expand Down
62 changes: 60 additions & 2 deletions src-tauri/src/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,31 @@ pub fn find_note(notes_root: &std::path::Path, id: &str) -> Option<PathBuf> {
/// edited in the file directly — see `context::apply_edits` — so replaying the
/// journal verbatim quietly undid a rename and dropped every tag the moment
/// notes were regenerated.
///
/// The body is not kept: it is written afresh from the journal. So when the
/// body was edited outside TRACE — or the note predates the hash that would
/// say — the file as it was is copied beside the note first, and that copy's
/// path returned for the user to be told.
pub fn rewrite_note(
path: &std::path::Path,
meeting: &crate::meeting::Meeting,
) -> Result<(), StoreError> {
) -> Result<Option<PathBuf>, StoreError> {
let mut meeting = meeting.clone();
let mut kept = None;
if let Ok(existing) = std::fs::read_to_string(path) {
context::apply_edits(&mut meeting, &existing);
if markdown::body_edited(&existing) != Some(false) {
let copy = paths::kept_copy_path(path, &chrono::Local::now());
// Copied, not rewritten, so the user's bytes are kept exactly.
// A copy that cannot be made stops the rewrite: losing the
// edits is the thing this is here to prevent.
std::fs::copy(path, &copy)?;
crate::diagnostics::log("note edited outside TRACE: a copy was kept before rewriting");
kept = Some(copy);
}
}
paths::write_atomic(path, &markdown::serialize(&meeting))
paths::write_atomic(path, &markdown::serialize(&meeting))?;
Ok(kept)
}

/// Write a meeting to its canonical Markdown file.
Expand Down Expand Up @@ -397,6 +413,48 @@ pub fn rename_note(
mod tests {
use super::*;

#[test]
fn rewriting_an_untouched_note_keeps_no_copy() {
let root = scratch("rewrite-untouched");
let meeting = a_meeting("m1", "Standup");
let written = write_note(&root, &meeting).unwrap();
assert_eq!(rewrite_note(&written, &meeting).unwrap(), None);
assert_eq!(
std::fs::read_dir(written.parent().unwrap())
.unwrap()
.count(),
1
);
}

#[test]
fn rewriting_an_edited_note_keeps_the_edits_in_a_copy() {
let root = scratch("rewrite-edited");
let meeting = a_meeting("m1", "Standup");
let written = write_note(&root, &meeting).unwrap();
let edited = std::fs::read_to_string(&written).unwrap() + "\nMy own words.\n";
std::fs::write(&written, &edited).unwrap();

let copy = rewrite_note(&written, &meeting).unwrap().expect("a copy");
assert_eq!(std::fs::read_to_string(&copy).unwrap(), edited);
assert!(copy.to_string_lossy().ends_with(".md.bak"));
assert!(!std::fs::read_to_string(&written)
.unwrap()
.contains("My own words"));

// The rewritten note is TRACE's again, so the next rewrite keeps none.
assert_eq!(rewrite_note(&written, &meeting).unwrap(), None);
}

#[test]
fn rewriting_a_note_from_before_the_hash_keeps_a_copy() {
let root = scratch("rewrite-legacy");
let path = root.join("old.md");
std::fs::write(&path, "---\nid: m1\ntitle: Old\n---\n\n# Old\n\nEdited?\n").unwrap();
let meeting = a_meeting("m1", "Old");
assert!(rewrite_note(&path, &meeting).unwrap().is_some());
}

/// A scratch notes root. Matches the convention in `audio::wav`'s tests
/// rather than adding a dependency for four tests.
fn scratch(name: &str) -> PathBuf {
Expand Down
Loading
Loading