use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Instant;
use ropey::Rope;
use crate::normalize;
pub fn prose_words_in_line(line: &str) -> usize {
if normalize::is_note_line(line) {
return 0;
}
let stripped = normalize::strip_markers(line);
word_count_str(&stripped)
}
fn word_count_str(s: &str) -> usize {
let mut count = 0usize;
let mut in_word = false;
for c in s.chars() {
let w = c.is_alphanumeric();
if w && !in_word {
count += 1;
}
in_word = w;
}
count
}
fn prose_chars_in_line(line: &str) -> usize {
if normalize::is_note_line(line) {
return 0;
}
let stripped = normalize::strip_markers(line);
stripped.chars().filter(|c| !c.is_control()).count()
}
#[derive(Debug, Clone)]
pub struct DocStats {
pub words: usize,
pub chars: usize,
line_words: Vec<usize>,
line_chars: Vec<usize>,
generation: u64,
last_full_gen: u64,
}
impl DocStats {
pub fn from_rope(rope: &Rope) -> Self {
let n = rope.len_lines();
let mut words = 0usize;
let mut chars = 0usize;
let mut line_words = Vec::with_capacity(n);
let mut line_chars = Vec::with_capacity(n);
for i in 0..n {
let text = rope.line(i).to_string();
let lw = prose_words_in_line(&text);
let lc = prose_chars_in_line(&text);
words += lw;
chars += lc;
line_words.push(lw);
line_chars.push(lc);
}
DocStats {
words,
chars,
line_words,
line_chars,
generation: 0,
last_full_gen: 0,
}
}
pub fn invalidate_lines(&mut self, rope: &Rope, from_line: usize, to_line: usize) {
self.generation += 1;
let n = rope.len_lines();
if n != self.line_words.len() {
self.line_words.resize(n, 0);
self.line_chars.resize(n, 0);
}
let end = to_line.min(n);
for i in from_line..end {
let text = rope.line(i).to_string();
let new_w = prose_words_in_line(&text);
let new_c = prose_chars_in_line(&text);
let old_w = self.line_words.get(i).copied().unwrap_or(0);
let old_c = self.line_chars.get(i).copied().unwrap_or(0);
self.words = self.words.wrapping_add(new_w).wrapping_sub(old_w);
self.chars = self.chars.wrapping_add(new_c).wrapping_sub(old_c);
self.line_words[i] = new_w;
self.line_chars[i] = new_c;
}
}
pub fn full_recount(&mut self, rope: &Rope) -> bool {
if self.generation == self.last_full_gen {
return false;
}
let fresh = Self::from_rope(rope);
let changed = fresh.words != self.words || fresh.chars != self.chars;
*self = DocStats {
last_full_gen: fresh.generation,
..fresh
};
changed
}
pub fn needs_recount(&self) -> bool {
self.generation != self.last_full_gen
}
}
pub fn count_slice(rope: &Rope, from: usize, to: usize) -> (usize, usize) {
let from_line = rope.char_to_line(from);
let to_line = rope.char_to_line(to.min(rope.len_chars().saturating_sub(1)));
let mut words = 0usize;
let mut chars = 0usize;
for i in from_line..=to_line {
let line_start = rope.line_to_char(i);
let line_text: String = rope.line(i).to_string();
let sel_start = from.saturating_sub(line_start);
let sel_end = (to - line_start).min(line_text.chars().count());
if sel_start >= sel_end {
continue;
}
let slice: String = line_text
.chars()
.skip(sel_start)
.take(sel_end - sel_start)
.collect();
if normalize::is_note_line(&line_text) {
continue;
}
words += word_count_str(&slice);
chars += slice.chars().filter(|c| !c.is_control()).count();
}
(words, chars)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalKind {
Words,
#[allow(dead_code)] Minutes,
}
#[derive(Debug, Clone)]
pub struct SessionGoal {
pub kind: GoalKind,
pub target: usize,
pub start_words: usize,
pub started: Instant,
pub reached: bool,
}
impl SessionGoal {
pub fn new(kind: GoalKind, target: usize, current_words: usize) -> Self {
SessionGoal {
kind,
target,
start_words: current_words,
started: Instant::now(),
reached: false,
}
}
pub fn progress(&self, current_words: usize) -> (usize, usize) {
match self.kind {
GoalKind::Words => {
let written = current_words.saturating_sub(self.start_words);
(written, self.target)
}
GoalKind::Minutes => {
let elapsed = self.started.elapsed().as_secs() / 60;
(elapsed as usize, self.target)
}
}
}
pub fn is_met(&self, current_words: usize) -> bool {
let (current, target) = self.progress(current_words);
current >= target
}
}
#[derive(Debug, Clone, Default)]
pub struct DailyHistory {
pub entries: BTreeMap<String, i64>,
path: Option<PathBuf>,
}
impl DailyHistory {
pub fn load(file_path: Option<&Path>) -> Self {
let path = match (crate::paths::stats(), file_path) {
(Some(dir), Some(fp)) => {
let key = crate::paths::path_key(fp);
Some(dir.join(format!("{key}.json")))
}
_ => None,
};
let entries = path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
DailyHistory { entries, path }
}
pub fn record_delta(&mut self, delta: i64) {
if delta == 0 {
return;
}
let today = today_key();
let entry = self.entries.entry(today).or_insert(0);
*entry += delta;
}
pub fn save(&self) -> io::Result<()> {
let Some(ref path) = self.path else {
return Ok(());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&self.entries)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
crate::paths::write_atomic(path, json.as_bytes())
}
pub fn words_on(&self, date: &str) -> i64 {
self.entries.get(date).copied().unwrap_or(0)
}
pub fn today(&self) -> i64 {
self.words_on(&today_key())
}
pub fn recent(&self, n: usize) -> Vec<(&str, i64)> {
self.entries
.iter()
.rev()
.take(n)
.map(|(k, &v)| (k.as_str(), v))
.collect()
}
}
fn today_key() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = now / 86400;
let (year, month, day) = days_to_date(days);
format!("{year:04}-{month:02}-{day:02}")
}
fn days_to_date(days_since_epoch: u64) -> (u32, u32, u32) {
let z = days_since_epoch + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u32, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
use ropey::Rope;
#[test]
fn prose_words_excludes_notes_and_markers() {
assert_eq!(prose_words_in_line(".. this is a note"), 0);
assert_eq!(prose_words_in_line("A **bold** and *fine* day."), 5);
assert_eq!(prose_words_in_line("## Heading"), 1);
assert_eq!(prose_words_in_line("plain line here"), 3);
}
#[test]
fn full_count_matches_line_by_line() {
let text = "# Chapter One\n.. note to self\nHello **world**.\nGoodbye.\n";
let rope = Rope::from_str(text);
let stats = DocStats::from_rope(&rope);
assert_eq!(stats.words, 5);
}
#[test]
fn incremental_update_tracks_edit() {
let text = "Hello world.\nSecond line.\n";
let rope = Rope::from_str(text);
let mut stats = DocStats::from_rope(&rope);
let initial = stats.words;
let mut rope2 = rope.clone();
rope2.remove(0..12); rope2.insert(0, "Hello bright world.");
stats.invalidate_lines(&rope2, 0, 1);
assert_eq!(stats.words, initial + 1);
}
#[test]
fn full_recount_corrects_drift() {
let text = "One two three.\n";
let rope = Rope::from_str(text);
let mut stats = DocStats::from_rope(&rope);
stats.words = 999;
stats.generation += 1;
assert!(stats.needs_recount());
assert!(stats.full_recount(&rope));
assert_eq!(stats.words, 3);
}
#[test]
fn selection_count() {
let rope = Rope::from_str("Hello world, this is a test.\n");
let (words, _chars) = count_slice(&rope, 0, 11); assert_eq!(words, 2);
}
#[test]
fn session_goal_words() {
let goal = SessionGoal::new(GoalKind::Words, 100, 50);
assert!(!goal.is_met(100));
assert!(goal.is_met(150));
let (progress, target) = goal.progress(120);
assert_eq!(progress, 70);
assert_eq!(target, 100);
}
#[test]
fn daily_history_round_trip() {
let mut h = DailyHistory::default();
h.record_delta(50);
h.record_delta(30);
assert_eq!(h.today(), 80);
}
#[test]
fn date_conversion_sanity() {
let (y, m, d) = days_to_date(19723);
assert_eq!((y, m, d), (2024, 1, 1));
}
}