mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Header & Footer: the automatic date takes a format
`HeaderFooter::date_format` is documented as the `datetime1`…`datetime13` format for automatic dates, and nothing read it: grep found one hit, the field's own declaration. The Date placeholder was hardcoded to `datetime1`, so the footer date was always the short date, and a `dateFormat` parameter was accepted and silently dropped — the command returned Ok, so a caller could not tell. Everything else was already there: `datetime::field_text` renders all thirteen types and Insert ▸ Date & Time already offers them. Missing was the wiring from a choice to the placeholder. `design.headerFooter` now takes `dateFormat`, refuses anything outside the thirteen rather than ignoring it, stores it, creates the placeholder with that field type, and updates an existing one when the format changes — as it already did for a changed `dateText`. A fixed date still wins as plain text, and going back to automatic restores the remembered format. `datetime::DATE_KINDS` is the canonical list; `dialogs.rs` uses it rather than its own copy of the same thirteen strings, and the Header & Footer dialog gains a Format dropdown listing each type by what it would show now, enabled while the date is automatic. Not here: reading a deck does not populate `date_format`, so the dialog shows datetime1 preselected for a deck whose dates are something else until the user picks. That is a pptx read-side change and wants its own PR.
This commit is contained in:
@@ -821,6 +821,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
@@ -836,6 +844,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -880,7 +889,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -901,9 +914,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text);
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -917,13 +930,31 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
@@ -1908,3 +1939,78 @@ mod browse_theme_tests {
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![(sample("datetime1"), Some("datetime1".into()))]);
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime5", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![(sample("datetime5"), Some("datetime5".into()))]);
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
|
||||
assert_ne!(sample("datetime11"), sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,25 @@ pub fn is_date_field(kind: &str) -> bool {
|
||||
kind.starts_with("datetime")
|
||||
}
|
||||
|
||||
/// The date and time field types PowerPoint's Header and Footer and Insert ▸ Date & Time offer,
|
||||
/// in the order the dialogs list them. `datetime` and `datetimeFigureOut` are read as `datetime1`
|
||||
/// (see [`field_text`]) but are not offered as a choice.
|
||||
pub const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
|
||||
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
|
||||
/// the format needs names in a language other than English: the field keeps its saved text.
|
||||
|
||||
@@ -1076,6 +1076,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
|
||||
let date = check(ui, d, "Date and time", "date", hf.date);
|
||||
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
|
||||
// The format applies to an automatic date; a fixed one is the text as typed.
|
||||
let automatic = d.get("dateText", &hf.date_text).is_empty();
|
||||
let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() };
|
||||
let fmt = d.get("dateFormat", &fmt_now);
|
||||
let rows = date_rows("en-US");
|
||||
let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone());
|
||||
ui.add_enabled_ui(date && automatic, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(" Format:");
|
||||
egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| {
|
||||
for (kind, sample) in &rows {
|
||||
if ui.selectable_label(fmt == *kind, sample).clicked() {
|
||||
d.fields.insert("dateFormat".into(), (*kind).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
|
||||
let foot = check(ui, d, "Footer", "footer", hf.footer);
|
||||
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
|
||||
@@ -1096,7 +1114,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
run(
|
||||
app,
|
||||
"design.headerFooter",
|
||||
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
|
||||
json!({"date": date, "dateText": dt, "dateFormat": d.get("dateFormat", &fmt_now), "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
|
||||
);
|
||||
}
|
||||
apply || apply_all || d.fields.contains_key("cancel")
|
||||
@@ -2440,21 +2458,7 @@ const DATE_LANGS: &[(&str, &str)] = &[
|
||||
("sv-SE", "Swedish"),
|
||||
];
|
||||
|
||||
const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
use deckcraft_text::datetime::DATE_KINDS;
|
||||
|
||||
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
|
||||
/// other languages keep the numeric date and the 24-hour times.
|
||||
|
||||
Reference in New Issue
Block a user