Edit SCHEDULED/DEADLINE via a calendar date picker

Add timestamp editing to the headline: a 📅 button and clickable
scheduled/deadline labels open a date picker that writes the planning
field. One 9P write per change; the id is unaffected.

GUI:
- New DatePickerDialog.qml: a MonthGrid calendar with month/year
  navigation, a Scheduled/Deadline selector, optional time (HH:MM) and
  repeater (+1w / ++1m / .+2d) fields, and Set/Clear/Cancel. It parses an
  existing <...>/[...] value to prefill, and builds the inner timestamp
  "YYYY-MM-DD Day [HH:MM] [repeater]" for the server to wrap.
- OutlineView: a shared DatePickerDialog instance; the calendar button
  (prefills from scheduled, else deadline, else today) and clicking a
  timestamp label open it on the right field.
- DocumentModel::setScheduled/setDeadline write /<doc>/<id>/{scheduled,
  deadline} and refresh the row.

Server fix (set_planning_field):
- The old logic rebuilt the planning line by splitting on whitespace,
  which breaks timestamps that contain spaces (day name, time, repeater):
  replacing a date-only SCHEDULED with a time-bearing one left a stray
  trailing token ("... 14:30> Sun>") and could duplicate the line, so the
  field then failed to parse. Rewrite it to parse the planning line into
  (keyword, bracketed-timestamp) pairs via new parse_planning_line, update
  or remove the target field, and re-emit in canonical order
  (SCHEDULED, DEADLINE, CLOSED) via emit_planning_line. CLOSED now wraps
  as inactive [...]; SCHEDULED/DEADLINE as active <...>.

Verified: server tests (72 passed, +4: time/repeater round-trip, two
coexisting fields without duplication, planning-line parse, and an exact
repro of the live scenario). Live 9P run: date-only -> time -> time+
repeater all round-trip, and SCHEDULED+DEADLINE share one clean planning
line. GUI builds clean (DatePickerDialog compiles).
This commit is contained in:
Levi Neely 2026-10-02 14:50:51 +02:00
parent d83324e1e4
commit 9de0c99f25
6 changed files with 532 additions and 64 deletions

View File

