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
1 change: 1 addition & 0 deletions linux/changelog.d/3890.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Picking a day in Row Details reset a timestamp's time to midnight when its year or spelling was not the plain form.
60 changes: 42 additions & 18 deletions linux/crates/app/src/ui/data_grid/row_detail/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,46 @@ use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{self as gtk, glib};
use tablepro_core::column::{ColumnKind, TemporalZone};
use tablepro_core::edit::{parse_timestamp_with_offset, temporal_parts};

use super::RowDetailPane;
use super::value_editor::Commit;
use crate::application::component_matches;

/// The day a value's text starts with, when it starts with one the
/// calendar can show.
// GtkCalendar shows the years 1 to 9999 only, so it opens on today for any other.
pub fn day_of(text: &str) -> Option<NaiveDate> {
let head = text.get(..10)?;
NaiveDate::parse_from_str(head, "%Y-%m-%d")
.ok()
.filter(|day| (1..=9999).contains(&day.year()))
temporal_parts(text).date.filter(|day| (1..=9999).contains(&day.year()))
}

/// The value's text with `day` picked: a date, or a timestamp in `zone`,
/// which keeps the time it had. A new zoned timestamp starts the day at
/// `offset`, as one without an offset is refused.
// Only the day changes, and the era with it, since a picked day is AD. Text with no day takes one
// before its clock, or becomes a new value; a zoned timestamp lacking an offset takes `offset`.
pub fn with_day(text: &str, day: NaiveDate, zone: Option<&TemporalZone>, offset: FixedOffset) -> String {
let date = day.format("%Y-%m-%d").to_string();
match (zone, day_of(text)) {
(None, _) => date,
(Some(_), Some(_)) => format!("{date}{}", text.get(10..).unwrap_or_default()),
(Some(TemporalZone::Offset), None) => format!("{date} 00:00:00{offset}"),
(Some(_), None) => format!("{date} 00:00:00"),
let parts = temporal_parts(text);
let mut text = text.to_owned();
if let Some(era) = parts.era {
text.replace_range(era, "");
}
match (parts.day, parts.clock, zone) {
(Some(old), _, _) => {
text.replace_range(old, &date);
text
}
(None, Some(clock), Some(zone)) => {
text.insert_str(clock.start, &format!("{date} "));
let (body, tail) = text.split_at(text.trim_end().len());
let zoned = format!("{body}{offset}{tail}");
match zone == &TemporalZone::Offset
&& parse_timestamp_with_offset(&text).is_err()
&& parse_timestamp_with_offset(&zoned).is_ok()
{
true => zoned,
false => text,
}
}
(None, _, None) => date,
(None, None, Some(TemporalZone::Offset)) => format!("{date} 00:00:00{offset}"),
(None, None, Some(_)) => format!("{date} 00:00:00"),
}
}

Expand All @@ -36,9 +52,12 @@ pub fn day_start_offset<Tz: TimeZone>(zone: &Tz, day: NaiveDate) -> FixedOffset

// A wall-clock time a clock change skips takes the offset before the change.
pub fn offset_at<Tz: TimeZone>(zone: &Tz, local: NaiveDateTime) -> FixedOffset {
[local, local - TimeDelta::hours(1)]
.iter()
.find_map(|local| zone.from_local_datetime(local).earliest())
zone.from_local_datetime(&local)
.earliest()
.or_else(|| {
let before = local.checked_sub_signed(TimeDelta::hours(1))?;
zone.from_local_datetime(&before).earliest()
})
.map_or_else(|| zone.offset_from_utc_datetime(&local).fix(), |at| at.offset().fix())
}

Expand Down Expand Up @@ -145,7 +164,12 @@ impl RowDetailPane {
ColumnKind::Timestamp { zone, .. } => Some(zone.clone()),
_ => None,
});
let text = with_day(&self.typed_text(), day, zone.as_ref(), day_start_offset(&Local, day));
let typed = self.typed_text();
let offset = temporal_parts(&typed).time.map_or_else(
|| day_start_offset(&Local, day),
|time| offset_at(&Local, day.and_time(time)),
);
let text = with_day(&typed, day, zone.as_ref(), offset);
imp.calendar_popover.popdown();
imp.value_buffer.set_text(&text);
self.commit(Commit::Keep);
Expand Down
63 changes: 8 additions & 55 deletions linux/crates/app/src/ui/data_grid/row_detail/clock.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use std::ops::Range;

use chrono::{Local, NaiveDate, NaiveTime, TimeZone, Timelike};
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{self as gtk, glib};
use tablepro_core::column::{ColumnKind, TemporalZone, TimeRange};
use tablepro_core::edit::temporal_parts;

use super::RowDetailPane;
use super::calendar::offset_at;
Expand Down Expand Up @@ -37,72 +36,26 @@ impl ClockField {
}
}

