From 4dbb7c5fea48e2c1aae47abe20bf956f61f37c07 Mon Sep 17 00:00:00 2001 From: Bendt Date: Thu, 18 Dec 2025 12:09:21 -0500 Subject: [PATCH] task creation --- .coverage | Bin 69632 -> 69632 bytes src/maildir_gtd/email_viewer.tcss | 2 +- src/maildir_gtd/screens/CreateTask.py | 194 +++++++++++----- src/services/task_client.py | 305 ++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 50 deletions(-) create mode 100644 src/services/task_client.py diff --git a/.coverage b/.coverage index 4ffaba4b5bb8abd8397a9ffff4f51aa8c2eacc1c..c1201d523f8e13c22e0cc29a52d1c74b41378623 100644 GIT binary patch delta 3278 zcmeHJX>3&26@K^a`<<~dU{l*O-j`Wy2FzlX8Slo%4TIyj2p9};Qx`h8jh!|k1do)Z zI4m_MifIT-Dnu&;X%Z_XEuyv4mX=CUQUPsIDwj|RyTOV{BGCjjZO^+lUZg0MBK220 z(#&`7xo6IO_uTJ&=k%NqdrpWihr?y6s{B-WNJ*1_D|g8S(sc=>I*Aj1BJL8y!X4oy zAtt2qSNZ4pRz8jVi5w*xiHjTT>suo?EfKkd7hZ{l`o}3zp*rG{%FvWLWpa0ZtNoFV zo!t)yIv-uV7?%WRN$>9J*x{I#ErOw{fE9~imd1`oXF;%j{UADCp3GdH&F=xC33Va&Q> zc)Q-QrH~>gmfF>^ZM*Y@L{$jJt4vaW4#g0zH7CwdUagaSC(yLb+S%D2$5{1wxaT~! z=in`KdXI)&AaxCVP0sAU!JE~pls!8ou~)GIKSx>Uy zrSWydd#Ke3I>&14h;P5IE#AHZN#?<&rIT0emNMC4*0rtxyk3%l$8?^0_U*RD6uGk< zb8SyVeRYfI5kguEC_Q-*zxfyQu({uS(%fdQH~nU=nQo>SpBfj93&sn^v&LSd#aOIg z(NF6y>b-hGZ_+)ws=cFK)-GsgwLMzPfZH{yD)}LiX!ivH_GV-9&+YXGOVN;_-1x`D z9f#+X;JirsP~udMcX2%&$8*FZ2hnPad~pDhdwm&xoCW-E^q=9-%Oi5(JsejdiasCO zH~3&vZ-HEl!v_QKOs_v$ghN;HR+3VP17F_o#7>-uh+kDHaMp9PTm|_!h^F6N!(FGi z_OaqP4z6~7xiF3hx0DSy&%pKG7{TO+_vI2Ve0AS4D`S<=7Sh?dYjaAsc($%K#g z)nuk8W6Tv_`sbLloAYt8fy-4|!*TN!XxtCMoX2ap9znLz)6P#)Ea!0wjuF{(4B13B z984ySx)XhV5!eUTB7HX=_&R3rHwVrlX}b@uM-pE;SeR2-rW16Z%a6Ww`-i-a8;0zo zoPCnsrElA>+ppNauusF>@A-W6A=*vjG)f!jS$f$1n|*^Gr>E#g^t}C!{ic1Y~oF2kT##ef_V%w<}&c4Fvx^m$Fnx(P&Uc885CFyvP}lb27@A+r;ta(JS- zYpyzWWzJX1zLD*MaE6z`$m%?>Oa>X(et&xYR_v?jjET` zgZNsxWN5ruD^;u&6Y1=k;v=;GF^gzYq(w{wr%b=IND>Ld@JJQx9&wS?NRTg+RXA^! zR?hnG0*Wu`3a5&R!E#|YB$@&PcSgTSKD%~dnZqt3EzJ<<=gE{5A1 zUmy7O*eFl4EX>IgZjhtDq~E3Mzz#3`e=qny(F=ZjUP&{|lN^1GZl_-Rn*EGjX5F;< ztmWpIc@nw(l*!jrIGt=Qi9i!Jpo2HP^8I1#HG|mtmcq^vyB~R-Ojh>~TclZ9g)82B zE7;CYHMr|nIu4cn7j5E3Zcl`o-;Y$+N%^c+Syz~X)h(aZiua#&JeSS1x#jw$xZOOb L>78iTQP}nc9dNRbueHqt6sfux;|JSK5k z3`*!}B$>1kr%X!6CZpZhCT3a_+azOVEKW5|XOfyqXT+37Or3Tb+nM&eM@6Qs|71G- zqtiRH`#azBo^!tM{?6$i6Z^-+Ze(45@U^Wq&9Ql|Y!6z7sN$m-a?|R1GIf#o%Et7HjWl?~cV_0u?TnV^58V3fbrwj0(WhO(z>HdcDSh@*(<;q-dRc z;vEoImp5N)b+>hW$zEjcoki02I#XH#l8c_0jmqnDV3-HPu(3Mcwj;h5oEx3AGh!z; z=~%r=KVNT2*|)qdECtO%lm(N&Yrk2KQtb`O-%;HDCKE@((ggCaD*(IyYo zLJrZtQs`qsLkLU0In!Xsfl67(J0SyFVEAen%Ky{f*P>#&ZDSNljy8K}=}a>HIIKbG zC%2T_MK|Xay$Hf9k&7icR%1ZrC%4?0cM?<|ZLWu^UxxoYm~#MXX-K&nMW|(4QyI-# zqta_JmHwp8KuhRLu_ly5J6hP9ZWq!-PSCFaDFbE0r0&|Fh)~w(Oasb!t|Lg(`jnPFO9Rt zgfV73ZEQD!`fv63^w;$t>rdzp>Z^2zu4y;4-)Secp@btTTT=@BBGT4->DIv|$VUeT ziyft)$W*5OcB`U+`%38P!Bou)S}gMJV)~mwPo@VXujkC@4>(TcpjiWb92XKrj~n#O zZd!1lP%eVuj$*p`Kyjf9hDk-+QEDL!Jeikn_lgJzKUFEP?Td4g5*H7|5Nu`HP#$b$ z^H7DAwMyuB)}~5G8_=C}Y^V;laeAmO#gPIbxr=(!>>S(~j@!0~Mh?=FyxuylUyw0a zaelI5*@+9DN3v-va*=Gt~xvH$}L=` zBXCdSuTEb7923fw3|R^;_dqNX7q6O>X0QV9dm%Rnw>;PEhUyBQQ0 zF(`5|$fUE+x4D-rWI|2`gM|wi+@H=MD~*9Ol|eTB+L4@=Ji>&0%%H$xkZUqXF&MaX z27Zl!Ph}t|3`%4Mr4oZ=k%2>CP?-4ih+5hQl#s=p;>aMH5?{X;3jvdSU-;zST=dyl zXym!d|2hFd?VQ{&9ccB!>En>RveV{|Yd)ZyB&ej5yNrNN0%}M6E8W0#mGhWJ(K~nT zhPO}!UY(Sr{)i^gI2kbo5vSz8|C z6Nld~MCgnin7H0@il}RdU4olXc8` z*h)6vG`r0_}=&cg= z(SfKpA%65_^e>0Utz+fi9wGls;vFru-`qNpNdNdMf9qHvITnz|a_&`*{EU2qG*KMz z|Np-AKXTvt%PYzP-JIpfkH{{Pi+_a&u+RFy+HWm4XQ5Lsn0#FjZir=c_)0P#35;Gd z(ORh*Zf_;@=ydgHpM_GSRUo=;kv=({Bu7_*=mfDr-uM8sd@{X`k5t*A;nDLL>8Nu4 Zom0C_DXS~y-BuN2j#E~zn6I}< Tuple[str, str]: + """Get the configured backend name and command path. + + Returns: + Tuple of (backend_name, command_path) + """ + config = get_config() + backend = config.task.backend + + if backend == "dstask": + return "dstask", config.task.dstask_path + else: + return "taskwarrior", config.task.taskwarrior_path + + +async def create_task( + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """ + Create a new task using the configured backend. + + Args: + task_description: Description of the task + tags: List of tags to apply to the task + project: Project to which the task belongs + due: Due date in the format the backend accepts + priority: Priority of the task (H, M, L) + + Returns: + Tuple containing: + - Success status (True if operation was successful) + - Task ID/message or error message + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + return await _create_task_dstask( + cmd_path, task_description, tags, project, due, priority + ) + else: + return await _create_task_taskwarrior( + cmd_path, task_description, tags, project, due, priority + ) + except Exception as e: + logger.error(f"Exception during task creation: {e}") + return False, str(e) + + +async def _create_task_taskwarrior( + cmd_path: str, + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """Create task using taskwarrior.""" + cmd = [cmd_path, "add"] + + # Add project if specified + if project: + cmd.append(f"project:{project}") + + # Add tags if specified + if tags: + for tag in tags: + if tag: + cmd.append(f"+{tag}") + + # Add due date if specified + if due: + cmd.append(f"due:{due}") + + # Add priority if specified + if priority and priority in ["H", "M", "L"]: + cmd.append(f"priority:{priority}") + + # Add task description + cmd.append(task_description) + + # Use shlex.join for proper escaping + cmd_str = shlex.join(cmd) + logger.debug(f"Taskwarrior command: {cmd_str}") + + process = await asyncio.create_subprocess_shell( + cmd_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return True, stdout.decode().strip() + else: + error_msg = stderr.decode().strip() + logger.error(f"Error creating task: {error_msg}") + return False, error_msg + + +async def _create_task_dstask( + cmd_path: str, + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """Create task using dstask. + + dstask syntax: dstask add [+tag...] [project:X] [priority:X] [due:X] description + """ + cmd = [cmd_path, "add"] + + # Add tags if specified (dstask uses +tag syntax like taskwarrior) + if tags: + for tag in tags: + if tag: + cmd.append(f"+{tag}") + + # Add project if specified + if project: + cmd.append(f"project:{project}") + + # Add priority if specified (dstask uses P1, P2, P3 but also accepts priority:H/M/L) + if priority and priority in ["H", "M", "L"]: + # Map to dstask priority format + priority_map = {"H": "P1", "M": "P2", "L": "P3"} + cmd.append(priority_map[priority]) + + # Add due date if specified + if due: + cmd.append(f"due:{due}") + + # Add task description (must be last for dstask) + cmd.append(task_description) + + # Use shlex.join for proper escaping + cmd_str = shlex.join(cmd) + logger.debug(f"dstask command: {cmd_str}") + + process = await asyncio.create_subprocess_shell( + cmd_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return True, stdout.decode().strip() + else: + error_msg = stderr.decode().strip() + logger.error(f"Error creating dstask task: {error_msg}") + return False, error_msg + + +async def list_tasks(filter_str: str = "") -> Tuple[List[Dict[str, Any]], bool]: + """ + List tasks from the configured backend. + + Args: + filter_str: Optional filter string + + Returns: + Tuple containing: + - List of task dictionaries + - Success status (True if operation was successful) + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + return await _list_tasks_dstask(cmd_path, filter_str) + else: + return await _list_tasks_taskwarrior(cmd_path, filter_str) + except Exception as e: + logger.error(f"Exception during task listing: {e}") + return [], False + + +async def _list_tasks_taskwarrior( + cmd_path: str, filter_str: str = "" +) -> Tuple[List[Dict[str, Any]], bool]: + """List tasks using taskwarrior.""" + cmd = f"{shlex.quote(cmd_path)} {filter_str} export" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + tasks = json.loads(stdout.decode()) + return tasks, True + else: + logger.error(f"Error listing tasks: {stderr.decode()}") + return [], False + + +async def _list_tasks_dstask( + cmd_path: str, filter_str: str = "" +) -> Tuple[List[Dict[str, Any]], bool]: + """List tasks using dstask.""" + # dstask uses 'dstask export' for JSON output + cmd = f"{shlex.quote(cmd_path)} {filter_str} export" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + try: + tasks = json.loads(stdout.decode()) + return tasks, True + except json.JSONDecodeError: + # dstask might output tasks differently + logger.warning("Could not parse dstask JSON output") + return [], False + else: + logger.error(f"Error listing dstask tasks: {stderr.decode()}") + return [], False + + +async def complete_task(task_id: str) -> bool: + """ + Mark a task as completed. + + Args: + task_id: ID of the task to complete + + Returns: + True if task was completed successfully, False otherwise + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + cmd = f"{shlex.quote(cmd_path)} done {task_id}" + else: + cmd = f"echo 'yes' | {shlex.quote(cmd_path)} {task_id} done" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + return process.returncode == 0 + except Exception as e: + logger.error(f"Exception during task completion: {e}") + return False + + +async def delete_task(task_id: str) -> bool: + """ + Delete a task. + + Args: + task_id: ID of the task to delete + + Returns: + True if task was deleted successfully, False otherwise + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + cmd = f"{shlex.quote(cmd_path)} remove {task_id}" + else: + cmd = f"echo 'yes' | {shlex.quote(cmd_path)} {task_id} delete" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + return process.returncode == 0 + except Exception as e: + logger.error(f"Exception during task deletion: {e}") + return False