@ -52,6 +52,78 @@ fn format_timestamp(ts: &Timestamp) -> String {
}
}
/// Parse a planning line (e.g. `SCHEDULED: <2025-01-15 Wed 10:30> DEADLINE:
/// <2025-02-01>`) into `(keyword, timestamp)` pairs in order of appearance.
/// Each timestamp is captured as a whole bracketed unit (`<...>` or `[...]`),
/// so day names, times, and repeaters — which contain spaces — stay intact.
/// Unrecognized tokens are ignored.
fn parse_planning_line(line: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
let n = bytes.len();
while i < n {
// Skip whitespace.
if bytes[i].is_ascii_whitespace() {
i += 1;
continue;
}
// A field keyword is an uppercase word ending in ':'.
if bytes[i].is_ascii_uppercase() {
let start = i;
while i < n && bytes[i].is_ascii_uppercase() {
i += 1;
}
if i < n && bytes[i] == b':' {
let keyword = &line[start..i];
i += 1; // consume ':'
// Skip whitespace before the timestamp.
while i < n && bytes[i].is_ascii_whitespace() {
i += 1;
}
// Capture a bracketed timestamp as a whole unit.
if i < n && (bytes[i] == b'<' || bytes[i] == b'[') {
let close = if bytes[i] == b'<' { b'>' } else { b']' };
let ts_start = i;
while i < n && bytes[i] != close {
i += 1;
}
if i < n {
i += 1; // consume closing bracket
}
out.push((keyword.to_string(), line[ts_start..i].to_string()));
continue;
}
// Keyword without a bracketed timestamp: skip it.
continue;
}
// Uppercase word that isn't a field keyword; move on.
continue;
}
i += 1;
}
out
}
/// Re-emit planning fields in canonical org order (SCHEDULED, DEADLINE,
/// CLOSED, then any others in their given order), space-separated. Returns an
/// empty string when there are no fields.
fn emit_planning_line(fields: &[(String, String)]) -> String {
const ORDER: [&str; 3] = ["SCHEDULED", "DEADLINE", "CLOSED"];
let mut parts: Vec<String> = Vec::new();
for key in ORDER {
if let Some((_, ts)) = fields.iter().find(|(k, _)| k == key) {
parts.push(format!("{}: {}", key, ts));
}
}
for (k, ts) in fields {
if !ORDER.contains(&k.as_str()) {
parts.push(format!("{}: {}", k, ts));
}
}
parts.join(" ")
}
/// Parsed document with metadata.
pub struct DocState {
pub path: PathBuf,
@ -1283,85 +1355,65 @@ impl OrkState {
}
/// Set a planning field (SCHEDULED/DEADLINE/CLOSED).
///
/// Parses the existing planning line into its fields (each a keyword
/// followed by a bracketed timestamp, which may contain spaces for a day
/// name, time, or repeater), updates or removes the target field, and
/// re-emits the fields in canonical order (SCHEDULED, DEADLINE, CLOSED).
/// An empty `timestamp` removes the field; an empty resulting line removes
/// the planning line entirely.
fn set_planning_field(&self, doc_name: &str, id: &str, field: &str, timestamp: &str) -> Result<()> {
let doc_state = self.get_doc(doc_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
let section = self.find_section(&doc_state.doc, id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
let timestamp = timestamp.trim();
let mut content = doc_state.content.clone();
// Wrap a bare timestamp in the brackets appropriate for the field:
// SCHEDULED/DEADLINE are active <...>, CLOSED is inactive [...]. A
// value that already carries brackets is used as-is.
let wrap = |ts: &str| -> String {
if ts.starts_with('<') || ts.starts_with('[') {
ts.to_string()
} else if field == "CLOSED" {
format!("[{}]", ts)
} else {
format!("<{}>", ts)
}
};
if let Some(planning) = &section.planning {
// Has planning line - modify it
let planning_start = planning.span.start.offset;
let planning_end = planning.span.end.offset;
let planning_text = &content[planning_start..planning_end];
// Rebuild planning line
let new_planning = if timestamp.is_empty() {
// Remove this field: strip "FIELD: <timestamp>" pattern
let mut result = String::new();
let mut skip_next_ts = false;
for part in planning_text.split_whitespace() {
if part == &format!("{}:", field) {
skip_next_ts = true;
continue;
}
if skip_next_ts && part.starts_with('<') {
skip_next_ts = false;
continue;
}
skip_next_ts = false;
if !result.is_empty() { result.push(' '); }
result.push_str(part);
}
if result.trim().is_empty() {
String::new()
} else {
format!("{}\n", result.trim())
}
// Parse existing fields into (keyword -> timestamp), preserving
// order of appearance for any we don't touch.
let mut fields = parse_planning_line(planning_text);
// Update or remove the target field.
fields.retain(|(k, _)| k != field);
if !timestamp.is_empty() {
fields.push((field.to_string(), wrap(timestamp)));
}
let new_planning = emit_planning_line(&fields);
let replacement = if new_planning.is_empty() {
String::new()
} else {
// Add/update this field
let ts = if timestamp.starts_with('<') { timestamp.to_string() } else { format!("<{}>", timestamp) };
if planning_text.contains(&format!("{}:", field)) {
// Replace existing: rebuild with new timestamp for this field
let mut result = String::new();
let mut replace_next_ts = false;
for part in planning_text.split_whitespace() {
if part == &format!("{}:", field) {
replace_next_ts = true;
if !result.is_empty() { result.push(' '); }
result.push_str(part);
continue;
}
if replace_next_ts && part.starts_with('<') {
result.push(' ');
result.push_str(&ts);
replace_next_ts = false;
continue;
}
replace_next_ts = false;
if !result.is_empty() { result.push(' '); }
result.push_str(part);
}
format!("{}\n", result.trim())
} else {
// Add new field
format!("{} {}: {}\n", planning_text.trim(), field, ts)
}
format!("{}\n", new_planning)
};
content = format!("{}{}{}", &content[..planning_start], new_planning, &content[planning_end..]);
content = format!("{}{}{}", &content[..planning_start], replacement, &content[planning_end..]);
} else if !timestamp.is_empty() {
// No planning line - insert after headline
// No planning line - insert one right after the headline.
let insert_pos = section.headline.span.end.offset;
let ts = if timestamp.starts_with('<') { timestamp.to_string() } else { format!("<{}>", timestamp) };
let planning_line = format!("{}: {}\n", field, ts);
let planning_line = format!("{}: {}\n", field, wrap(timestamp));
content = format!("{}{}{}", &content[..insert_pos], planning_line, &content[insert_pos..]);
}
self.update_doc(doc_name, &doc_state.path, content)
}
@ -2523,6 +2575,74 @@ mod tests {
assert!(state.find_section(&state.get_doc("w").unwrap().doc, "task-one").is_some());
}
#[test]
fn set_scheduled_with_time_and_repeater_round_trips() {
// A timestamp with a day name, time, and repeater (which contain
// spaces) must survive update without corrupting the planning line.
let (_d, state) = state_with("#+TITLE: W\n* TODO Task\n");
state.set_section_scheduled("w", "task", "2025-06-15 Sun").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap(), "<2025-06-15 Sun>");
// Replace with a time-bearing timestamp (the old whitespace-splitting
// logic left a stray trailing token here).
state.set_section_scheduled("w", "task", "2025-06-15 Sun 14:30").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap(), "<2025-06-15 Sun 14:30>");
state.set_section_scheduled("w", "task", "2025-06-15 Sun 14:30 +1w").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap(), "<2025-06-15 Sun 14:30 +1w>");
}
#[test]
fn planning_fields_coexist_without_duplication() {
// Setting SCHEDULED then DEADLINE yields one planning line with both
// fields; neither is duplicated and the other is preserved on update.
let (_d, state) = state_with("#+TITLE: W\n* TODO Task\n");
state.set_section_scheduled("w", "task", "2025-06-15 Sun 09:00").unwrap();
state.set_section_deadline("w", "task", "2025-07-01 Tue").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap(), "<2025-06-15 Sun 09:00>");
assert_eq!(state.section_deadline("w", "task").unwrap(), "<2025-07-01 Tue>");
let raw = state.doc_raw("w").unwrap();
assert_eq!(raw.matches("SCHEDULED:").count(), 1);
assert_eq!(raw.matches("DEADLINE:").count(), 1);
// Updating SCHEDULED keeps DEADLINE intact.
state.set_section_scheduled("w", "task", "2025-06-20 Fri").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap(), "<2025-06-20 Fri>");
assert_eq!(state.section_deadline("w", "task").unwrap(), "<2025-07-01 Tue>");
// Clearing SCHEDULED leaves DEADLINE and a single planning line.
state.set_section_scheduled("w", "task", "").unwrap();
assert_eq!(state.section_scheduled("w", "task").unwrap_or_default(), "");
assert_eq!(state.section_deadline("w", "task").unwrap(), "<2025-07-01 Tue>");
let raw = state.doc_raw("w").unwrap();
assert_eq!(raw.matches("SCHEDULED:").count(), 0);
assert_eq!(raw.matches("DEADLINE:").count(), 1);
}
#[test]
fn parse_planning_line_captures_bracketed_timestamps() {
let fields = parse_planning_line(
"SCHEDULED: <2025-01-15 Wed 10:30 +1w> DEADLINE: <2025-02-01 Sat>",
);
assert_eq!(fields.len(), 2);
assert_eq!(fields[0], ("SCHEDULED".to_string(), "<2025-01-15 Wed 10:30 +1w>".to_string()));
assert_eq!(fields[1], ("DEADLINE".to_string(), "<2025-02-01 Sat>".to_string()));
}
#[test]
fn scheduled_replace_live_repro_plan_something() {
// Exact reproduction of the live GUI scenario that previously left a
// malformed planning line: a titled doc, a "Plan something" headline,
// set a date-only SCHEDULED, then replace it with a time-bearing one.
let (_d, state) = state_with("#+TITLE: TS\n* TODO Plan something\n");
state.set_section_scheduled("w", "plan-something", "2025-06-15 Sun").unwrap();
assert_eq!(state.section_scheduled("w", "plan-something").unwrap(), "<2025-06-15 Sun>");
state.set_section_scheduled("w", "plan-something", "2025-06-15 Sun 14:30").unwrap();
assert_eq!(state.section_scheduled("w", "plan-something").unwrap(), "<2025-06-15 Sun 14:30>");
let raw = state.doc_raw("w").unwrap();
assert_eq!(raw.matches("SCHEDULED:").count(), 1, "raw was: {:?}", raw);
assert!(!raw.contains("Sun>\n") || raw.contains("14:30> "), "stray token in: {:?}", raw);
}
#[test]
fn test_delete_section() {
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");

View File

@ -29,6 +29,7 @@ qt_add_qml_module(orkmode
qml/Main.qml
qml/OutlineView.qml
qml/SettingsDialog.qml
qml/DatePickerDialog.qml
)
target_link_libraries(orkmode PRIVATE

250
ui/qml/DatePickerDialog.qml Normal file
View File

@ -0,0 +1,250 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A compact date picker for a section's SCHEDULED/DEADLINE planning
// timestamp. Supports the common point-timestamp forms the parser handles:
// a date, an optional time (HH:MM), and an optional repeater (e.g. +1w).
// Planning timestamps are active by org convention, so the server wraps the
// value in <...>. Set writes the field, Clear removes it.
//
// Open with open(row, field, currentValue), where field is "SCHEDULED" or
// "DEADLINE" and currentValue is the field's current <...> string (may be
// empty). The dialog parses currentValue to prefill its controls.
Dialog {
id: dpd
title: qsTr("Set Date")
modal: true
standardButtons: Dialog.NoButton
width: 340
x: (parent.width - width) / 2
y: (parent.height - height) / 2
// Target section row and planning field.
property int targetRow: -1
property string field: "SCHEDULED"
// Calendar state.
property int shownMonth: (new Date()).getMonth() // 0-11
property int shownYear: (new Date()).getFullYear()
// Selected date components (-1 day = none selected yet).
property int selYear: -1
property int selMonth: -1
property int selDay: -1
readonly property var monthNames: [
qsTr("January"), qsTr("February"), qsTr("March"), qsTr("April"),
qsTr("May"), qsTr("June"), qsTr("July"), qsTr("August"),
qsTr("September"), qsTr("October"), qsTr("November"), qsTr("December")
]
readonly property var dayNames: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
background: Rectangle {
color: theme.windowColor
border.color: theme.midColor
border.width: 1
radius: 6
}
// Open the picker for a given row/field, prefilled from currentValue.
function open2(row, fieldName, currentValue) {
targetRow = row
field = fieldName
parseValue(currentValue)
open()
}
// Parse an org timestamp like "<2025-01-15 Wed 10:30 +1w>" into the
// dialog's controls. An empty/unparseable value leaves today selected and
// the time/repeater empty.
function parseValue(v) {
timeField.text = ""
repeaterField.text = ""
var now = new Date()
selYear = now.getFullYear(); selMonth = now.getMonth(); selDay = now.getDate()
if (v && v.length > 0) {
// Strip surrounding < > or [ ].
var inner = v.replace(/^[<\[]/, "").replace(/[>\]]$/, "").trim()
var dm = inner.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (dm) {
selYear = parseInt(dm[1]); selMonth = parseInt(dm[2]) - 1; selDay = parseInt(dm[3])
}
var tm = inner.match(/\b(\d{1,2}:\d{2})\b/)
if (tm) timeField.text = tm[1]
var rm = inner.match(/((?:\+\+|\.\+|\+)\d+[hdwmy])/)
if (rm) repeaterField.text = rm[1]
}
shownMonth = selMonth
shownYear = selYear
}
function prevMonth() {
if (shownMonth === 0) { shownMonth = 11; shownYear-- } else shownMonth--
}
function nextMonth() {
if (shownMonth === 11) { shownMonth = 0; shownYear++ } else shownMonth++
}
// Build the inner timestamp string "YYYY-MM-DD Day [HH:MM] [repeater]".
function buildTimestamp() {
if (selYear < 0) return ""
var mm = (selMonth + 1 < 10 ? "0" : "") + (selMonth + 1)
var dd = (selDay < 10 ? "0" : "") + selDay
var dow = dayNames[(new Date(selYear, selMonth, selDay)).getDay()]
var s = selYear + "-" + mm + "-" + dd + " " + dow
var t = timeField.text.trim()
if (/^\d{1,2}:\d{2}$/.test(t)) s += " " + t
var r = repeaterField.text.trim()
if (/^(?:\+\+|\.\+|\+)\d+[hdwmy]$/.test(r)) s += " " + r
return s
}
function applySet() {
var ts = buildTimestamp()
if (ts.length === 0) { close(); return }
if (field === "DEADLINE")
docModel.setDeadline(targetRow, ts)
else
docModel.setScheduled(targetRow, ts)
close()
}
function applyClear() {
if (field === "DEADLINE")
docModel.setDeadline(targetRow, "")
else
docModel.setScheduled(targetRow, "")
close()
}
contentItem: ColumnLayout {
spacing: 10
// Field selector: Scheduled vs Deadline.
RowLayout {
Layout.fillWidth: true
spacing: 6
RadioButton {
text: qsTr("Scheduled")
checked: dpd.field === "SCHEDULED"
onClicked: dpd.field = "SCHEDULED"
}
RadioButton {
text: qsTr("Deadline")
checked: dpd.field === "DEADLINE"
onClicked: dpd.field = "DEADLINE"
}
}
// Month/year navigation.
RowLayout {
Layout.fillWidth: true
Button { text: "‹"; onClicked: dpd.prevMonth() }
Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: dpd.monthNames[dpd.shownMonth] + " " + dpd.shownYear
color: theme.textColor
font.bold: true
}
Button { text: "›"; onClicked: dpd.nextMonth() }
}
DayOfWeekRow {
Layout.fillWidth: true
locale: grid.locale
delegate: Label {
text: model.shortName
color: theme.placeholderTextColor
font.pointSize: Math.max(8, (theme.fontSize > 0 ? theme.fontSize : 11) - 2)
horizontalAlignment: Text.AlignHCenter
}
}
MonthGrid {
id: grid
Layout.fillWidth: true
month: dpd.shownMonth
year: dpd.shownYear
delegate: Rectangle {
required property var model
readonly property bool inMonth: model.month === dpd.shownMonth
readonly property bool isSelected:
model.year === dpd.selYear && model.month === dpd.selMonth
&& model.day === dpd.selDay
implicitWidth: 36
implicitHeight: 28
radius: 4
color: isSelected ? theme.highlightColor
: (dayHover.hovered && inMonth ? theme.alternateBaseColor
: "transparent")
Label {
anchors.centerIn: parent
text: model.day
color: !inMonth ? theme.placeholderTextColor
: (parent.isSelected ? theme.highlightedTextColor
: theme.textColor)
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 10
}
HoverHandler { id: dayHover }
TapHandler {
onTapped: {
dpd.selYear = model.year
dpd.selMonth = model.month
dpd.selDay = model.day
// Follow the click into adjacent months.
dpd.shownMonth = model.month
dpd.shownYear = model.year
}
}
}
}
// Optional time and repeater.
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 8
rowSpacing: 6
Label { text: qsTr("Time"); color: theme.textColor }
TextField {
id: timeField
Layout.fillWidth: true
placeholderText: qsTr("HH:MM (optional)")
inputMask: ""
selectByMouse: true
}
Label { text: qsTr("Repeat"); color: theme.textColor }
TextField {
id: repeaterField
Layout.fillWidth: true
placeholderText: qsTr("+1w / ++1m / .+2d (optional)")
selectByMouse: true
}
}
// Actions.
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
text: qsTr("Clear")
onClicked: dpd.applyClear()
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Cancel")
onClicked: dpd.close()
}
Button {
text: qsTr("Set")
highlighted: true
onClicked: dpd.applySet()
}
}
}
}