// By the text's own form, as chrono reads it for the commit: SQLite keeps any date or time text in a
// DATE, TIME or DATETIME column, and chrono takes whitespace before a number and one digit for two.
fn clock_span(text: &str) -> Option<Range<usize>> {
let after_day = day_len(text).map_or(0, |day| day + usize::from(text.as_bytes().get(day) == Some(&b'T')));
let start = after_day + spaces(text.get(after_day..)?);
let minute = number(text, literal(text, number(text, start, 2)?, b':')?, 2)?;
let end = literal(text, minute, b':').and_then(|second| number(text, second, 2));
Some(start..end.unwrap_or(minute))
}

// A year may run past 9999, as PostgreSQL writes one, and carry a sign.
fn day_len(text: &str) -> Option<usize> {
let lead = spaces(text);
let year = lead + usize::from(matches!(text.as_bytes().get(lead), Some(b'-' | b'+')));
let month = number(text, literal(text, number(text, year, usize::MAX)?, b'-')?, 2)?;
number(text, literal(text, month, b'-')?, 2)
}

// The end of the number at `at`, past any whitespace before it.
fn number(text: &str, at: usize, most: usize) -> Option<usize> {
let start = at + spaces(text.get(at..)?);
let run = text
.get(start..)?
.bytes()
.take(most)
.take_while(u8::is_ascii_digit)
.count();
(run > 0).then_some(start + run)
}

fn literal(text: &str, at: usize, byte: u8) -> Option<usize> {
(text.as_bytes().get(at) == Some(&byte)).then_some(at + 1)
}

fn spaces(text: &str) -> usize {
text.len() - text.trim_start().len()
}

pub fn time_of(text: &str) -> Option<NaiveTime> {
let mut parts = text
.get(clock_span(text)?)?
.split(':')
.map(|part| part.trim().parse::<u32>());
let (hour, minute) = (parts.next()?.ok()?, parts.next()?.ok()?);
let second = parts.next().transpose().ok()?.unwrap_or(0);
NaiveTime::from_hms_opt(hour, minute, second)
temporal_parts(text).time
}

