From 9de0c99f250d16eaa7aa273acb884c3c2bbfe0c5 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 2 Oct 2026 14:50:51 +0200 Subject: [PATCH] Edit SCHEDULED/DEADLINE via a calendar date picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ///{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). --- crates/ork-server/src/state.rs | 244 ++++++++++++++++++++++++-------- ui/CMakeLists.txt | 1 + ui/qml/DatePickerDialog.qml | 250 +++++++++++++++++++++++++++++++++ ui/qml/OutlineView.qml | 49 ++++++- ui/src/documentmodel.cpp | 42 ++++++ ui/src/documentmodel.h | 10 ++ 6 files changed, 532 insertions(+), 64 deletions(-) create mode 100644 ui/qml/DatePickerDialog.qml diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index e55735d..0ae0d2e 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -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 = 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) = §ion.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: " 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"); diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index d3f2965..ea8fe7b 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -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 diff --git a/ui/qml/DatePickerDialog.qml b/ui/qml/DatePickerDialog.qml new file mode 100644 index 0000000..1218c39 --- /dev/null +++ b/ui/qml/DatePickerDialog.qml @@ -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() + } + } + } +} diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index 653143f..0f4b87f 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -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 diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 09aa848..5099808 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -412,6 +412,48 @@ bool DocumentModel::setTags(int row, const QString &tags) return true; } +bool DocumentModel::setScheduled(int row, const QString ×tamp) +{ + 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 ×tamp) +{ + 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 §ionId, int bodyOffset) { if (sectionId.isEmpty() || !m_sections.contains(sectionId)) diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 2f97cfe..3af38e6 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -106,6 +106,16 @@ public: // removes all tags. Writes ///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 + // ///scheduled and refreshes the row. Returns true on success. + Q_INVOKABLE bool setScheduled(int row, const QString ×tamp); + + // Set a section's DEADLINE timestamp (see setScheduled). Writes + // ///deadline and refreshes the row. + Q_INVOKABLE bool setDeadline(int row, const QString ×tamp); + // 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