Personal noctalia plugins collection

feat: add EDS VTODO (task) support with tab-based UI

Add full VTODO/task management via Evolution Data Server Python scripts
(list, create, complete/uncomplete, delete) and integrate into the panel
with a Calendar/Tasks tab bar, task list view with priority indicators,
due date display, and a create task dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+1017 -39
+185 -2
weekly-calendar/Main.qml
··· 17 17 property bool hasLoadedOnce: false 18 18 property string syncStatus: "" 19 19 property int lastKnownEventCount: 0 20 + 21 + // Todo support 22 + property ListModel todosModel: ListModel {} 23 + property var taskLists: [] 24 + property bool todosLoading: false 25 + property string todoSyncStatus: "" 26 + property bool showCompletedTodos: false 20 27 21 28 property real dayColumnWidth: 120 * Style.uiScaleRatio 22 29 property real allDaySectionHeight: 0 * Style.uiScaleRatio ··· 58 65 function onAvailableChanged() { 59 66 if (CalendarService.available) { 60 67 Qt.callLater(loadEvents) 68 + Qt.callLater(loadTaskLists) 69 + Qt.callLater(loadTodos) 61 70 } else { 62 71 isLoading = false 63 72 if (pluginApi) syncStatus = pluginApi.tr("panel.no_service") ··· 106 115 if (CalendarService.events && CalendarService.events.length > 0) { 107 116 Qt.callLater(updateEventsFromService) 108 117 } 109 - if (CalendarService.available) Qt.callLater(loadEvents) 118 + if (CalendarService.available) { 119 + Qt.callLater(loadEvents) 120 + Qt.callLater(loadTaskLists) 121 + Qt.callLater(loadTodos) 122 + } 110 123 } 111 124 112 125 onPluginApiChanged: { ··· 114 127 if (CalendarService.events && CalendarService.events.length > 0) { 115 128 Qt.callLater(updateEventsFromService) 116 129 } 117 - if (CalendarService.available) Qt.callLater(loadEvents) 130 + if (CalendarService.available) { 131 + Qt.callLater(loadEvents) 132 + Qt.callLater(loadTaskLists) 133 + Qt.callLater(loadTodos) 134 + } 118 135 } 119 136 120 137 function initializePluginSettings() { ··· 586 603 if (description) { args.push("--description"); args.push(description) } 587 604 createEventProcess.command = args 588 605 createEventProcess.running = true 606 + } 607 + 608 + // === Todo support === 609 + 610 + property string listTaskListsStdout: "" 611 + property string listTaskListsStderr: "" 612 + 613 + Process { 614 + id: listTaskListsProcess 615 + onExited: function(exitCode, exitStatus) { 616 + if (exitCode === 0) { 617 + try { 618 + var result = JSON.parse(listTaskListsStdout) 619 + if (Array.isArray(result)) { 620 + taskLists = result.filter(function(tl) { return tl.enabled }) 621 + } 622 + } catch(e) { 623 + console.error("[weekly-calendar] Failed to parse task lists: " + listTaskListsStdout) 624 + } 625 + } else { 626 + console.error("[weekly-calendar] list-task-lists.py failed: " + listTaskListsStderr) 627 + } 628 + listTaskListsStdout = "" 629 + listTaskListsStderr = "" 630 + } 631 + stdout: SplitParser { onRead: data => listTaskListsStdout += data } 632 + stderr: SplitParser { onRead: data => listTaskListsStderr += data } 633 + } 634 + 635 + property string listTodosStdout: "" 636 + property string listTodosStderr: "" 637 + 638 + Process { 639 + id: listTodosProcess 640 + onExited: function(exitCode, exitStatus) { 641 + todosLoading = false 642 + if (exitCode === 0) { 643 + try { 644 + var result = JSON.parse(listTodosStdout) 645 + if (Array.isArray(result)) { 646 + todosModel.clear() 647 + for (var i = 0; i < result.length; i++) { 648 + todosModel.append(result[i]) 649 + } 650 + todoSyncStatus = result.length + (result.length === 1 ? " task" : " tasks") 651 + } 652 + } catch(e) { 653 + console.error("[weekly-calendar] Failed to parse todos: " + listTodosStdout) 654 + todoSyncStatus = pluginApi ? pluginApi.tr("panel.task_error") : "Error" 655 + } 656 + } else { 657 + console.error("[weekly-calendar] list-todos.py failed: " + listTodosStderr) 658 + todoSyncStatus = pluginApi ? pluginApi.tr("panel.task_error") : "Error" 659 + } 660 + listTodosStdout = "" 661 + listTodosStderr = "" 662 + } 663 + stdout: SplitParser { onRead: data => listTodosStdout += data } 664 + stderr: SplitParser { onRead: data => listTodosStderr += data } 665 + } 666 + 667 + property string createTodoStdout: "" 668 + property string createTodoStderr: "" 669 + 670 + Process { 671 + id: createTodoProcess 672 + onExited: function(exitCode, exitStatus) { 673 + if (exitCode === 0) { 674 + try { 675 + var result = JSON.parse(createTodoStdout) 676 + if (result.success) { 677 + console.log("[weekly-calendar] Todo created: " + result.uid) 678 + Qt.callLater(loadTodos) 679 + } 680 + } catch(e) { 681 + console.error("[weekly-calendar] Failed to parse create-todo output: " + createTodoStdout) 682 + } 683 + } else { 684 + console.error("[weekly-calendar] create-todo.py failed: " + createTodoStderr) 685 + } 686 + createTodoStdout = "" 687 + createTodoStderr = "" 688 + } 689 + stdout: SplitParser { onRead: data => createTodoStdout += data } 690 + stderr: SplitParser { onRead: data => createTodoStderr += data } 691 + } 692 + 693 + property string updateTodoStdout: "" 694 + property string updateTodoStderr: "" 695 + 696 + Process { 697 + id: updateTodoProcess 698 + onExited: function(exitCode, exitStatus) { 699 + if (exitCode === 0) { 700 + try { 701 + var result = JSON.parse(updateTodoStdout) 702 + if (result.success) { 703 + console.log("[weekly-calendar] Todo updated") 704 + Qt.callLater(loadTodos) 705 + } 706 + } catch(e) { 707 + console.error("[weekly-calendar] Failed to parse update-todo output: " + updateTodoStdout) 708 + } 709 + } else { 710 + console.error("[weekly-calendar] update-todo.py failed: " + updateTodoStderr) 711 + } 712 + updateTodoStdout = "" 713 + updateTodoStderr = "" 714 + } 715 + stdout: SplitParser { onRead: data => updateTodoStdout += data } 716 + stderr: SplitParser { onRead: data => updateTodoStderr += data } 717 + } 718 + 719 + function loadTaskLists() { 720 + if (!pluginApi) return 721 + var scriptPath = pluginApi.pluginDir + "/scripts/list-task-lists.py" 722 + listTaskListsProcess.command = ["python3", scriptPath] 723 + listTaskListsProcess.running = true 724 + } 725 + 726 + function loadTodos() { 727 + if (!pluginApi) return 728 + todosLoading = true 729 + todoSyncStatus = pluginApi.tr("panel.loading") 730 + var scriptPath = pluginApi.pluginDir + "/scripts/list-todos.py" 731 + var args = ["python3", scriptPath] 732 + if (showCompletedTodos) args.push("--include-completed") 733 + listTodosProcess.command = args 734 + listTodosProcess.running = true 735 + } 736 + 737 + function createTodo(taskListUid, summary, due, priority, description) { 738 + if (!pluginApi) return 739 + var scriptPath = pluginApi.pluginDir + "/scripts/create-todo.py" 740 + var args = ["python3", scriptPath, 741 + "--task-list", taskListUid, 742 + "--summary", summary] 743 + if (due > 0) { args.push("--due"); args.push(String(due)) } 744 + if (priority > 0) { args.push("--priority"); args.push(String(priority)) } 745 + if (description) { args.push("--description"); args.push(description) } 746 + createTodoProcess.command = args 747 + createTodoProcess.running = true 748 + } 749 + 750 + function completeTodo(taskListUid, todoUid) { 751 + if (!pluginApi) return 752 + var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py" 753 + updateTodoProcess.command = ["python3", scriptPath, 754 + "--task-list", taskListUid, "--uid", todoUid, "--action", "complete"] 755 + updateTodoProcess.running = true 756 + } 757 + 758 + function uncompleteTodo(taskListUid, todoUid) { 759 + if (!pluginApi) return 760 + var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py" 761 + updateTodoProcess.command = ["python3", scriptPath, 762 + "--task-list", taskListUid, "--uid", todoUid, "--action", "uncomplete"] 763 + updateTodoProcess.running = true 764 + } 765 + 766 + function deleteTodo(taskListUid, todoUid) { 767 + if (!pluginApi) return 768 + var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py" 769 + updateTodoProcess.command = ["python3", scriptPath, 770 + "--task-list", taskListUid, "--uid", todoUid, "--action", "delete"] 771 + updateTodoProcess.running = true 589 772 } 590 773 }
+454 -35
weekly-calendar/Panel.qml
··· 21 21 anchors.fill: parent 22 22 23 23 property bool showCreateDialog: false 24 + property bool showCreateTaskDialog: false 25 + property int currentTab: 0 // 0 = Calendar, 1 = Tasks 24 26 25 27 property real hourHeight: 50 * Style.uiScaleRatio 26 28 property real timeColumnWidth: 65 * Style.uiScaleRatio ··· 33 35 mainInstance.refreshView() 34 36 mainInstance.goToToday() 35 37 Qt.callLater(root.scrollToCurrentTime) 38 + if (currentTab === 1) mainInstance.loadTodos() 36 39 } 37 40 38 41 // Scroll to time indicator position ··· 41 44 var now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) 42 45 var weekStart = new Date(mainInstance.weekStart) 43 46 var weekEnd = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7) 44 - 47 + 45 48 if (today >= weekStart && today < weekEnd) { 46 49 var currentHour = now.getHours() + now.getMinutes() / 60 47 50 var scrollPos = (currentHour * hourHeight) - (calendarFlickable.height / 2) ··· 50 53 scrollAnim.start() 51 54 } 52 55 } 53 - 56 + 57 + function formatTodoDueDate(dueStr) { 58 + if (!dueStr) return "" 59 + var due = new Date(dueStr) 60 + if (isNaN(due.getTime())) return "" 61 + var now = new Date() 62 + var today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) 63 + var dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()) 64 + var diffDays = Math.floor((dueDay - today) / 86400000) 65 + if (diffDays < 0) return pluginApi.tr("panel.overdue") 66 + if (diffDays === 0) return pluginApi.tr("panel.today") 67 + return I18n.locale.toString(due, "MMM d") 68 + } 69 + 70 + function isTodoOverdue(dueStr, status) { 71 + if (!dueStr || status === "COMPLETED") return false 72 + var due = new Date(dueStr) 73 + return due < new Date() 74 + } 75 + 76 + function priorityColor(priority) { 77 + if (priority >= 1 && priority <= 4) return Color.mError 78 + if (priority === 5) return Color.mTertiary 79 + if (priority >= 6 && priority <= 9) return Color.mPrimary 80 + return "transparent" 81 + } 82 + 54 83 // Event creation dialog 55 84 Rectangle { 56 85 id: createEventOverlay ··· 204 233 } 205 234 } 206 235 236 + // Task creation dialog 237 + Rectangle { 238 + id: createTaskOverlay 239 + anchors.fill: parent 240 + color: Qt.rgba(0, 0, 0, 0.5) 241 + visible: showCreateTaskDialog 242 + z: 2000 243 + 244 + MouseArea { anchors.fill: parent; onClicked: showCreateTaskDialog = false } 245 + 246 + Rectangle { 247 + anchors.centerIn: parent 248 + width: 400 * Style.uiScaleRatio 249 + height: createTaskDialogColumn.implicitHeight + 2 * Style.marginM 250 + color: Color.mSurface 251 + radius: Style.radiusM 252 + 253 + MouseArea { anchors.fill: parent } 254 + 255 + ColumnLayout { 256 + id: createTaskDialogColumn 257 + anchors.fill: parent 258 + anchors.margins: Style.marginM 259 + spacing: Style.marginS 260 + 261 + NText { 262 + text: pluginApi.tr("panel.add_task") 263 + font.pointSize: Style.fontSizeL; font.weight: Font.Bold 264 + color: Color.mOnSurface 265 + } 266 + 267 + NText { text: pluginApi.tr("panel.task_summary"); color: Color.mOnSurfaceVariant; font.pointSize: Style.fontSizeS } 268 + TextField { 269 + id: createTaskSummary 270 + Layout.fillWidth: true 271 + placeholderText: pluginApi.tr("panel.task_summary") 272 + color: Color.mOnSurface 273 + background: Rectangle { color: Color.mSurfaceVariant; radius: Style.radiusS } 274 + } 275 + 276 + NText { text: pluginApi.tr("panel.due_date"); color: Color.mOnSurfaceVariant; font.pointSize: Style.fontSizeS } 277 + TextField { 278 + id: createTaskDueDate 279 + Layout.fillWidth: true 280 + placeholderText: "YYYY-MM-DD HH:MM" 281 + color: Color.mOnSurface 282 + background: Rectangle { color: Color.mSurfaceVariant; radius: Style.radiusS } 283 + } 284 + 285 + NText { text: pluginApi.tr("panel.description"); color: Color.mOnSurfaceVariant; font.pointSize: Style.fontSizeS } 286 + TextField { 287 + id: createTaskDescription 288 + Layout.fillWidth: true 289 + placeholderText: pluginApi.tr("panel.description") 290 + color: Color.mOnSurface 291 + background: Rectangle { color: Color.mSurfaceVariant; radius: Style.radiusS } 292 + } 293 + 294 + NText { text: pluginApi.tr("panel.priority"); color: Color.mOnSurfaceVariant; font.pointSize: Style.fontSizeS } 295 + property int selectedPriority: 0 296 + RowLayout { 297 + spacing: Style.marginS 298 + Repeater { 299 + model: [ 300 + { label: pluginApi.tr("panel.priority_high"), value: 1 }, 301 + { label: pluginApi.tr("panel.priority_medium"), value: 5 }, 302 + { label: pluginApi.tr("panel.priority_low"), value: 9 } 303 + ] 304 + Rectangle { 305 + Layout.preferredWidth: priLabel.implicitWidth + 2 * Style.marginM 306 + Layout.preferredHeight: priLabel.implicitHeight + Style.marginS 307 + color: createTaskDialogColumn.selectedPriority === modelData.value ? Color.mPrimary : Color.mSurfaceVariant 308 + radius: Style.radiusS 309 + NText { 310 + id: priLabel; anchors.centerIn: parent 311 + text: modelData.label 312 + color: createTaskDialogColumn.selectedPriority === modelData.value ? Color.mOnPrimary : Color.mOnSurfaceVariant 313 + font.weight: Font.Medium 314 + } 315 + MouseArea { 316 + anchors.fill: parent; cursorShape: Qt.PointingHandCursor 317 + onClicked: createTaskDialogColumn.selectedPriority = 318 + createTaskDialogColumn.selectedPriority === modelData.value ? 0 : modelData.value 319 + } 320 + } 321 + } 322 + } 323 + 324 + NText { text: pluginApi.tr("panel.task_list_select"); color: Color.mOnSurfaceVariant; font.pointSize: Style.fontSizeS } 325 + ComboBox { 326 + id: taskListSelector 327 + Layout.fillWidth: true 328 + model: mainInstance?.taskLists || [] 329 + textRole: "name" 330 + background: Rectangle { color: Color.mSurfaceVariant; radius: Style.radiusS } 331 + } 332 + 333 + RowLayout { 334 + Layout.fillWidth: true 335 + spacing: Style.marginS 336 + 337 + Item { Layout.fillWidth: true } 338 + 339 + Rectangle { 340 + Layout.preferredWidth: taskCancelBtn.implicitWidth + 2 * Style.marginM 341 + Layout.preferredHeight: taskCancelBtn.implicitHeight + Style.marginS 342 + color: Color.mSurfaceVariant; radius: Style.radiusS 343 + NText { 344 + id: taskCancelBtn; anchors.centerIn: parent 345 + text: pluginApi.tr("panel.cancel"); color: Color.mOnSurfaceVariant 346 + } 347 + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: showCreateTaskDialog = false } 348 + } 349 + 350 + Rectangle { 351 + Layout.preferredWidth: taskCreateBtn.implicitWidth + 2 * Style.marginM 352 + Layout.preferredHeight: taskCreateBtn.implicitHeight + Style.marginS 353 + color: Color.mPrimary; radius: Style.radiusS 354 + opacity: createTaskSummary.text.trim() !== "" ? 1.0 : 0.5 355 + NText { 356 + id: taskCreateBtn; anchors.centerIn: parent 357 + text: pluginApi.tr("panel.create"); color: Color.mOnPrimary; font.weight: Font.Bold 358 + } 359 + MouseArea { 360 + anchors.fill: parent; cursorShape: Qt.PointingHandCursor 361 + onClicked: { 362 + if (createTaskSummary.text.trim() === "") return 363 + var tl = mainInstance?.taskLists?.[taskListSelector.currentIndex] 364 + var tlUid = tl?.uid || "" 365 + var dueTs = 0 366 + if (createTaskDueDate.text.trim() !== "") { 367 + var d = new Date(createTaskDueDate.text.trim()) 368 + if (!isNaN(d.getTime())) dueTs = Math.floor(d.getTime() / 1000) 369 + } 370 + mainInstance?.createTodo(tlUid, createTaskSummary.text.trim(), 371 + dueTs, createTaskDialogColumn.selectedPriority, 372 + createTaskDescription.text.trim()) 373 + showCreateTaskDialog = false 374 + } 375 + } 376 + } 377 + } 378 + } 379 + } 380 + } 381 + 207 382 // UI 208 383 Rectangle { 209 384 id: panelContainer 210 385 anchors.fill: parent 211 386 color: "transparent" 212 - 387 + 213 388 ColumnLayout { 214 389 anchors.fill: parent 215 390 anchors.margins: Style.marginM ··· 226 401 RowLayout { 227 402 anchors.margins: Style.marginM 228 403 anchors.fill: parent 229 - 230 - NIcon { icon: "calendar-week"; pointSize: Style.fontSizeXXL; color: Color.mPrimary } 404 + 405 + NIcon { icon: currentTab === 0 ? "calendar-week" : "clipboard-check"; pointSize: Style.fontSizeXXL; color: Color.mPrimary } 231 406 232 407 ColumnLayout { 233 408 Layout.fillHeight: true ··· 239 414 RowLayout { 240 415 spacing: Style.marginS 241 416 NText { 242 - text: mainInstance?.monthRangeText || "" 417 + text: currentTab === 0 ? (mainInstance?.monthRangeText || "") : (mainInstance?.todoSyncStatus || "") 243 418 font.pointSize: Style.fontSizeS; font.weight: Font.Medium; color: Color.mOnSurfaceVariant 244 419 } 245 420 Rectangle { 246 421 Layout.preferredWidth: 8; Layout.preferredHeight: 8; radius: 4 247 - color: mainInstance?.isLoading ? Color.mError : 248 - mainInstance?.syncStatus?.includes("No") ? Color.mError : Color.mOnSurfaceVariant 422 + color: { 423 + if (currentTab === 0) { 424 + return mainInstance?.isLoading ? Color.mError : 425 + mainInstance?.syncStatus?.includes("No") ? Color.mError : Color.mOnSurfaceVariant 426 + } else { 427 + return mainInstance?.todosLoading ? Color.mError : Color.mOnSurfaceVariant 428 + } 429 + } 249 430 } 250 431 NText { 251 - text: mainInstance?.syncStatus || "" 432 + text: currentTab === 0 ? (mainInstance?.syncStatus || "") : "" 252 433 font.pointSize: Style.fontSizeS; color: Color.mOnSurfaceVariant 253 434 } 254 435 } ··· 258 439 259 440 RowLayout { 260 441 spacing: Style.marginS 442 + // Calendar-specific buttons 261 443 NIconButton { 444 + visible: currentTab === 0 262 445 icon: "plus"; tooltipText: pluginApi.tr("panel.add_event") 263 446 onClicked: { 264 447 createEventSummary.text = "" ··· 273 456 } 274 457 } 275 458 NIconButton { 459 + visible: currentTab === 0 276 460 icon: "chevron-left" 277 461 onClicked: mainInstance?.navigateWeek(-7) 278 462 } 279 463 NIconButton { 464 + visible: currentTab === 0 280 465 icon: "calendar"; tooltipText: pluginApi.tr("panel.today") 281 466 onClicked: { mainInstance?.goToToday(); Qt.callLater(root.scrollToCurrentTime) } 282 467 } 283 468 NIconButton { 469 + visible: currentTab === 0 284 470 icon: "chevron-right" 285 471 onClicked: mainInstance?.navigateWeek(7) 286 472 } 473 + // Tasks-specific buttons 474 + NIconButton { 475 + visible: currentTab === 1 476 + icon: "plus"; tooltipText: pluginApi.tr("panel.add_task") 477 + onClicked: { 478 + createTaskSummary.text = "" 479 + createTaskDueDate.text = "" 480 + createTaskDescription.text = "" 481 + createTaskDialogColumn.selectedPriority = 0 482 + showCreateTaskDialog = true 483 + } 484 + } 485 + NIconButton { 486 + visible: currentTab === 1 487 + icon: mainInstance?.showCompletedTodos ? "eye-off" : "eye" 488 + tooltipText: pluginApi.tr("panel.show_completed") 489 + onClicked: { 490 + if (mainInstance) { 491 + mainInstance.showCompletedTodos = !mainInstance.showCompletedTodos 492 + mainInstance.loadTodos() 493 + } 494 + } 495 + } 496 + // Shared buttons 287 497 NIconButton { 288 498 icon: "refresh"; tooltipText: I18n.tr("common.refresh") 289 - onClicked: mainInstance?.loadEvents() 290 - enabled: mainInstance ? !mainInstance.isLoading : false 499 + onClicked: { 500 + if (currentTab === 0) mainInstance?.loadEvents() 501 + else mainInstance?.loadTodos() 502 + } 503 + enabled: currentTab === 0 ? (mainInstance ? !mainInstance.isLoading : false) 504 + : (mainInstance ? !mainInstance.todosLoading : false) 291 505 } 292 506 NIconButton { 293 507 icon: "close"; tooltipText: I18n.tr("common.close") ··· 297 511 } 298 512 } 299 513 300 - // Calendar 514 + // Tab Bar 515 + Rectangle { 516 + Layout.fillWidth: true 517 + Layout.preferredHeight: 36 * Style.uiScaleRatio 518 + color: Color.mSurfaceVariant 519 + radius: Style.radiusM 520 + 521 + RowLayout { 522 + anchors.fill: parent 523 + anchors.margins: 3 524 + spacing: 3 525 + 526 + Repeater { 527 + model: [ 528 + { text: pluginApi.tr("panel.calendar_tab"), icon: "calendar", idx: 0 }, 529 + { text: pluginApi.tr("panel.tasks_tab"), icon: "clipboard-check", idx: 1 } 530 + ] 531 + Rectangle { 532 + Layout.fillWidth: true 533 + Layout.fillHeight: true 534 + color: currentTab === modelData.idx ? Color.mSurface : "transparent" 535 + radius: Style.radiusS 536 + 537 + RowLayout { 538 + anchors.centerIn: parent 539 + spacing: Style.marginS 540 + NIcon { 541 + icon: modelData.icon 542 + pointSize: Style.fontSizeS 543 + color: currentTab === modelData.idx ? Color.mPrimary : Color.mOnSurfaceVariant 544 + } 545 + NText { 546 + text: modelData.text 547 + color: currentTab === modelData.idx ? Color.mPrimary : Color.mOnSurfaceVariant 548 + font.pointSize: Style.fontSizeS 549 + font.weight: currentTab === modelData.idx ? Font.Bold : Font.Medium 550 + } 551 + } 552 + MouseArea { 553 + anchors.fill: parent; cursorShape: Qt.PointingHandCursor 554 + onClicked: { 555 + currentTab = modelData.idx 556 + if (modelData.idx === 1 && mainInstance) mainInstance.loadTodos() 557 + } 558 + } 559 + } 560 + } 561 + } 562 + } 563 + 564 + // Calendar View 301 565 Rectangle { 302 566 Layout.fillWidth: true 303 567 Layout.fillHeight: true 304 568 color: Color.mSurfaceVariant 305 569 radius: Style.radiusM 306 570 clip: true 571 + visible: currentTab === 0 307 572 308 573 Column { 309 574 anchors.fill: parent ··· 316 581 height: 56 317 582 color: Color.mSurfaceVariant 318 583 radius: Style.radiusM 319 - 584 + 320 585 Row { 321 586 anchors.fill: parent 322 587 anchors.leftMargin: root.timeColumnWidth 323 588 spacing: root.daySpacing 324 - 589 + 325 590 Repeater { 326 591 model: 7 327 592 Rectangle { ··· 370 635 height: mainInstance ? Math.round(mainInstance.allDaySectionHeight * Style.uiScaleRatio) : 0 371 636 color: Color.mSurfaceVariant 372 637 visible: height > 0 373 - 638 + 374 639 Item { 375 640 id: allDayEventsContainer 376 641 anchors.fill: parent 377 642 anchors.leftMargin: root.timeColumnWidth 378 - 643 + 379 644 Repeater { 380 645 model: 6 381 646 delegate: Rectangle { ··· 384 649 color: Qt.alpha(mainInstance?.lineColor || Color.mOutline, mainInstance?.dayLineOpacitySetting || 0.9) 385 650 } 386 651 } 387 - 652 + 388 653 Repeater { 389 654 model: mainInstance?.allDayEventsWithLayout || [] 390 655 delegate: Item { ··· 393 658 y: eventData.lane * 25 394 659 width: (eventData.spanDays * ((mainInstance?.dayColumnWidth) + (root.daySpacing))) - (root.daySpacing) 395 660 height: 24 396 - 661 + 397 662 Rectangle { 398 663 anchors.fill: parent 399 664 color: Color.mTertiary ··· 421 686 } 422 687 } 423 688 } 424 - // Calendar flickable 689 + // Calendar flickable 425 690 Rectangle { 426 691 width: parent.width 427 692 height: parent.height - dayHeaders.height - (allDayEventsSection.visible ? allDayEventsSection.height : 0) 428 693 color: Color.mSurfaceVariant 429 694 radius: Style.radiusM 430 695 clip: true 431 - 696 + 432 697 Flickable { 433 698 id: calendarFlickable 434 699 anchors.fill: parent ··· 439 704 Component.onCompleted: { 440 705 calendarFlickable.forceActiveFocus() 441 706 } 442 - 707 + 443 708 // Keyboard interaction 444 709 Keys.onPressed: function(event) { 445 710 if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) { ··· 456 721 event.accepted = true 457 722 } 458 723 } 459 - 724 + 460 725 NumberAnimation { 461 726 id: scrollAnim 462 727 target: calendarFlickable; property: "contentY"; duration: 100 ··· 468 733 Row { 469 734 width: parent.width 470 735 height: parent.height 471 - 736 + 472 737 // Time Column 473 738 Column { 474 739 width: root.timeColumnWidth ··· 497 762 } 498 763 } 499 764 } 500 - 765 + 501 766 // Hour Rectangles 502 767 Item { 503 768 width: 7 * ((mainInstance?.dayColumnWidth) + (root.daySpacing)) 504 769 height: parent.height 505 - 770 + 506 771 Row { 507 772 anchors.fill: parent 508 773 spacing: root.daySpacing ··· 536 801 color: Qt.alpha(mainInstance?.lineColor || Color.mOutline, mainInstance?.dayLineOpacitySetting || 0.9) 537 802 } 538 803 } 539 - 804 + 540 805 // Event positioning 541 806 Repeater { 542 807 model: mainInstance?.eventsModel ··· 546 811 property real startHour: model.startTime.getHours() + model.startTime.getMinutes() / 60 547 812 property real endHour: model.endTime.getHours() + model.endTime.getMinutes() / 60 548 813 property real duration: Math.max(0, (model.endTime - model.startTime) / 3600000) 549 - 814 + 550 815 property real exactHeight: Math.max(1, duration * (mainInstance?.hourHeight || 50) - 1) 551 816 property bool isCompact: exactHeight < 40 552 817 property var overlapInfo: mainInstance?.overlappingEventsData?.[index] ?? { ··· 561 826 x: dayIndex * ((mainInstance?.dayColumnWidth) + (root.daySpacing)) + eventXOffset 562 827 y: startHour * (mainInstance?.hourHeight || 50) 563 828 z: 100 + overlapInfo.lane 564 - 829 + 565 830 Rectangle { 566 831 anchors.fill: parent 567 832 color: Color.mPrimary ··· 583 848 sourceComponent: isCompact ? compactLayout : normalLayout 584 849 } 585 850 } 586 - 851 + 587 852 Component { 588 853 id: normalLayout 589 854 Column { ··· 612 877 } 613 878 } 614 879 } 615 - 880 + 616 881 Component { 617 882 id: compactLayout 618 883 NText { 619 - text: exactHeight < 15 ? model.title : 884 + text: exactHeight < 15 ? model.title : 620 885 model.title + " • " + (mainInstance?.formatTimeRangeForDisplay(model) || "") 621 886 color: Color.mOnPrimary 622 887 font.pointSize: exactHeight < 15 ? Style.fontSizeXXS : Style.fontSizeXS ··· 625 890 width: parent.width - 3 626 891 } 627 892 } 628 - 893 + 629 894 MouseArea { 630 895 anchors.fill: parent 631 896 hoverEnabled: true ··· 645 910 property var now: new Date() 646 911 property date today: new Date(now.getFullYear(), now.getMonth(), now.getDate()) 647 912 property date weekStartDate: mainInstance?.weekStart ?? new Date() 648 - property date weekEndDate: mainInstance ? 913 + property date weekEndDate: mainInstance ? 649 914 new Date(mainInstance.weekStart.getFullYear(), mainInstance.weekStart.getMonth(), mainInstance.weekStart.getDate() + 7) : new Date() 650 915 property bool inCurrentWeek: today >= weekStartDate && today < weekEndDate 651 916 property int currentDay: mainInstance?.getDayIndexForDate(now) ?? -1 652 917 property real currentHour: now.getHours() + now.getMinutes() / 60 653 - 918 + 654 919 visible: inCurrentWeek && currentDay >= 0 655 920 width: mainInstance?.dayColumnWidth 656 921 height: 2 ··· 674 939 } 675 940 } 676 941 } 942 + 943 + // Tasks View 944 + Rectangle { 945 + Layout.fillWidth: true 946 + Layout.fillHeight: true 947 + color: Color.mSurfaceVariant 948 + radius: Style.radiusM 949 + clip: true 950 + visible: currentTab === 1 951 + 952 + ColumnLayout { 953 + anchors.fill: parent 954 + spacing: 0 955 + 956 + // Empty state 957 + Item { 958 + Layout.fillWidth: true 959 + Layout.fillHeight: true 960 + visible: mainInstance?.todosModel.count === 0 && !mainInstance?.todosLoading 961 + 962 + ColumnLayout { 963 + anchors.centerIn: parent 964 + spacing: Style.marginM 965 + NIcon { 966 + Layout.alignment: Qt.AlignHCenter 967 + icon: "clipboard-check" 968 + pointSize: Style.fontSizeXXL * 2 969 + color: Color.mOnSurfaceVariant 970 + opacity: 0.4 971 + } 972 + NText { 973 + Layout.alignment: Qt.AlignHCenter 974 + text: pluginApi.tr("panel.no_tasks") 975 + color: Color.mOnSurfaceVariant 976 + font.pointSize: Style.fontSizeM 977 + } 978 + } 979 + } 980 + 981 + // Task list 982 + ListView { 983 + id: todosListView 984 + Layout.fillWidth: true 985 + Layout.fillHeight: true 986 + model: mainInstance?.todosModel 987 + clip: true 988 + visible: mainInstance?.todosModel.count > 0 || mainInstance?.todosLoading 989 + spacing: 1 990 + 991 + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } 992 + 993 + delegate: Rectangle { 994 + width: todosListView.width 995 + height: todoRow.implicitHeight + 2 * Style.marginS 996 + color: todoMouseArea.containsMouse ? Qt.alpha(Color.mSurface, 0.5) : "transparent" 997 + 998 + RowLayout { 999 + id: todoRow 1000 + anchors.fill: parent 1001 + anchors.margins: Style.marginS 1002 + anchors.leftMargin: Style.marginM 1003 + anchors.rightMargin: Style.marginM 1004 + spacing: Style.marginS 1005 + 1006 + // Priority indicator 1007 + Rectangle { 1008 + Layout.preferredWidth: 4 1009 + Layout.fillHeight: true 1010 + Layout.topMargin: 2 1011 + Layout.bottomMargin: 2 1012 + radius: 2 1013 + color: root.priorityColor(model.priority) 1014 + } 1015 + 1016 + // Checkbox 1017 + Rectangle { 1018 + Layout.preferredWidth: 22; Layout.preferredHeight: 22 1019 + Layout.alignment: Qt.AlignVCenter 1020 + radius: 4 1021 + color: model.status === "COMPLETED" ? Color.mPrimary : "transparent" 1022 + border.width: 2 1023 + border.color: model.status === "COMPLETED" ? Color.mPrimary : Color.mOnSurfaceVariant 1024 + 1025 + NIcon { 1026 + anchors.centerIn: parent 1027 + icon: "check" 1028 + pointSize: Style.fontSizeXS 1029 + color: Color.mOnPrimary 1030 + visible: model.status === "COMPLETED" 1031 + } 1032 + MouseArea { 1033 + anchors.fill: parent; cursorShape: Qt.PointingHandCursor 1034 + onClicked: { 1035 + if (model.status === "COMPLETED") 1036 + mainInstance?.uncompleteTodo(model.calendarUid, model.uid) 1037 + else 1038 + mainInstance?.completeTodo(model.calendarUid, model.uid) 1039 + } 1040 + } 1041 + } 1042 + 1043 + // Task content 1044 + ColumnLayout { 1045 + Layout.fillWidth: true 1046 + spacing: 2 1047 + NText { 1048 + Layout.fillWidth: true 1049 + text: model.summary 1050 + color: model.status === "COMPLETED" ? Color.mOnSurfaceVariant : Color.mOnSurface 1051 + font.pointSize: Style.fontSizeS 1052 + font.strikeout: model.status === "COMPLETED" 1053 + elide: Text.ElideRight 1054 + } 1055 + RowLayout { 1056 + spacing: Style.marginS 1057 + visible: model.due !== "" && model.due !== undefined && model.due !== null 1058 + NIcon { 1059 + icon: "clock" 1060 + pointSize: Style.fontSizeXXS 1061 + color: root.isTodoOverdue(model.due, model.status) ? Color.mError : Color.mOnSurfaceVariant 1062 + } 1063 + NText { 1064 + text: root.formatTodoDueDate(model.due) 1065 + font.pointSize: Style.fontSizeXXS 1066 + color: root.isTodoOverdue(model.due, model.status) ? Color.mError : Color.mOnSurfaceVariant 1067 + } 1068 + NText { 1069 + text: model.calendarName 1070 + font.pointSize: Style.fontSizeXXS 1071 + color: Color.mOnSurfaceVariant 1072 + opacity: 0.7 1073 + } 1074 + } 1075 + } 1076 + 1077 + // Delete button 1078 + NIconButton { 1079 + icon: "x" 1080 + tooltipText: I18n.tr("common.delete") || "Delete" 1081 + visible: todoMouseArea.containsMouse 1082 + onClicked: mainInstance?.deleteTodo(model.calendarUid, model.uid) 1083 + } 1084 + } 1085 + 1086 + MouseArea { 1087 + id: todoMouseArea 1088 + anchors.fill: parent 1089 + hoverEnabled: true 1090 + acceptedButtons: Qt.NoButton 1091 + } 1092 + } 1093 + } 1094 + } 1095 + } 677 1096 } 678 1097 } 679 - } 1098 + }
+16 -1
weekly-calendar/i18n/en.json
··· 25 25 "description": "Description", 26 26 "calendar_select": "Calendar", 27 27 "event_created": "Event created", 28 - "event_error": "Failed to create event" 28 + "event_error": "Failed to create event", 29 + "calendar_tab": "Calendar", 30 + "tasks_tab": "Tasks", 31 + "add_task": "Add Task", 32 + "task_summary": "Task Summary", 33 + "due_date": "Due Date (optional)", 34 + "priority": "Priority", 35 + "priority_high": "H", 36 + "priority_medium": "M", 37 + "priority_low": "L", 38 + "task_list_select": "Task List", 39 + "task_created": "Task created", 40 + "task_error": "Failed to create task", 41 + "no_tasks": "No tasks", 42 + "show_completed": "Show completed", 43 + "overdue": "Overdue" 29 44 }, 30 45 "settings": { 31 46 "weekStart": "First day of week",
+16 -1
weekly-calendar/i18n/zh-CN.json
··· 25 25 "description": "描述", 26 26 "calendar_select": "日历", 27 27 "event_created": "活动已创建", 28 - "event_error": "创建活动失败" 28 + "event_error": "创建活动失败", 29 + "calendar_tab": "日历", 30 + "tasks_tab": "任务", 31 + "add_task": "添加任务", 32 + "task_summary": "任务摘要", 33 + "due_date": "截止日期(可选)", 34 + "priority": "优先级", 35 + "priority_high": "高", 36 + "priority_medium": "中", 37 + "priority_low": "低", 38 + "task_list_select": "任务列表", 39 + "task_created": "任务已创建", 40 + "task_error": "创建任务失败", 41 + "no_tasks": "没有任务", 42 + "show_completed": "显示已完成", 43 + "overdue": "已逾期" 29 44 }, 30 45 "settings": { 31 46 "weekStart": "一周起始日",
+87
weekly-calendar/scripts/create-todo.py
··· 1 + #!/usr/bin/env python3 2 + """Create a VTODO item via Evolution Data Server.""" 3 + 4 + import argparse 5 + import json 6 + import sys 7 + from datetime import datetime, timezone 8 + 9 + import gi 10 + gi.require_version("ECal", "2.0") 11 + gi.require_version("EDataServer", "1.2") 12 + gi.require_version("ICalGLib", "3.0") 13 + from gi.repository import ECal, EDataServer, ICalGLib 14 + 15 + 16 + def find_task_source(registry, task_list_uid): 17 + source = registry.ref_source(task_list_uid) 18 + if source and source.has_extension(EDataServer.SOURCE_EXTENSION_TASK_LIST): 19 + return source 20 + for src in registry.list_sources(EDataServer.SOURCE_EXTENSION_TASK_LIST): 21 + if src.get_display_name() == task_list_uid or src.get_uid() == task_list_uid: 22 + return src 23 + return None 24 + 25 + 26 + def make_ical_datetime(timestamp): 27 + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone() 28 + ical_time = ICalGLib.Time.new_null_time() 29 + ical_time.set_date(dt.year, dt.month, dt.day) 30 + ical_time.set_time(dt.hour, dt.minute, dt.second) 31 + tz_id = dt.strftime("%Z") 32 + builtin_tz = ICalGLib.Timezone.get_builtin_timezone(tz_id) 33 + if builtin_tz: 34 + ical_time.set_timezone(builtin_tz) 35 + else: 36 + ical_time.set_timezone(ICalGLib.Timezone.get_utc_timezone()) 37 + return ical_time 38 + 39 + 40 + def main(): 41 + parser = argparse.ArgumentParser(description="Create EDS VTODO item") 42 + parser.add_argument("--task-list", required=True, help="Task list UID") 43 + parser.add_argument("--summary", required=True, help="Task summary") 44 + parser.add_argument("--due", type=int, default=0, help="Due date (UNIX timestamp)") 45 + parser.add_argument("--priority", type=int, default=0, help="Priority (0-9)") 46 + parser.add_argument("--description", default="", help="Task description") 47 + args = parser.parse_args() 48 + 49 + try: 50 + registry = EDataServer.SourceRegistry.new_sync(None) 51 + source = find_task_source(registry, args.task_list) 52 + if not source: 53 + print(json.dumps({"success": False, "error": f"Task list not found: {args.task_list}"})) 54 + sys.exit(1) 55 + 56 + client = ECal.Client.connect_sync( 57 + source, ECal.ClientSourceType.TASKS, -1, None 58 + ) 59 + 60 + comp = ICalGLib.Component.new(ICalGLib.ComponentKind.VTODO_COMPONENT) 61 + comp.set_summary(args.summary) 62 + comp.set_status(ICalGLib.PropertyStatus.NEEDSACTION) 63 + 64 + if args.due > 0: 65 + comp.set_due(make_ical_datetime(args.due)) 66 + 67 + if args.priority > 0: 68 + prop = ICalGLib.Property.new_priority(args.priority) 69 + comp.add_property(prop) 70 + 71 + if args.description: 72 + comp.set_description(args.description) 73 + 74 + # Set PERCENT-COMPLETE to 0 75 + prop = ICalGLib.Property.new_percentcomplete(0) 76 + comp.add_property(prop) 77 + 78 + uid = client.create_object_sync(comp, ECal.OperationFlags.NONE, None) 79 + print(json.dumps({"success": True, "uid": uid})) 80 + 81 + except Exception as e: 82 + print(json.dumps({"success": False, "error": str(e)})) 83 + sys.exit(1) 84 + 85 + 86 + if __name__ == "__main__": 87 + main()
+32
weekly-calendar/scripts/list-task-lists.py
··· 1 + #!/usr/bin/env python3 2 + """List task lists (VTODO sources) from Evolution Data Server.""" 3 + 4 + import json 5 + import sys 6 + 7 + import gi 8 + gi.require_version("EDataServer", "1.2") 9 + from gi.repository import EDataServer 10 + 11 + 12 + def main(): 13 + try: 14 + registry = EDataServer.SourceRegistry.new_sync(None) 15 + task_lists = [] 16 + 17 + for source in registry.list_sources(EDataServer.SOURCE_EXTENSION_TASK_LIST): 18 + task_lists.append({ 19 + "uid": source.get_uid(), 20 + "name": source.get_display_name(), 21 + "enabled": source.get_enabled(), 22 + }) 23 + 24 + print(json.dumps(task_lists)) 25 + 26 + except Exception as e: 27 + print(json.dumps({"error": str(e)}), file=sys.stderr) 28 + sys.exit(1) 29 + 30 + 31 + if __name__ == "__main__": 32 + main()
+123
weekly-calendar/scripts/list-todos.py
··· 1 + #!/usr/bin/env python3 2 + """List VTODO items from Evolution Data Server task lists.""" 3 + 4 + import argparse 5 + import json 6 + import sys 7 + from datetime import datetime, timezone 8 + 9 + import gi 10 + gi.require_version("ECal", "2.0") 11 + gi.require_version("EDataServer", "1.2") 12 + gi.require_version("ICalGLib", "3.0") 13 + from gi.repository import ECal, EDataServer, ICalGLib 14 + 15 + 16 + def ical_time_to_iso(ical_time): 17 + """Convert ICalTime to ISO 8601 string, or None if null/invalid.""" 18 + if not ical_time or ical_time.is_null_time(): 19 + return None 20 + y = ical_time.get_year() 21 + m = ical_time.get_month() 22 + d = ical_time.get_day() 23 + h = ical_time.get_hour() 24 + mi = ical_time.get_minute() 25 + s = ical_time.get_second() 26 + try: 27 + dt = datetime(y, m, d, h, mi, s) 28 + return dt.isoformat() 29 + except (ValueError, OverflowError): 30 + return None 31 + 32 + 33 + def get_status_string(status): 34 + mapping = { 35 + ICalGLib.PropertyStatus.NEEDSACTION: "NEEDS-ACTION", 36 + ICalGLib.PropertyStatus.COMPLETED: "COMPLETED", 37 + ICalGLib.PropertyStatus.INPROCESS: "IN-PROCESS", 38 + ICalGLib.PropertyStatus.CANCELLED: "CANCELLED", 39 + } 40 + return mapping.get(status, "NEEDS-ACTION") 41 + 42 + 43 + def main(): 44 + parser = argparse.ArgumentParser(description="List VTODO items from EDS") 45 + parser.add_argument("--include-completed", action="store_true", 46 + help="Include completed tasks") 47 + args = parser.parse_args() 48 + 49 + try: 50 + registry = EDataServer.SourceRegistry.new_sync(None) 51 + todos = [] 52 + 53 + for source in registry.list_sources(EDataServer.SOURCE_EXTENSION_TASK_LIST): 54 + if not source.get_enabled(): 55 + continue 56 + 57 + try: 58 + client = ECal.Client.connect_sync( 59 + source, ECal.ClientSourceType.TASKS, -1, None 60 + ) 61 + except Exception: 62 + continue 63 + 64 + # #t matches all objects 65 + success, result = client.get_object_list_as_comps_sync("#t", None) 66 + if not success: 67 + continue 68 + 69 + cal_name = source.get_display_name() 70 + cal_uid = source.get_uid() 71 + 72 + for comp in result: 73 + if comp.get_vtype() != ECal.ComponentVType.TODO: 74 + continue 75 + 76 + ical = comp.get_icalcomponent() 77 + status = get_status_string(ical.get_status()) 78 + 79 + if not args.include_completed and status == "COMPLETED": 80 + continue 81 + 82 + due = ical_time_to_iso(ical.get_due()) 83 + dtstart = ical_time_to_iso(ical.get_dtstart()) 84 + 85 + # Get percent-complete 86 + percent = 0 87 + prop = ical.get_first_property(ICalGLib.PropertyKind.PERCENTCOMPLETE_PROPERTY) 88 + if prop: 89 + percent = prop.get_percentcomplete() 90 + 91 + # Get priority 92 + priority = ical.get_priority() if hasattr(ical, 'get_priority') else 0 93 + # Fallback: read priority property directly 94 + if priority == 0: 95 + prop = ical.get_first_property(ICalGLib.PropertyKind.PRIORITY_PROPERTY) 96 + if prop: 97 + priority = prop.get_priority() 98 + 99 + todos.append({ 100 + "uid": ical.get_uid(), 101 + "summary": ical.get_summary() or "", 102 + "description": ical.get_description() or "", 103 + "due": due, 104 + "dtstart": dtstart, 105 + "status": status, 106 + "priority": priority, 107 + "percentComplete": percent, 108 + "calendarName": cal_name, 109 + "calendarUid": cal_uid, 110 + }) 111 + 112 + # Sort: non-null due dates first (ascending), then null-due items 113 + todos.sort(key=lambda t: (t["due"] is None, t["due"] or "")) 114 + 115 + print(json.dumps(todos)) 116 + 117 + except Exception as e: 118 + print(json.dumps({"error": str(e)}), file=sys.stderr) 119 + sys.exit(1) 120 + 121 + 122 + if __name__ == "__main__": 123 + main()
+104
weekly-calendar/scripts/update-todo.py
··· 1 + #!/usr/bin/env python3 2 + """Update or delete a VTODO item via Evolution Data Server.""" 3 + 4 + import argparse 5 + import json 6 + import sys 7 + from datetime import datetime, timezone 8 + 9 + import gi 10 + gi.require_version("ECal", "2.0") 11 + gi.require_version("EDataServer", "1.2") 12 + gi.require_version("ICalGLib", "3.0") 13 + from gi.repository import ECal, EDataServer, ICalGLib 14 + 15 + 16 + def find_task_source(registry, task_list_uid): 17 + source = registry.ref_source(task_list_uid) 18 + if source and source.has_extension(EDataServer.SOURCE_EXTENSION_TASK_LIST): 19 + return source 20 + for src in registry.list_sources(EDataServer.SOURCE_EXTENSION_TASK_LIST): 21 + if src.get_display_name() == task_list_uid or src.get_uid() == task_list_uid: 22 + return src 23 + return None 24 + 25 + 26 + def remove_property(comp, kind): 27 + """Remove all properties of the given kind from a component.""" 28 + prop = comp.get_first_property(kind) 29 + while prop: 30 + comp.remove_property(prop) 31 + prop = comp.get_first_property(kind) 32 + 33 + 34 + def main(): 35 + parser = argparse.ArgumentParser(description="Update/delete EDS VTODO item") 36 + parser.add_argument("--task-list", required=True, help="Task list UID") 37 + parser.add_argument("--uid", required=True, help="VTODO UID") 38 + parser.add_argument("--action", required=True, choices=["complete", "uncomplete", "delete"], 39 + help="Action to perform") 40 + args = parser.parse_args() 41 + 42 + try: 43 + registry = EDataServer.SourceRegistry.new_sync(None) 44 + source = find_task_source(registry, args.task_list) 45 + if not source: 46 + print(json.dumps({"success": False, "error": f"Task list not found: {args.task_list}"})) 47 + sys.exit(1) 48 + 49 + client = ECal.Client.connect_sync( 50 + source, ECal.ClientSourceType.TASKS, -1, None 51 + ) 52 + 53 + if args.action == "delete": 54 + client.remove_object_sync(args.uid, None, ECal.ObjModType.ALL, ECal.OperationFlags.NONE, None) 55 + print(json.dumps({"success": True})) 56 + return 57 + 58 + # For complete/uncomplete, fetch the existing component first 59 + success, comp = client.get_object_sync(args.uid, None, None) 60 + if not success or not comp: 61 + print(json.dumps({"success": False, "error": "VTODO not found"})) 62 + sys.exit(1) 63 + 64 + ical = comp.get_icalcomponent() 65 + 66 + if args.action == "complete": 67 + ical.set_status(ICalGLib.PropertyStatus.COMPLETED) 68 + 69 + # Set PERCENT-COMPLETE to 100 70 + remove_property(ical, ICalGLib.PropertyKind.PERCENTCOMPLETE_PROPERTY) 71 + prop = ICalGLib.Property.new_percentcomplete(100) 72 + ical.add_property(prop) 73 + 74 + # Set COMPLETED timestamp 75 + remove_property(ical, ICalGLib.PropertyKind.COMPLETED_PROPERTY) 76 + now = datetime.now(timezone.utc) 77 + completed_time = ICalGLib.Time.new_null_time() 78 + completed_time.set_date(now.year, now.month, now.day) 79 + completed_time.set_time(now.hour, now.minute, now.second) 80 + completed_time.set_timezone(ICalGLib.Timezone.get_utc_timezone()) 81 + prop = ICalGLib.Property.new_completed(completed_time) 82 + ical.add_property(prop) 83 + 84 + elif args.action == "uncomplete": 85 + ical.set_status(ICalGLib.PropertyStatus.NEEDSACTION) 86 + 87 + # Set PERCENT-COMPLETE to 0 88 + remove_property(ical, ICalGLib.PropertyKind.PERCENTCOMPLETE_PROPERTY) 89 + prop = ICalGLib.Property.new_percentcomplete(0) 90 + ical.add_property(prop) 91 + 92 + # Remove COMPLETED timestamp 93 + remove_property(ical, ICalGLib.PropertyKind.COMPLETED_PROPERTY) 94 + 95 + client.modify_object_sync(comp, ECal.ObjModType.ALL, ECal.OperationFlags.NONE, None) 96 + print(json.dumps({"success": True})) 97 + 98 + except Exception as e: 99 + print(json.dumps({"success": False, "error": str(e)})) 100 + sys.exit(1) 101 + 102 + 103 + if __name__ == "__main__": 104 + main()