View File

@ -117,6 +117,13 @@ ListView {
onTriggered: outline.flashRow = -1
}
// Shared date picker for SCHEDULED/DEADLINE editing. Opened from a
// headline's calendar button via datePicker.open2(row, field, value).
DatePickerDialog {
id: datePicker
parent: Overlay.overlay
}
delegate: Rectangle {
id: del
width: outline.width
@ -490,20 +497,58 @@ ListView {
}
}
// Scheduled timestamp
// Scheduled timestamp. Click to edit via the date picker.
Label {
visible: (del.model.scheduled ?? "") !== ""
text: "⏱ " + (del.model.scheduled ?? "")
color: theme.timestampColor
font.pointSize: outline.baseFontSize - 1
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: datePicker.open2(del.index, "SCHEDULED",
del.model.scheduled ?? "")
}
}
// Deadline timestamp
// Deadline timestamp. Click to edit via the date picker.
Label {
visible: (del.model.deadline ?? "") !== ""
text: "⚑ " + (del.model.deadline ?? "")
color: theme.deadlineColor
font.pointSize: outline.baseFontSize - 1
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: datePicker.open2(del.index, "DEADLINE",
del.model.deadline ?? "")
}
}
// Calendar button: open the date picker to add or change a
// SCHEDULED/DEADLINE timestamp. Prefills from whichever field
// is already set (SCHEDULED preferred), else today.
Label {
text: "📅"
font.pointSize: outline.baseFontSize - 1
opacity: calMouse.containsMouse ? 1.0 : 0.45
MouseArea {
id: calMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var hasDeadline = (del.model.deadline ?? "") !== ""
var hasScheduled = (del.model.scheduled ?? "") !== ""
if (hasDeadline && !hasScheduled)
datePicker.open2(del.index, "DEADLINE", del.model.deadline)
else
datePicker.open2(del.index, "SCHEDULED",
del.model.scheduled ?? "")
}
}
}
// Tags. In display mode, each tag is a pill and clicking any

