1use blockworx_doc::{commit::Commit, rev::Rev};
13
14use super::manifest::{self, RowKind};
15use super::record::WallTime;
16use super::tags::Tags;
17
18pub fn now() -> WallTime {
23 #[cfg(not(target_arch = "wasm32"))]
24 {
25 WallTime::from_unix_millis(
26 std::time::SystemTime::UNIX_EPOCH
27 .elapsed()
28 .unwrap_or_default()
29 .as_millis() as u64,
30 )
31 }
32 #[cfg(target_arch = "wasm32")]
33 {
34 WallTime::EPOCH
35 }
36}
37
38#[derive(Clone, Copy)]
42pub enum Journal<'a> {
43 Recorded(&'a [manifest::Row]),
44 Session(&'a [Commit]),
46}
47
48pub struct Row<'a> {
51 pub rev: Rev,
52 pub label: &'a str,
53 pub touched: usize,
57 pub written: Option<&'a manifest::Row>,
59 pub tags: &'a [String],
62}
63
64#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
67pub enum Kind {
68 Edit,
69 Undo,
70 Redo,
71}
72
73impl Kind {
74 pub fn label(self) -> &'static str {
75 match self {
76 Kind::Edit => "edit",
77 Kind::Undo => "undo",
78 Kind::Redo => "redo",
79 }
80 }
81}
82
83#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
88pub struct Day(jiff::civil::Date);
89
90impl Day {
91 fn of(when: WallTime, zone: &jiff::tz::TimeZone) -> Option<Self> {
92 jiff::Timestamp::from_millisecond(when.unix_millis() as i64)
93 .ok()
94 .map(|instant| Day(instant.to_zoned(zone.clone()).date()))
95 }
96
97 pub fn label(self, now: WallTime) -> String {
100 self.label_in(now, &jiff::tz::TimeZone::system())
101 }
102
103 fn label_in(self, now: WallTime, zone: &jiff::tz::TimeZone) -> String {
104 match Day::of(now, zone) {
105 Some(today) if today == self => "Today".to_owned(),
106 Some(today) if today.0.yesterday().ok() == Some(self.0) => "Yesterday".to_owned(),
107 _ => self.to_string(),
108 }
109 }
110}
111
112impl std::fmt::Display for Day {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 write!(f, "{}", self.0.strftime("%Y-%m-%d"))
115 }
116}
117
118impl<'a> Row<'a> {
119 pub fn kind_of(&self) -> Kind {
123 match self.written.map(|row| row.kind) {
124 None | Some(RowKind::Edit | RowKind::Tag | RowKind::Untag) => Kind::Edit,
127 Some(RowKind::Undo { .. }) => Kind::Undo,
128 Some(RowKind::Redo { .. }) => Kind::Redo,
129 }
130 }
131
132 pub fn is_inverse(&self) -> bool {
137 match self.written {
138 Some(_) => self.kind_of() == Kind::Undo,
139 None => self.label.starts_with("Undo "),
140 }
141 }
142
143 pub fn undone(&self) -> Option<Rev> {
146 match self.written.map(|row| row.kind) {
147 Some(RowKind::Undo { of }) => Some(of),
148 _ => None,
149 }
150 }
151
152 pub fn kind(&self) -> String {
154 match self.written.map(|row| row.kind) {
155 None | Some(RowKind::Edit | RowKind::Tag | RowKind::Untag) => {
156 Kind::Edit.label().to_owned()
157 }
158 Some(RowKind::Undo { of }) => format!("{} of r{}", Kind::Undo.label(), of.get()),
159 Some(RowKind::Redo { of }) => format!("{} of r{}", Kind::Redo.label(), of.get()),
160 }
161 }
162
163 pub fn scope_names(&self) -> &'a [String] {
167 self.written.map_or(&[], |row| row.scope_names.as_slice())
168 }
169
170 pub fn initials(&self) -> String {
173 self.author()
174 .split_whitespace()
175 .filter_map(|word| word.chars().next())
176 .take(2)
177 .flat_map(char::to_uppercase)
178 .collect()
179 }
180
181 pub fn time(&self) -> String {
185 self.time_in(&jiff::tz::TimeZone::system())
186 }
187
188 fn time_in(&self, zone: &jiff::tz::TimeZone) -> String {
189 self.written.map_or_else(String::new, |row| {
190 written_as(row.wall_time, zone, "%-I:%M %p")
191 })
192 }
193
194 pub fn since(&self, now: WallTime) -> String {
200 self.written
201 .and_then(|row| {
202 now.unix_millis()
203 .checked_sub(row.wall_time.unix_millis())
204 .map(|elapsed| humanize(std::time::Duration::from_millis(elapsed)))
205 })
206 .unwrap_or_default()
207 }
208
209 pub fn full_when(&self) -> String {
212 self.full_when_in(&jiff::tz::TimeZone::system())
213 }
214
215 fn full_when_in(&self, zone: &jiff::tz::TimeZone) -> String {
216 self.written.map_or_else(String::new, |row| {
217 written_as(row.wall_time, zone, "%a %-d %b %Y, %-I:%M %p")
218 })
219 }
220
221 pub fn day(&self) -> Option<Day> {
224 self.day_in(&jiff::tz::TimeZone::system())
225 }
226
227 fn day_in(&self, zone: &jiff::tz::TimeZone) -> Option<Day> {
228 self.written.and_then(|row| Day::of(row.wall_time, zone))
229 }
230
231 pub fn author(&self) -> &str {
232 self.written.map_or("", |row| row.author.name.as_str())
233 }
234
235 pub fn when(&self) -> String {
238 self.when_in(&jiff::tz::TimeZone::system())
239 }
240
241 fn when_in(&self, zone: &jiff::tz::TimeZone) -> String {
244 self.written
245 .map_or_else(String::new, |row| stamp(row.wall_time, zone))
246 }
247}
248
249#[derive(Clone, Copy, PartialEq, Eq, Debug)]
253enum Field {
254 Anywhere,
256 Tag,
257 Author,
258 Scope,
259 Rev,
261}
262
263impl Field {
264 const PREFIXES: [(&'static str, Field); 4] = [
267 ("tag:", Field::Tag),
268 ("by:", Field::Author),
269 ("in:", Field::Scope),
270 ("#", Field::Rev),
271 ];
272}
273
274#[derive(Clone, PartialEq, Eq, Debug)]
276struct Term {
277 field: Field,
278 text: String,
279}
280
281impl Term {
282 fn of(word: &str) -> Self {
283 let lowered = word.to_lowercase();
284 for (prefix, field) in Field::PREFIXES {
285 if let Some(rest) = lowered.strip_prefix(prefix) {
286 return Term {
287 field,
288 text: rest.to_owned(),
289 };
290 }
291 }
292 Term {
293 field: Field::Anywhere,
294 text: lowered,
295 }
296 }
297
298 fn admits(&self, row: &Row<'_>) -> bool {
299 if self.text.is_empty() {
302 return true;
303 }
304 let has = |text: &str| text.to_lowercase().contains(&self.text);
305 let tagged = || row.tags.iter().any(|tag| has(tag));
306 let in_scope = || row.scope_names().iter().any(|name| has(name));
307 let is_rev = || row.rev.get().to_string().starts_with(&self.text);
308 match self.field {
309 Field::Tag => tagged(),
310 Field::Author => has(row.author()),
311 Field::Scope => in_scope(),
312 Field::Rev => is_rev(),
313 Field::Anywhere => {
314 has(row.label) || in_scope() || tagged() || has(row.author()) || is_rev()
315 }
316 }
317 }
318}
319
320#[derive(Clone, Default, PartialEq, Eq, Debug)]
326pub struct Query {
327 terms: Vec<Term>,
328}
329
330impl Query {
331 pub fn parse(text: &str) -> Self {
332 Query {
333 terms: text.split_whitespace().map(Term::of).collect(),
334 }
335 }
336
337 pub fn admits(&self, row: &Row<'_>) -> bool {
340 self.terms.iter().all(|term| term.admits(row))
341 }
342
343 pub fn narrows(&self) -> bool {
346 !self.terms.is_empty()
347 }
348}
349
350pub fn tag_query(name: &str) -> String {
353 format!("tag:{name}")
354}
355
356pub fn humanize(elapsed: std::time::Duration) -> String {
360 timeago::Formatter::new().convert(elapsed)
361}
362
363pub fn rows<'a>(journal: Journal<'a>, tags: &'a Tags) -> Vec<Row<'a>> {
366 let at = |ndx: usize| Rev::ZERO.forward(ndx as u64 + 1);
367 match journal {
368 Journal::Recorded(written) => written
369 .iter()
370 .map(|row| Row {
371 rev: row.rev,
372 label: row.label.as_str(),
373 touched: row.touched.len(),
374 written: Some(row),
375 tags: tags.of(row.rev),
376 })
377 .collect(),
378 Journal::Session(commits) => commits
379 .iter()
380 .enumerate()
381 .map(|(ndx, commit)| Row {
382 rev: at(ndx),
383 label: commit.label(),
384 touched: commit.ops().len(),
385 written: None,
386 tags: tags.of(at(ndx)),
387 })
388 .collect(),
389 }
390}
391
392pub fn lines(rows: &[Row<'_>]) -> Vec<String> {
396 lines_in(rows, &jiff::tz::TimeZone::system())
397}
398
399fn lines_in(rows: &[Row<'_>], zone: &jiff::tz::TimeZone) -> Vec<String> {
402 let kinds: Vec<String> = rows.iter().map(Row::kind).collect();
403 let width = |values: &mut dyn Iterator<Item = usize>| values.max().unwrap_or(0);
404 let rev_w = width(&mut rows.iter().map(|row| digits(row.rev)));
405 let author_w = width(&mut rows.iter().map(|row| row.author().chars().count()));
406 let kind_w = width(&mut kinds.iter().map(|kind| kind.chars().count()));
407 let ops_w = width(&mut rows.iter().map(|row| digits_of(row.touched)));
408 let tag_w = width(&mut rows.iter().map(|row| tag_cell(row.tags).chars().count()));
410
411 rows.iter()
412 .zip(&kinds)
413 .map(|(row, kind)| {
414 let rev = format!("r{:<rev_w$}", row.rev.get());
415 let ops = format!("{:>ops_w$} {}", row.touched, plural(row.touched));
416 let tag = match tag_w {
417 0 => String::new(),
418 _ => format!("{:<tag_w$} ", tag_cell(row.tags)),
419 };
420 let attribution = match row.written {
423 None => String::new(),
424 Some(_) => format!("{} {:<author_w$} ", row.when_in(zone), row.author()),
425 };
426 format!(
427 "{rev} {attribution}{kind:<kind_w$} {ops} {tag}{label}",
428 label = row.label
429 )
430 })
431 .collect()
432}
433
434fn tag_cell(tags: &[String]) -> String {
435 if tags.is_empty() {
436 return String::new();
437 }
438 format!("[{}]", tags.join(" "))
439}
440
441fn plural(touched: usize) -> &'static str {
442 if touched == 1 { "name " } else { "names" }
443}
444
445fn digits(rev: Rev) -> usize {
446 digits_of(rev.get() as usize)
447}
448
449fn digits_of(n: usize) -> usize {
450 n.checked_ilog10().unwrap_or(0) as usize + 1
451}
452
453fn stamp(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
457 written_as(when, zone, "%Y-%m-%d %H:%M:%S")
458}
459
460pub fn date(when: WallTime) -> String {
464 date_in(when, &jiff::tz::TimeZone::system())
465}
466
467pub fn written_at(when: WallTime) -> String {
472 written_at_in(when, &jiff::tz::TimeZone::system())
473}
474
475fn written_at_in(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
476 written_as(when, zone, "%Y-%m-%d %H:%M")
477}
478
479fn date_in(when: WallTime, zone: &jiff::tz::TimeZone) -> String {
482 written_as(when, zone, "%Y-%m-%d")
483}
484
485fn written_as(when: WallTime, zone: &jiff::tz::TimeZone, format: &str) -> String {
486 let Ok(instant) = jiff::Timestamp::from_millisecond(when.unix_millis() as i64) else {
487 return String::new();
488 };
489 instant.to_zoned(zone.clone()).strftime(format).to_string()
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::store::manifest::RowKind;
496 use crate::store::record::{Camera, Digest, Identity, ScopePath};
497 use crate::store::tests::fixture;
498 use blockworx_doc::fixtures::rev;
499
500 fn written(n: u64, kind: RowKind, name: &str) -> manifest::Row {
501 manifest::Row {
502 rev: rev(n),
503 kind,
504 wall_time: WallTime::from_unix_millis(1_756_000_000_000 + n * 1_000),
505 author: Identity::new(name),
506 label: "Added a block".to_owned(),
507 scope: ScopePath::default(),
508 scope_names: Vec::new(),
509 camera: Camera::UNSEEN,
510 touched: vec![blockworx_doc::id::EntityRef::Block(
511 blockworx_doc::fixtures::block_id(1),
512 )],
513 truncated: false,
514 hash: Digest::of(b"a rev file"),
515 parent: Digest::genesis(),
516 }
517 }
518
519 #[test]
523 fn a_session_with_no_rows_still_lists_its_commits() {
524 let commits = fixture::edits(2);
525 let none = Tags::default();
526 let rows = rows(Journal::Session(&commits), &none);
527 assert_eq!(rows.len(), 2);
528 assert_eq!(rows[1].rev, rev(2));
529 assert_eq!(rows[1].label, "Added a block");
530 assert_eq!(rows[1].touched, 1);
531 assert_eq!(rows[0].author(), "");
532 assert_eq!(rows[0].when(), "");
533 assert_eq!(rows[0].kind(), "edit", "a commit with no row is an edit");
534 }
535
536 #[test]
537 fn a_row_kind_names_the_rev_it_took_back() {
538 let recorded = [
539 written(1, RowKind::Edit, "ada"),
540 written(2, RowKind::Undo { of: rev(1) }, "ada"),
541 ];
542 let none = Tags::default();
543 let rows = rows(Journal::Recorded(&recorded), &none);
544 assert_eq!(rows[0].kind(), "edit");
545 assert_eq!(rows[1].kind(), "undo of r1");
546 assert_eq!(rows[1].undone(), Some(rev(1)));
547 assert_eq!(rows[1].author(), "ada");
548 }
549
550 #[test]
554 fn a_row_reports_its_age_in_words_and_declines_to_report_a_future_one() {
555 let recorded = [written(1, RowKind::Edit, "ada")];
556 let none = Tags::default();
557 let rows = rows(Journal::Recorded(&recorded), &none);
558 let at = recorded[0].wall_time.unix_millis();
559
560 let three_hours_on = WallTime::from_unix_millis(at + 3 * 60 * 60 * 1_000);
561 assert_eq!(rows[0].since(three_hours_on), "3 hours ago");
562 assert!(
563 !rows[0].when().is_empty(),
564 "the absolute stamp is still there for the tooltip",
565 );
566 assert_eq!(
567 rows[0].since(WallTime::from_unix_millis(at - 1)),
568 "",
569 "a row stamped after `now` has no age to report",
570 );
571 }
572
573 #[test]
574 fn a_scratch_row_has_no_age_because_it_carries_no_clock() {
575 let commits = fixture::edits(1);
576 let none = Tags::default();
577 assert_eq!(rows(Journal::Session(&commits), &none)[0].since(now()), "");
578 }
579
580 #[test]
583 fn a_tagged_rev_carries_its_name_into_the_dump() {
584 let commits = fixture::edits(2);
585 let mut tags = Tags::default();
586 tags.add(rev(2), "Initial Draft");
587 tags.add(rev(2), "vendor");
588 let rows = rows(Journal::Session(&commits), &tags);
589 assert_eq!(rows[1].tags, ["Initial Draft", "vendor"]);
590 assert!(rows[0].tags.is_empty());
591
592 let tagged = lines(&rows);
593 assert!(
594 tagged[1].contains("[Initial Draft vendor]"),
595 "a rev's whole set does not reach the dump: {}",
596 tagged[1],
597 );
598 let none = Tags::default();
599 let untagged = lines(&super::rows(Journal::Session(&commits), &none));
600 assert!(
601 !untagged[0].contains('['),
602 "a history with no tags grows no tag column: {}",
603 untagged[0],
604 );
605 }
606
607 #[cfg(not(target_arch = "wasm32"))]
611 #[test]
612 fn a_container_dumps_its_trail_line_by_line() {
613 use crate::store::handle::{Clock, Store};
614
615 let dir = fixture::dir("log-dump");
616 let root = dir.join("doc.bwx");
617 let author = Identity::new("ada");
618 let mut store = Store::create(
619 &root,
620 Clock::Pinned {
621 at: WallTime::from_unix_millis(1_756_000_000_000),
622 step: std::time::Duration::from_mins(1),
623 },
624 )
625 .expect("the container");
626 let edit = store
627 .submit_edit(
628 fixture::commit(
629 "Added a block",
630 vec![
631 fixture::block_create(1, "Adder"),
632 fixture::block_create(2, "Summer"),
633 ],
634 ),
635 &author,
636 )
637 .expect("the edit lands");
638 store
639 .undo(edit, &author)
640 .expect("and is taken back, into the trail");
641
642 let dumped = lines_in(
643 &rows(Journal::Recorded(store.rows()), store.tags()),
644 &jiff::tz::TimeZone::UTC,
645 );
646 assert_eq!(
647 dumped,
648 [
649 "r1 2025-08-24 01:47:40 ada edit 2 names Added a block",
650 "r2 2025-08-24 01:48:40 ada undo of r1 2 names Undo Added a block",
651 ],
652 "an undo frames what it moved: the same names its edit did",
653 );
654 }
655
656 #[test]
660 fn a_revs_date_is_the_day_its_row_was_written() {
661 let at = WallTime::from_unix_millis(1_756_000_060_000);
662 assert_eq!(date_in(at, &jiff::tz::TimeZone::UTC), "2025-08-24");
663 assert_eq!(
664 date_in(at, &jiff::tz::TimeZone::UTC),
665 stamp(at, &jiff::tz::TimeZone::UTC)
666 .split(' ')
667 .next()
668 .expect("the audit stamp leads with its date"),
669 "the two spellings of the same instant disagree about the day",
670 );
671 }
672
673 #[test]
677 fn a_row_groups_under_the_day_it_was_written() {
678 let utc = jiff::tz::TimeZone::UTC;
679 let recorded = [
680 written(1, RowKind::Edit, "ada"),
681 manifest::Row {
682 wall_time: WallTime::from_unix_millis(1_756_000_000_000 - 2 * 86_400_000),
683 ..written(2, RowKind::Edit, "ada")
684 },
685 ];
686 let none = Tags::default();
687 let rows = rows(Journal::Recorded(&recorded), &none);
688 let (recent, older) = (
689 rows[0].day_in(&utc).expect("a row carries a day"),
690 rows[1].day_in(&utc).expect("a row carries a day"),
691 );
692 assert_ne!(recent, older, "two days apart grouped together");
693 assert_eq!(recent.to_string(), "2025-08-24");
694 assert_eq!(older.to_string(), "2025-08-22");
695
696 let now = WallTime::from_unix_millis(1_756_000_000_000);
697 assert_eq!(recent.label_in(now, &utc), "Today");
698 assert_eq!(older.label_in(now, &utc), "2025-08-22");
699 let tomorrow = WallTime::from_unix_millis(1_756_000_000_000 + 86_400_000);
700 assert_eq!(recent.label_in(tomorrow, &utc), "Yesterday");
701
702 let commits = fixture::edits(1);
703 assert_eq!(
704 super::rows(Journal::Session(&commits), &none)[0].day_in(&utc),
705 None,
706 "a session with no clock has no day to group under",
707 );
708 }
709
710 #[test]
713 fn the_kind_facet_is_coarse_and_marks_inverse_revs() {
714 let recorded = [
715 written(1, RowKind::Edit, "ada"),
716 written(2, RowKind::Undo { of: rev(1) }, "ada"),
717 ];
718 let none = Tags::default();
719 let rows = rows(Journal::Recorded(&recorded), &none);
720 assert_eq!(rows[0].kind_of(), Kind::Edit);
721 assert_eq!(rows[1].kind_of(), Kind::Undo);
722 assert_eq!(rows[1].kind(), "undo of r1", "the column still names it");
723 assert!(!rows[0].is_inverse());
724 assert!(rows[1].is_inverse());
725
726 let commits = fixture::edits(2);
727 let unrecorded = super::rows(Journal::Session(&commits), &none);
728 assert_eq!(
729 unrecorded[1].label, "Added a block",
730 "precondition: the fixture's commits are plain edits",
731 );
732 assert!(
733 !unrecorded[1].is_inverse(),
734 "a session with no rows reads its own labels",
735 );
736 }
737
738 #[test]
741 fn a_prefix_asks_one_column_and_a_bare_word_asks_them_all() {
742 let mut recorded = [written(1, RowKind::Edit, "ada")];
743 recorded[0].label = "Base plate 78 to 84 mm".to_owned();
744 recorded[0].scope_names = vec!["engine".to_owned(), "base plate".to_owned()];
745 let mut tags = Tags::default();
746 tags.add(rev(1), "vendor");
747 let rows = rows(Journal::Recorded(&recorded), &tags);
748 let admits = |query: &str| Query::parse(query).admits(&rows[0]);
749
750 assert!(admits("plate"), "a bare word reaches the description");
751 assert!(admits("ada"), "a bare word reaches the author");
752 assert!(admits("vendor"), "a bare word reaches the tags");
753 assert!(
754 admits("78"),
755 "§6.1's deltas are what makes numbers searchable"
756 );
757
758 assert!(admits("by:ada") && !admits("by:grace"));
759 assert!(admits("tag:vendor") && !admits("tag:plate"));
760 assert!(
761 admits("in:engine") && !admits("in:vendor"),
762 "`in:` reached outside the scope",
763 );
764 assert!(
765 admits("#1") && !admits("#2"),
766 "`#` matches a rev number as a prefix of it",
767 );
768 assert!(
769 !admits("tag:plate"),
770 "a prefixed term matched a column it did not name",
771 );
772 }
773
774 #[test]
777 fn every_word_must_match_and_a_bare_prefix_narrows_no_further() {
778 let mut recorded = [written(1, RowKind::Edit, "ada")];
779 recorded[0].label = "Base plate".to_owned();
780 let none = Tags::default();
781 let rows = rows(Journal::Recorded(&recorded), &none);
782 let admits = |query: &str| Query::parse(query).admits(&rows[0]);
783
784 assert!(admits("base plate"));
785 assert!(!admits("base rib"), "a second word widened the search");
786 assert!(
787 admits("by:"),
788 "a prefix still being typed narrows to the column, not to nothing",
789 );
790 assert!(!Query::parse(" ").narrows(), "blank text narrows nothing");
791 }
792
793 #[test]
796 fn columns_line_up_across_rows() {
797 let recorded: Vec<manifest::Row> = (1..=10)
798 .map(|n| {
799 written(
800 n,
801 RowKind::Edit,
802 if n == 3 { "ada lovelace" } else { "bob" },
803 )
804 })
805 .collect();
806 let none = Tags::default();
807 let lines = lines(&rows(Journal::Recorded(&recorded), &none));
808 assert_eq!(lines.len(), 10);
809 assert!(lines[9].starts_with("r10 "), "{}", lines[9]);
810 let label_at = |line: &String| line.find("Added").expect("every line names its label");
811 let first = label_at(&lines[0]);
812 for line in &lines {
813 assert_eq!(label_at(line), first, "a column drifted: {line}");
814 }
815 }
816}