Skip to main content

blockworx/store/
notes.rs

1//! Authored narration: what a log's `note` records say, and at which revs
2//! (D17). A projection of the log, rebuilt on every open, exactly as
3//! [`Tags`](super::tags::Tags) is — the records are the truth and this is
4//! the map they fold to.
5//!
6//! Where the two diverge is the rev. A tag *names* one; a note *occupies*
7//! one, as the empty commit it is, which is what buys it undo for free:
8//! taking back a note is taking back a commit, so a note is shown only
9//! while the step that wrote it still stands.
10//!
11//! Which step that is has to be followed rather than looked up. An undo is
12//! a forward commit with a rev of its own, and the journal's redo stack
13//! holds *it*, not the rev it took back — so
14//! [`Repo::redo_revs`](blockworx_doc::repo::Repo::redo_revs) can never
15//! answer "is this note undone". What can is the record sequence itself:
16//! each undo and redo names the step it moved, so a note is followed along
17//! the chain of stand-ins from the record that wrote it to whichever step
18//! is holding its place now.
19
20use std::collections::BTreeMap;
21
22use blockworx_doc::rev::Rev;
23
24use super::record::{Anchor, LogRecord, NoteDisplay, RecordKind};
25
26/// One authored note, as its record wrote it.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct Note {
29    pub text: String,
30    pub anchor: Option<Anchor>,
31    pub show: NoteDisplay,
32}
33
34/// Whether a note currently stands. Not a bool: a note that has been taken
35/// back is still in the log, so "gone" is never the answer and the two
36/// states are named rather than counted.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38enum Standing {
39    Shown,
40    Hidden,
41}
42
43/// A note and where it stands.
44#[derive(Clone, Debug, PartialEq, Eq)]
45struct Held {
46    note: Note,
47    standing: Standing,
48}
49
50/// What a log's notes say, keyed by the rev each one was written at.
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
52pub struct Notes {
53    notes: BTreeMap<Rev, Held>,
54    /// Which note each history step is about: the note's own rev to begin
55    /// with, then the undo that took it back, then the redo that brought it
56    /// back, each naming the last. Only steps that concern a note are held,
57    /// so a document with no narration in it carries nothing.
58    stands_for: BTreeMap<Rev, Rev>,
59}
60
61impl Notes {
62    /// Take `record` into the projection. Every record is offered, because
63    /// an undo or a redo of a note is as much a part of what the notes say
64    /// as the note itself; the kinds that concern no note fall through.
65    pub fn take(&mut self, record: &LogRecord) {
66        match record.kind {
67            RecordKind::Note { anchor, show } => {
68                self.written(record.rev, &record.label, anchor, show);
69            }
70            RecordKind::Undo { of } => self.undone(record.rev, of),
71            RecordKind::Redo { of } => self.redone(record.rev, of),
72            RecordKind::Edit | RecordKind::Tag => {}
73        }
74    }
75
76    /// A note written at `at`. The three steps below are what a session
77    /// with no records behind it calls instead of [`Self::take`]: a scratch
78    /// note still occupies a rev in the in-process repo, and is still taken
79    /// back by taking back that rev.
80    pub fn written(&mut self, at: Rev, text: &str, anchor: Option<Anchor>, show: NoteDisplay) {
81        self.notes.insert(
82            at,
83            Held {
84                note: Note {
85                    text: text.to_owned(),
86                    anchor,
87                    show,
88                },
89                standing: Standing::Shown,
90            },
91        );
92        self.stands_for.insert(at, at);
93    }
94
95    /// The step at `at` took back the step at `of`.
96    pub fn undone(&mut self, at: Rev, of: Rev) {
97        self.moved(at, of, Standing::Hidden);
98    }
99
100    /// The step at `at` re-applied the step at `of`.
101    pub fn redone(&mut self, at: Rev, of: Rev) {
102        self.moved(at, of, Standing::Shown);
103    }
104
105    fn moved(&mut self, at: Rev, of: Rev, standing: Standing) {
106        let Some(note) = self.stands_for.get(&of).copied() else {
107            return;
108        };
109        if let Some(held) = self.notes.get_mut(&note) {
110            held.standing = standing;
111        }
112        self.stands_for.insert(at, note);
113    }
114
115    /// The note written at `at`, shown or not — the record, which the log
116    /// holds however often it is taken back.
117    pub fn get(&self, at: Rev) -> Option<&Note> {
118        self.notes.get(&at).map(|held| &held.note)
119    }
120
121    /// Whether the note written at `at` currently stands.
122    pub fn is_shown(&self, at: Rev) -> bool {
123        self.notes
124            .get(&at)
125            .is_some_and(|held| held.standing == Standing::Shown)
126    }
127
128    /// The note shown at `at`, or `None` where there is none or it has been
129    /// taken back.
130    pub fn shown_at(&self, at: Rev) -> Option<&Note> {
131        let held = self.notes.get(&at)?;
132        (held.standing == Standing::Shown).then_some(&held.note)
133    }
134
135    /// Every note the log holds, oldest first, shown or not.
136    pub fn iter(&self) -> impl Iterator<Item = (Rev, &Note)> {
137        self.notes.iter().map(|(rev, held)| (*rev, &held.note))
138    }
139
140    /// The notes a reader would see, oldest first.
141    pub fn shown(&self) -> impl Iterator<Item = (Rev, &Note)> {
142        self.notes
143            .iter()
144            .filter(|(_, held)| held.standing == Standing::Shown)
145            .map(|(rev, held)| (*rev, &held.note))
146    }
147
148    pub fn len(&self) -> usize {
149        self.notes.len()
150    }
151
152    pub fn is_empty(&self) -> bool {
153        self.notes.is_empty()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use blockworx_doc::fixtures::rev;
161
162    fn narration(text: &str) -> Note {
163        Note {
164            text: text.to_owned(),
165            anchor: None,
166            show: NoteDisplay::Narration,
167        }
168    }
169
170    fn notes() -> Notes {
171        let mut notes = Notes::default();
172        notes.written(rev(1), "Earlier", None, NoteDisplay::Narration);
173        notes.written(rev(2), "Later", None, NoteDisplay::Narration);
174        notes
175    }
176
177    #[test]
178    fn the_last_note_for_a_rev_wins() {
179        let mut notes = Notes::default();
180        notes.written(rev(2), "First", None, NoteDisplay::Narration);
181        notes.written(rev(2), "Second", None, NoteDisplay::Chapter);
182        assert_eq!(notes.len(), 1);
183        assert_eq!(
184            notes.get(rev(2)).map(|note| note.show),
185            Some(NoteDisplay::Chapter),
186        );
187    }
188
189    #[test]
190    fn notes_read_back_oldest_first() {
191        assert_eq!(
192            notes()
193                .iter()
194                .map(|(rev, note)| (rev, note.text.clone()))
195                .collect::<Vec<_>>(),
196            [(rev(1), "Earlier".to_owned()), (rev(2), "Later".to_owned()),],
197        );
198    }
199
200    /// D17's undo rule, over the step revs the journal really mints: the
201    /// undo of the note at r2 is a commit of its own at r3, and the redo of
202    /// *that* is another at r4 — so the note is followed through its
203    /// stand-ins rather than looked for on a stack.
204    #[test]
205    fn an_undo_hides_the_note_it_names_and_a_redo_of_the_undo_brings_it_back() {
206        let mut notes = notes();
207        assert_eq!(notes.shown().count(), 2, "precondition: both stand");
208
209        notes.undone(rev(3), rev(2));
210        assert_eq!(notes.shown_at(rev(2)), None);
211        assert_eq!(notes.shown_at(rev(1)), Some(&narration("Earlier")));
212        assert_eq!(
213            notes.get(rev(2)),
214            Some(&narration("Later")),
215            "the record is still in the log — it is the showing that stopped",
216        );
217
218        notes.redone(rev(4), rev(3));
219        assert_eq!(notes.shown_at(rev(2)), Some(&narration("Later")));
220
221        notes.undone(rev(5), rev(4));
222        assert_eq!(notes.shown_at(rev(2)), None, "and back again, any depth");
223    }
224
225    /// A history that forks away from an undone note leaves it undone:
226    /// nothing names it again, so nothing shows it again. The abandonment
227    /// the journal's redo stack loses is not a fact this has to be told.
228    #[test]
229    fn a_note_the_history_forked_away_from_stays_hidden() {
230        let mut notes = notes();
231        notes.undone(rev(3), rev(2));
232        // The fork: an ordinary edit at r4, which abandons the redo the
233        // journal was holding. It concerns no note.
234        notes.undone(rev(5), rev(4));
235        assert_eq!(notes.shown_at(rev(2)), None);
236        assert_eq!(notes.shown().count(), 1);
237    }
238
239    /// A step that stands in for no note moves nothing — which is what lets
240    /// every record be offered without the caller sorting them first.
241    #[test]
242    fn an_undo_of_an_ordinary_edit_touches_no_note() {
243        let mut notes = notes();
244        notes.undone(rev(9), rev(8));
245        assert_eq!(notes.shown().count(), 2);
246        assert!(!notes.stands_for.contains_key(&rev(9)));
247    }
248}