View File

@ -412,6 +412,48 @@ bool DocumentModel::setTags(int row, const QString &tags)
return true;
}
bool DocumentModel::setScheduled(int row, const QString &timestamp)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/scheduled"), timestamp))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {ScheduledRole});
return true;
}
bool DocumentModel::setDeadline(int row, const QString &timestamp)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/deadline"), timestamp))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {DeadlineRole});
return true;
}
int DocumentModel::revealSection(const QString &sectionId, int bodyOffset)
{
if (sectionId.isEmpty() || !m_sections.contains(sectionId))

View File

@ -106,6 +106,16 @@ public:
// removes all tags. Writes /<doc>/<id>/tags and refreshes the row.
Q_INVOKABLE bool setTags(int row, const QString &tags);
// Set a section's SCHEDULED timestamp. `timestamp` is the inner form
// (e.g. "2025-01-15" or "2025-01-15 10:30 +1w"); the server wraps it in
// <...>. An empty string removes the SCHEDULED field. Writes
// /<doc>/<id>/scheduled and refreshes the row. Returns true on success.
Q_INVOKABLE bool setScheduled(int row, const QString &timestamp);
// Set a section's DEADLINE timestamp (see setScheduled). Writes
// /<doc>/<id>/deadline and refreshes the row.
Q_INVOKABLE bool setDeadline(int row, const QString &timestamp);
// Reveal a section by id: expand all its ancestors so it becomes visible,
// and return its visible row index (-1 if the id is unknown). Emits
// sectionRevealed(row, bodyOffset) so the view can scroll to and highlight