// Only the clock changes; text with no day or clock, such as NULL or infinity, becomes a new value.
pub fn with_time<Tz: TimeZone>(text: &str, time: NaiveTime, field: ClockField, today: NaiveDate, zone: &Tz) -> String {
let mut clock = time.format("%H:%M:%S").to_string();
let parts = temporal_parts(text);
let mut text = text.to_owned();
if let Some(span) = clock_span(&text) {
if let Some(span) = parts.clock {
text.replace_range(span, &clock);
return text;
}
let day_end = day_len(&text);
let day = day_end
.and_then(|end| NaiveDate::parse_from_str(text.get(..end)?.trim_start(), "%Y-%m-%d").ok())
.unwrap_or(today);
if field.with_offset {
let day = parts.date.unwrap_or(today);
clock.push_str(&offset_at(zone, day.and_time(time)).to_string());
}
match day_end {
Some(end) => {
text.insert_str(end, &format!(" {clock}"));
match parts.day {
Some(day) => {
text.insert_str(day.end, &format!(" {clock}"));
text
}
None if field.dated => format!("{} {clock}", today.format("%Y-%m-%d")),
Expand Down
68 changes: 68 additions & 0 deletions linux/crates/app/src/ui/data_grid/row_detail/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,50 @@ fn a_picked_day_keeps_a_timestamp_time() {
assert_eq!(day_of("2024-02-2é"), None, "a cut inside a character");
}

#[test]
fn a_picked_day_keeps_the_time_of_any_timestamp_the_commit_reads() {
let day = NaiveDate::from_ymd_opt(2024, 3, 1).expect("a day");
let plus_two = chrono::FixedOffset::east_opt(2 * 3600).expect("an offset");
let pick = |text: &str| with_day(text, day, Some(&TemporalZone::Naive), plus_two);

assert_eq!(
pick("12024-01-05 10:00:00"),
"2024-03-01 10:00:00",
"PostgreSQL writes a far year in full"
);
assert_eq!(
pick("2024-1-5 10:00"),
"2024-03-01 10:00",
"SQLite keeps the text as typed"
);
assert_eq!(pick(" 2024-01-05 10:00"), " 2024-03-01 10:00");
assert_eq!(
pick("0044-03-15 12:00:00 BC"),
"2024-03-01 12:00:00",
"an AD day kept the BC era"
);
assert_eq!(pick("10:00"), "2024-03-01 10:00");
let zoned = |text: &str| with_day(text, day, Some(&TemporalZone::Offset), plus_two);
assert_eq!(
zoned("10:00"),
"2024-03-01 10:00+02:00",
"a zoned timestamp took no offset"
);
assert_eq!(zoned("10:00+05"), "2024-03-01 10:00+05");
assert_eq!(
with_day("2024-01-05 10:00", day, None, plus_two),
"2024-03-01 10:00",
"SQLite keeps a timestamp in a DATE column"
);
assert_eq!(day_of("2024-1-5 10:00"), NaiveDate::from_ymd_opt(2024, 1, 5));
assert_eq!(day_of(" 2024-01-05"), NaiveDate::from_ymd_opt(2024, 1, 5));
assert_eq!(
day_of("0044-03-15 12:00:00 BC"),
None,
"the calendar opened on an AD day"
);
}

/// A zone whose offset moves from `before` to `after` at `change`, in UTC.
#[derive(Clone)]
struct OneChange {
Expand Down Expand Up @@ -1033,6 +1077,30 @@ fn a_new_zoned_time_takes_the_offset_of_its_own_moment() {
);
}

#[test]
fn a_clock_on_the_first_day_chrono_holds_takes_an_offset() {
let first = NaiveDate::MIN.format("%Y-%m-%d").to_string();
let today = NaiveDate::from_ymd_opt(2026, 10, 29).expect("a day");
let plus_two = chrono::FixedOffset::east_opt(2 * 3600).expect("an offset");

assert_eq!(
with_time(&first, a_time(0, 30, 0), ZONED, today, &plus_two),
format!("{first} 00:30:00+02:00")
);
}

#[test]
fn a_picker_replaces_a_part_out_of_range() {
let day = NaiveDate::from_ymd_opt(2024, 3, 1).expect("a day");
let plus_two = chrono::FixedOffset::east_opt(2 * 3600).expect("an offset");

assert_eq!(set_at_nine("2024-01-01 12:34:99", NAIVE), "2024-01-01 09:05:30");
assert_eq!(
with_day("2024-02-30 10:00", day, Some(&TemporalZone::Naive), plus_two),
"2024-03-01 10:00"
);
}

#[test]
fn the_clock_reads_the_time_a_value_shows() {
assert_eq!(time_of("2024-02-29 23:59:58.125"), Some(a_time(23, 59, 58)));
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/core/src/edit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod is_unchanged;
mod new_draft_cell;
mod parse_edit_text;
mod parse_literal_text;
mod temporal_parts;

pub use cell_input::CellInput;
pub use edit_context::EditContext;
Expand All @@ -22,3 +23,4 @@ pub use parse_edit_text::parse_edit_text;
pub use parse_literal_text::{
parse_filter_text, parse_literal_text, parse_time_with_offset, parse_timestamp_with_offset,
};
pub use temporal_parts::{TemporalParts, temporal_parts};
17 changes: 11 additions & 6 deletions linux/crates/core/src/edit/parse_literal_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ fn parse_timestamp(input: &str) -> Result<Value, EditParseError> {
/// `+10000` and `-4712`: there is no year 0 between 1 BC and AD 1, so
/// 1 BC is chrono's 0. `None` for a ` BC` value with no year to count
/// back from.
fn chrono_year(input: &str) -> Option<Cow<'_, str>> {
pub(super) fn chrono_year(input: &str) -> Option<Cow<'_, str>> {
let (dated, before_christ) = match input.strip_suffix(" BC").or_else(|| input.strip_suffix(" bc")) {
Some(dated) => (dated, true),
None => (input, false),
Expand Down Expand Up @@ -378,11 +378,7 @@ fn far_temporal(
purpose: Purpose,
parse: fn(&str) -> Result<Value, EditParseError>,
) -> Option<Result<Value, EditParseError>> {
let year_digits = input.bytes().take_while(u8::is_ascii_digit).count();
let (year, rest) = input.split_at(year_digits);
let last_year = u64::try_from(NaiveDate::MAX.year()).ok()?;
let year = year.parse::<u64>().ok().filter(|year| *year > last_year)?;
let stand_in = parse(&format!("{}{rest}", 2000 + year % 400)).ok()?;
let stand_in = parse(&far_year_stand_in(input)?).ok()?;
if purpose == Purpose::Store
&& let Err(error) = check_declared_fraction(&stand_in, column.kind.fractional_digits())
{
Expand All @@ -394,6 +390,15 @@ fn far_temporal(
}))))
}

// The text with a year past chrono's last moved into its range, keeping the rest as written.
pub(super) fn far_year_stand_in(input: &str) -> Option<String> {
let year_digits = input.bytes().take_while(u8::is_ascii_digit).count();
let (year, rest) = input.split_at(year_digits);
let last_year = u64::try_from(NaiveDate::MAX.year()).ok()?;
let year = year.parse::<u64>().ok().filter(|year| *year > last_year)?;
Some(format!("{}{rest}", 2000 + year % 400))
}

fn infinite_temporal<T>(input: &str) -> Option<Temporal<T>> {
match input.to_ascii_lowercase().as_str() {
"infinity" | "inf" => Some(Temporal::Infinity),
Expand Down
Loading
Loading