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:
parent
d83324e1e4
commit
9de0c99f25
|
|
@ -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) = §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: <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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ×tamp);
|
||||
|
||||
// Set a section's DEADLINE timestamp (see setScheduled). Writes
|
||||
// /<doc>/<id>/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
|
||||
|
|
|
|||
Loading…
Reference in New Issue