forked from janek/mostr
1
0
Fork 0

Compare commits

...

306 Commits

Author SHA1 Message Date
xeruf ae4a678d77 feat: improve times_tracked history display utility 2024-12-14 12:45:52 +01:00
xeruf 660d7b1815 build(ci): only capture exe on windows for upload 2024-12-07 01:10:28 +01:00
xeruf d85ff3ac8d refactor(tasks): extract more modules 2024-12-06 23:28:06 +01:00
xeruf 77bc359d8a build(ci): recognize windows exe 2024-12-06 22:30:13 +01:00
xeruf ced5c4b3ef style(main): rename relays_file variable 2024-12-06 22:14:09 +01:00
xeruf 932d07b893 test(examples): track various experiments 2024-12-06 22:12:05 +01:00
xeruf 76baed51e2 build: extract unused examples 2024-12-06 22:10:14 +01:00
xeruf e8312959c3 build(ci): allow disabling keyring features 2024-12-06 21:40:26 +01:00
xeruf 1df75055df build(ci): add artifact upload 2024-12-06 21:16:39 +01:00
xeruf cdf3d3a805 build(ci): add github ci 2024-12-06 20:57:47 +01:00
xeruf 29ef9634a4 docs(readme): add collaboration convention 2024-12-06 20:52:11 +01:00
xeruf cf04d4d852 refactor(tasks): simplify up_to back 2024-12-06 20:47:47 +01:00
xeruf 0c6ad19600 test(tasks): extract tests from tasks 2024-12-06 20:45:58 +01:00
xeruf 6f2a7951d5 refactor: modularize task 2024-12-06 13:52:01 +01:00
xeruf 03fd79ad95 refactor(tasks): more descriptive variable names 2024-12-06 12:01:00 +01:00
xeruf 6362718aa7 docs(readme): reorder for gentler introduction 2024-12-06 12:00:17 +01:00
xeruf cdf75cda24 release: 0.7.1 - improved user key interactivity 2024-12-05 23:04:57 +01:00
xeruf e1c1b1d4f6 fix(main): consistent history label italics 2024-12-05 23:02:22 +01:00
xeruf 6fc8b42bcc fix(tasks): never create empty task 2024-12-05 23:00:45 +01:00
xeruf 0dba23bcc6 style: reformat code 2024-12-05 14:56:26 +01:00
xeruf db11b54220 fix(kinds): key tag formatting 2024-12-05 14:34:27 +01:00
xeruf df598efdc3 fix(tasks): view pubkey tracking history reliably 2024-12-05 13:18:21 +01:00
xeruf d159004340 feat: also match user filter to hashtag and position better in prompt 2024-11-25 14:23:05 +01:00
xeruf 591adafd6e feat(task): list all participants 2024-11-25 14:07:08 +01:00
xeruf ca263b50d2 fix(tasks): filter by owner instead of creator 2024-11-25 10:44:25 +01:00
xeruf e95a14ae89 release: 0.7.0 - task reassignment, user interaction and more helpful feedback 2024-11-25 02:33:32 +01:00
xeruf a3eeb38e5f fix(tasks): fallback upon invalid regex 2024-11-25 02:30:56 +01:00
xeruf 3a4588b45d feat: display task owner 2024-11-25 02:29:23 +01:00
xeruf 6ef5c47e98 feat: assign users to task 2024-11-25 02:15:18 +01:00
xeruf 87392fccb6 feat: guidance for moving backward 2024-11-25 01:45:18 +01:00
xeruf 78438696ac refactor: create own struct for nostr relay users 2024-11-24 23:42:47 +01:00
xeruf 654f273ad9 fix(tasks): fix pubkey filtering to show all activities instead of all tasks 2024-11-24 23:14:35 +01:00
xeruf cb15fbaac5 enhance(tasks): feedback about ask movement with custom time 2024-11-24 23:11:19 +01:00
xeruf a8fb3f919d enhance(tasks): improve state labels 2024-11-24 08:51:54 +01:00
xeruf 044c853993 enhance(tasks): show displayname rather than username where appropriate 2024-11-24 08:47:57 +01:00
xeruf b26d64646c fix(main): do not create empty notes 2024-11-23 12:11:31 +01:00
xeruf 7ecfa6e810 feat(task): get state at specific time 2024-11-23 08:47:54 +01:00
xeruf fe0b59ef65 feat(main): use whoami crate to get full name, also on windows 2024-11-22 13:37:19 +01:00
xeruf 031d9a3b69 release: 0.6.3 - enhance quick access 2024-11-22 11:24:40 +01:00
xeruf 58117b901a style: clean up code formatting and add notes 2024-11-22 11:22:28 +01:00
xeruf 29476e60ad fix: flush more liberally 2024-11-22 11:21:04 +01:00
xeruf 1a7b65fe1c fix(tasks): priority filtering for quick access with exhaustive tests 2024-11-22 11:20:13 +01:00
xeruf 94976905d3 fix(tasks): properly test quick access bookmarks and view 2024-11-22 10:06:50 +01:00
xeruf 0cf354942e feat(tasks): show activities to everyone 2024-11-22 09:29:50 +01:00
xeruf a6b611312b fix(tasks): correct task hints when showing sole details 2024-11-22 09:28:46 +01:00
xeruf bd32e61212 refactor: revamp visible task algorithm 2024-11-22 00:04:19 +01:00
xeruf 5cd82e8581 enhance: display more accurate time tracking prefixes 2024-11-21 23:59:30 +01:00
xeruf eea8511a6e feat: enable finding user by partial key and name 2024-11-21 21:18:26 +01:00
xeruf 5032b4db93 refactor(tasks): omit empty descriptions
preparation for state update notes
2024-11-21 10:56:52 +01:00
xeruf fc97b513c4 release: 0.6.2 - case-insensitive hashtags and pubkey filtering 2024-11-21 09:48:04 +01:00
xeruf 9c92a19cde fix: make hashtag behaviour more consistent 2024-11-21 09:47:14 +01:00
xeruf 0a7685d907 feat: make hashtags case-insensitive 2024-11-21 09:17:56 +01:00
xeruf 20fc8f9a3a refactor: rename set_filter_from to since 2024-11-20 23:28:06 +01:00
xeruf 1f13c45831 feat: easy reset to own pubkey filter 2024-11-20 23:28:06 +01:00
xeruf e320523fc0 feat: enable setting pubkey as context and auto-filter for own 2024-11-20 23:28:06 +01:00
xeruf b87970d4e2 release: version 0.6.1 back to rust 1.82.0 2024-11-20 23:27:29 +01:00
xeruf 2ce5801925 fix: task descriptions ordering 2024-11-20 23:10:28 +01:00
xeruf ca50bdf3bb feat: enhance display of task history 2024-11-20 19:34:40 +01:00
xeruf 9eb6138852 enhance(tasks): current task description 2024-11-20 19:06:32 +01:00
xeruf 88ecd68eb8 refactor: rename and document a few task methods 2024-11-20 19:05:33 +01:00
xeruf 00bd7a997a feat(main): show all task updates with comma 2024-11-19 17:09:59 +01:00
xeruf cb1d8ef8fb feat: release new version 0.6.0 2024-11-18 15:02:50 +01:00
xeruf 7561bc0e2f refactor: update to latest nostr sdk version 2024-11-18 14:52:52 +01:00
xeruf 360b44e64e docs: miniscule adjustments 2024-11-18 14:44:07 +01:00
xeruf adcd35967f refactor: reformat tasks file 2024-11-18 14:43:49 +01:00
xeruf 2400f7c45b fix: parse markers again by reducing EventId referencing 2024-11-18 14:40:50 +01:00
xeruf e186d034e5 fix: upgrade to nostr sdk development version 2024-11-15 17:19:49 +01:00
xeruf 59b789d5ed fix(tasks): do not recurse activities by default 2024-11-15 17:12:52 +01:00
xeruf 473f26d7a5 fix(task): adjust to inverted ordering of sdk 0.36
https://github.com/rust-nostr/nostr/issues/632
2024-11-14 19:05:58 +01:00
xeruf 43f8a3ebca feat: update to nostr sdk 0.36 2024-11-14 19:05:12 +01:00
xeruf 9a9c30dbb7 feat(main): auto-migrate existing key 2024-11-14 18:50:50 +01:00
xeruf a0e411d2e9 feat(main): debug print config directory 2024-11-14 18:32:25 +01:00
xeruf ecc5b7686b test(task): verify state progression 2024-11-14 18:29:47 +01:00
xeruf d095c65b23 test(tasks): reformat 2024-11-14 14:23:42 +01:00
xeruf 6b8bf29b20 feat(task): extract tags from state updates 2024-11-13 11:12:25 +01:00
xeruf 2cec689bf1 feat: automatically add tags from task properties 2024-11-12 23:03:53 +01:00
xeruf 44feea9894 enhance(kinds): improve tag formatting 2024-11-12 20:33:23 +01:00
xeruf 3fa8df4eaa feat(tasks): add high priority tasks to quick access 2024-11-12 20:23:30 +01:00
xeruf b9f1d461fb feat(main): save keys in system keychain 2024-11-12 19:52:05 +01:00
xeruf 7cedd980fb enhance(tasks): more transparent task and activity creation 2024-11-11 22:57:58 +01:00
xeruf 55d42fc52c enhance(tasks): inherit displayed priority 2024-11-11 21:56:29 +01:00
xeruf 15a2ffd7e6 style: optimize imports with cargo fix 2024-11-11 14:59:25 +01:00
xeruf 5f8a232bd5 refactor(event_sender): reformat 2024-11-11 13:17:50 +01:00
xeruf 5dfd7a084b refactor: create own file for EventSender 2024-11-11 13:13:15 +01:00
xeruf ca24693dbb fix(main): auto-create config directory 2024-11-11 12:34:28 +01:00
xeruf ed72bcebcf refactor: replace xdg dependency by platform-agnostic directories
Previously could not compile on Windows
2024-11-11 01:34:55 +01:00
xeruf 3749f72048 refactor(main): optimize feedback for (( command 2024-11-11 01:21:18 +01:00
xeruf d1735476cc fix(main): improve feedback for (( command 2024-11-11 01:19:27 +01:00
xeruf 5dbea00562 feat: make hashtag interactions more dynamic 2024-11-10 20:41:13 +01:00
xeruf cc1e9d4d69 docs(readme): beta hints 2024-11-10 20:29:09 +01:00
xeruf d5e6bd2578 fix(main): fallback when listing empty description 2024-11-10 20:20:34 +01:00
xeruf 60b33b1dd3 fix: make bookmark and priority filter commands more consistent 2024-11-10 20:19:02 +01:00
xeruf 561fd9e1e5 feat: implement priority filtering 2024-11-09 20:41:22 +01:00
xeruf 91b6047f9a feat: implement priority property for task 2024-11-09 20:33:29 +01:00
xeruf 5294d9081f chore(rust): upgrade to nightly to fix build 2024-11-09 20:10:43 +01:00
xeruf b81e5a27bf fix(main): retain current movement when tracking for another time 2024-11-09 20:00:06 +01:00
xeruf 8f0a169677 fix(main): hide Quick Access in a custom search
Matching items are included anyway
2024-11-09 19:36:52 +01:00
xeruf ae525c870f fix: filter from correct position with multiple slashes 2024-11-09 19:36:06 +01:00
xeruf b9307b7b5d feat(main): improve prompt symbol 2024-11-09 19:20:12 +01:00
xeruf e9bee3c114 feat: allow setting priority context for creating tasks 2024-11-09 19:18:42 +01:00
xeruf dc8df51e0f fix: slight interaction and documentation improvements 2024-11-09 18:02:33 +01:00
xeruf cc64c0f493 style(tasks): reformat 2024-11-09 18:01:40 +01:00
xeruf 5a8fa69e4c feat: implement recursive closing and property marker 2024-11-09 18:00:17 +01:00
xeruf f33d890d7f feat: implement priority parsing from task string 2024-11-09 17:06:20 +01:00
xeruf dd78a2f460 fix(tasks): revamp tag delimiter in task creation syntax
Prevent accidental interpretation of title parts as tags
2024-11-08 12:15:32 +01:00
xeruf 5303d0cb41 fix(tasks): set parent for dependent sibling 2024-11-08 11:49:49 +01:00
xeruf 2053f045b2 fix(helpers): add one second to displayed timestamp to produce round times on stopping
Internally, tracking is stopped one second earlier
to prevent random accidental overlaps.
This brings the interface in line with the user input.
2024-11-08 11:35:07 +01:00
xeruf baf93bd788 docs(readme): notes about interfaces 2024-10-18 18:14:24 +02:00
xeruf d8eebcfb6a feat(tasks): filter out tracked intervals smaller than 2mins 2024-10-18 18:13:35 +02:00
xeruf 7f33bdc9ab feat(main): relay switching by substring match 2024-10-18 18:07:37 +02:00
xeruf 306e0e0421 chore: create rust-toolchain.toml to pin rust 1.81 2024-10-16 22:05:48 +02:00
xeruf 49d8eef29c fix(tasks): wrong position for immediate task entering 2024-10-15 15:42:17 +02:00
xeruf 74fff5a2b1 fix(main): only parse single-digit view depths 2024-10-15 03:28:40 +02:00
xeruf bdb8b6e814 fix(main): show correct relay url on relay change 2024-10-15 03:02:46 +02:00
xeruf b0c92e64fa feat(tasks): expand Bookmarks display to Quick Access
Now also including recently created tasks
2024-10-15 03:01:57 +02:00
xeruf 4e4ad7099f fix(tasks): do not find children of closed tasks globally 2024-10-15 03:00:12 +02:00
xeruf 613a8b3822 feat(tasks): display bookmarks and time summary 2024-10-14 16:44:35 +02:00
xeruf 1533676bff fix: do not show all tasks when filter has no matches 2024-10-14 16:39:44 +02:00
xeruf 52be8c53eb feat: revamp task printing through recursive sorting
Still to be fixed: Bookmarks, Time Summary
2024-10-14 16:10:56 +02:00
xeruf 5f25e116a1 feat: allow filtering tasks by author name 2024-10-13 17:15:43 +02:00
xeruf d1720f89ae fix(tasks): do not show progress for activities 2024-10-13 16:01:55 +02:00
xeruf f6082f12f2 fix(tasks): prevent crashes at zero depth 2024-10-12 21:55:32 +02:00
xeruf 3d389e8d52 feat: toggleable activity recursion 2024-10-12 14:17:46 +02:00
xeruf 28d1f4c983 fix(tasks): properly set search depth 2024-10-12 11:54:29 +02:00
xeruf 93fde86169 test(tasks): adjust view and search depth 2024-10-12 11:35:43 +02:00
xeruf 769b9578fe refactor: do not import xdg 2024-10-12 11:34:44 +02:00
xeruf c27ccb8282 refactor: rename stateless tasks to activities 2024-10-11 22:06:18 +02:00
xeruf d744fb8457 feat: separate search and view depth 2024-10-11 01:10:17 +02:00
xeruf c2f775e891 docs(tasks): expand documentation a bit 2024-10-03 13:39:52 +02:00
xeruf 617b1ea6d1 fix(tasks): do not show children of non-matching states 2024-10-03 13:29:41 +02:00
xeruf fd970b3709 fix(tasks): match task names at word boundary with regex 2024-10-01 23:20:08 +02:00
xeruf c6de8daee9 fix(main): increase notification channel size
apparently it needs to be double of the expected events,
so even this is only a temporary remedy
2024-10-01 23:19:01 +02:00
xeruf a297f61363 feat(tasks): report reprocessing issues 2024-09-23 13:59:29 +02:00
xeruf 96ca945263 feat: allow viewing tracking history for user 2024-09-23 13:51:16 +02:00
xeruf d4bca1c26f feat: deferred state updates 2024-09-23 08:50:12 +02:00
xeruf 516acadd4a fix: properly determine current task state 2024-09-23 01:20:24 +02:00
xeruf 945eb6906a feat: add ability to schedule any action 2024-09-22 20:05:05 +02:00
xeruf 34657540de refactor: remove Tasks to TasksRelay 2024-09-22 16:48:15 +02:00
xeruf afe3fa8670 fix: remove cached task children list 2024-09-22 16:47:26 +02:00
xeruf 753afad2fd fix: obtain children on the fly 2024-09-22 16:24:07 +02:00
xeruf 7755967a7a feat: requeue events with missing references 2024-09-22 14:44:20 +02:00
xeruf cb75a5749f style(tasks): some bits of documentation 2024-09-14 16:17:30 +03:00
xeruf 0744e86922 feat: properly include tasks with hashtags in filters 2024-09-14 16:13:41 +03:00
xeruf ddb68f7107 feat: recognize hashtags in task name 2024-09-14 15:53:27 +03:00
xeruf 4b59b273f5 feat(tasks): extend search depth by tags if appropriate 2024-09-07 16:26:55 +03:00
xeruf bb3bb1fd56 feat: better feedback on bookmarking 2024-09-07 16:25:44 +03:00
xeruf 593ebcddca feat(main): make empty / go to root 2024-09-07 16:06:59 +03:00
xeruf 132ea048a5 feat: greatly revamp filtering 2024-09-07 16:06:28 +03:00
xeruf ddc57dc36a fix(tasks): do not panic on missing children 2024-09-05 13:56:48 +03:00
xeruf 77bfc4cb7a fix: add weekday to relative date formatting 2024-09-05 13:50:50 +03:00
xeruf 43e7a5cd10 fix(tasks): prevent infinite loop on higher depth in ChildIterator 2024-09-03 21:15:48 +03:00
xeruf 3942105764 fix(tasks): do not show bookmarks on childrenless tasks 2024-08-29 23:28:52 +03:00
xeruf 945e29b5ed feat: enable setting persistent bookmarks 2024-08-29 23:20:31 +03:00
xeruf 1297be43bc feat(tasks): include bookmarked tasks regardless of position 2024-08-29 22:59:31 +03:00
xeruf 5a62e8f99e feat(tasks): parse own bookmarks from event 2024-08-29 22:28:25 +03:00
xeruf 018357b21e refactor(tasks): accurately rename referenced_events to singular 2024-08-29 22:17:46 +03:00
xeruf d1ae0f5458 docs(readme): enhance command documentation 2024-08-29 22:15:59 +03:00
xeruf d4f544173a style: slight reformatting 2024-08-29 22:15:30 +03:00
xeruf c2b106ea69 refactor: use static Kinds rather than their ids 2024-08-29 22:14:26 +03:00
xeruf 003d1d6120 feat: prefer immediate children search results 2024-08-29 12:11:43 +03:00
xeruf 039c390c66 fix(tasks): re-filter when reducing view depth 2024-08-29 12:02:13 +03:00
xeruf b974957bc9 fix: do not set view depth when searching 2024-08-29 11:54:53 +03:00
xeruf dd5aaf71d2 feat: enable moving back through history with & 2024-08-29 11:50:34 +03:00
xeruf c5a2872534 feat: enable setting specific state with label 2024-08-29 11:06:56 +03:00
xeruf 1a1f23007b feat: filter tasks by state change time 2024-08-28 21:56:15 +03:00
xeruf 9eaf10006b fix(tasks): avoid timestamp conflicts 2024-08-28 09:16:38 +03:00
xeruf 6492a22cc9 fix: dim path separators 2024-08-28 09:16:38 +03:00
xeruf 13dac88ded fix: more consistent time-tracking history 2024-08-28 09:16:35 +03:00
xeruf 1263e39fb3 fix: parse date without numbers as day start 2024-08-27 12:02:45 +03:00
xeruf 714d4a4d5b fix(tasks): only enter a perfect global match that is not closed 2024-08-27 12:02:45 +03:00
xeruf 1d7d3eea74 feat(main): automatic readline history 2024-08-27 12:02:45 +03:00
xeruf 3cab294122 feat(tasks): prevent accidental redundant time-tracking 2024-08-27 12:02:45 +03:00
xeruf 01305c5a78 feat: more adaptive tag filtering
Make tag exclusions more persistent
2024-08-27 12:02:45 +03:00
xeruf 14a1cbe09c refactor: code cleanup with clippy 2024-08-27 12:02:45 +03:00
xeruf 533378b24d feat(tasks): allow jumping to task anywhere by exact name match 2024-08-27 12:02:40 +03:00
xeruf a0f5d62c81 fix: more intuitive feedback when stopping timetracking 2024-08-25 14:28:23 +03:00
xeruf 84e46827ce fix(tasks): temporary improved author formatting 2024-08-25 11:17:55 +03:00
xeruf 3c93e0aae7 feat: character threshold for creating notes 2024-08-25 11:17:26 +03:00
xeruf c3d18e4494 fix(tasks): properly determine last tracking of current task 2024-08-25 10:50:24 +03:00
xeruf 75a5f27843 feat(main): allow creating multiple task at once with newlines 2024-08-24 21:01:07 +03:00
xeruf 58126ba6e7 feat(main): migrate to current rustyline version 2024-08-24 20:57:07 +03:00
xeruf 9f14a0f3f9 feat: parse plain hour when adjusting tracking 2024-08-22 11:13:35 +03:00
xeruf f240413e2a style: various small cleanups 2024-08-21 12:31:29 +03:00
xeruf 999068bdd9 fix(main): pass own username to Tasks object 2024-08-21 12:30:13 +03:00
xeruf f7f4bdc4f3 refactor(main): merge local_tasks into relays map 2024-08-21 12:22:47 +03:00
xeruf ed1f482707 feat: revamp timestamp formatting helpers 2024-08-21 11:57:28 +03:00
xeruf 3dca6a4b23 fix(main): safer key persistence 2024-08-21 11:52:07 +03:00
xeruf 17b3334aea fix: prompt via readline 2024-08-21 11:05:43 +03:00
xeruf 77ba311bab feat(main): neatly interpret plain hour in date filter 2024-08-21 10:14:01 +03:00
xeruf 9da41db427 feat(main): implement readline functionality with rustylinez
Including background log output
2024-08-20 22:40:16 +03:00
xeruf a67bd384ec fix(tasks): creation position in filter_or_create 2024-08-20 21:42:05 +03:00
xeruf ace365de38 fix(main): enhance Result warnings macro with string interpolation 2024-08-20 21:16:01 +03:00
xeruf 07bba314ec fix(main): print Result warnings via macro 2024-08-20 20:41:41 +03:00
xeruf 126bd8cf81 fix: move from std::sync fully to tokio
Fixes Relay adding

Closes https://github.com/rust-nostr/nostr/issues/533
2024-08-20 14:29:09 +03:00
xeruf b5b2ea9b71 fix(main): client communication ordering 2024-08-20 14:18:37 +03:00
xeruf f98486f012 docs: small updates 2024-08-20 13:51:23 +03:00
xeruf ada3492487 feat: upgrade to nostr sdk 0.34 2024-08-20 13:00:36 +03:00
xeruf efc0061390 feat(main): rudimentary filter by date or author 2024-08-19 22:16:19 +03:00
xeruf 1b0f7dca09 fix(main): more intuitive behavior when stopping tracking with stamp 2024-08-19 21:55:44 +03:00
xeruf f4cff3d702 feat: use author name consistently 2024-08-19 21:41:45 +03:00
xeruf f8a4777f71 feat(kinds): more accurate tag formatting 2024-08-19 21:25:59 +03:00
xeruf 0296556bcd fix(main): fetch task kinds before updates 2024-08-19 21:25:20 +03:00
xeruf c64a1fa051 feat(tasks): add task author to default columns 2024-08-19 21:21:31 +03:00
xeruf 721c200b97 fix(tasks): hide duplicates again in personal time-tracking history 2024-08-19 17:35:26 +03:00
xeruf 3b9fedd9a3 feat(tasks): display details on task without subtasks 2024-08-19 17:30:05 +03:00
xeruf 12b7c909ab feat(tasks): track depth in ChildIterator 2024-08-19 16:52:19 +03:00
xeruf 5bc3509930 refactor: provide position ref and cloned 2024-08-19 16:36:06 +03:00
xeruf 629db66018 fix(tasks): properly find current position when futures are involved 2024-08-19 13:59:37 +03:00
xeruf e0d241ec5a fix(tasks): undo tracking with history-based position 2024-08-19 13:56:28 +03:00
xeruf 506a73060a fix(tasks): properly process movement with history-based position 2024-08-19 13:47:27 +03:00
xeruf ebfe632497 feat(tasks): derive position from history 2024-08-19 13:06:20 +03:00
xeruf a4f9398846 fix: simplify tracking functions
Pruning the local history is not worth it.
2024-08-19 11:45:12 +03:00
xeruf 416a7f195d feat: format visible time tracking history 2024-08-19 11:27:12 +03:00
xeruf f8375cf879 refactor(task): state kind numbers as enum values 2024-08-18 22:47:19 +03:00
xeruf 8bf305d4d4 fix: only show default state for proper tasks 2024-08-18 22:43:14 +03:00
xeruf eaeeebca7b feat: add notes as stateless tasks 2024-08-18 22:37:02 +03:00
xeruf 2255abc1b8 docs: unify property columns documentation 2024-08-18 21:54:48 +03:00
xeruf 19d0fbb8fc feat(task): expose remaining relevant event properties 2024-08-18 21:38:20 +03:00
xeruf 903536bd3b docs: some helpful comments 2024-08-18 21:37:39 +03:00
xeruf 86654c8348 feat: show named task authors 2024-08-18 21:33:32 +03:00
xeruf d88cae4273 feat(main): enable filtering by author 2024-08-16 21:58:38 +03:00
xeruf 9ad1243078 fix(main): better messages for background thread operations 2024-08-16 17:46:04 +03:00
xeruf 2fac3fd8f8 refactor(tasks): resolve recursive tasks without intermediate collections 2024-08-16 09:45:35 +03:00
xeruf 34540370c3 refactor(main): small cleanups 2024-08-15 15:50:58 +03:00
xeruf 43278a6631 feat(tasks): interpret plain numbers as minutes and strip prefixes 2024-08-15 13:22:16 +03:00
xeruf 9c0a688297 fix(tasks): prevent tracking invalid times 2024-08-15 13:12:42 +03:00
xeruf fcd5e9c0c9 feat(tasks): employ time parsing libraries for tracking offsets 2024-08-15 12:21:32 +03:00
xeruf 3e056eb2b6 feat: feedback when filter does not match 2024-08-15 10:33:52 +03:00
xeruf 930c6b9c38 fix: improve task filtering, especially with slash
- smart case
- substring match
- less movement needed
2024-08-15 10:33:06 +03:00
xeruf c93b2f2d91 feat(main): enhance prompt formatting 2024-08-15 09:31:49 +03:00
xeruf 0253b00c61 feat(tasks): display tracking since for current task 2024-08-14 22:12:43 +03:00
xeruf 957422f767 feat: localize nostr Timestamps consistently 2024-08-14 21:49:36 +03:00
xeruf 3eefbad6d5 feat: allow setting multiple tag filters at once 2024-08-14 19:42:58 +03:00
xeruf b544616801 test(tasks): small rearrangements 2024-08-14 16:00:03 +03:00
xeruf 1e0cc319b8 docs: expand Readme and adjust code comments 2024-08-14 15:59:43 +03:00
xeruf 8588fb9a04 refactor(main): improved debug output 2024-08-14 15:57:05 +03:00
xeruf bbfbb2d8b0 fix(main): better feedback on url parse error 2024-08-14 15:40:15 +03:00
xeruf f5aca3eca8 fix(main): better feedback after adjusting timetracking 2024-08-14 15:36:54 +03:00
xeruf be582cb536 fix(main): revamp prompt formatting 2024-08-14 15:33:50 +03:00
xeruf 68d5c101e9 feat(main): enable switching to new relay while running 2024-08-14 15:32:42 +03:00
xeruf 45b8f9cf0f feat(main): define MostrMessage type for inter-thread channel 2024-08-13 21:40:23 +03:00
xeruf ae4d315d87 fix: small sorting adjustment 2024-08-13 21:40:01 +03:00
xeruf 66af6af0ab fix(main): do not superfluously move in place 2024-08-13 21:40:01 +03:00
xeruf a059ff1c3e refactor(main): define timeout delays as constants 2024-08-13 21:39:47 +03:00
xeruf 6cb2d47a8d feat(main): advanced logger instantiation 2024-08-13 21:27:26 +03:00
xeruf 6d4666c12d feat(tasks): improve visible tasks summary 2024-08-13 11:59:57 +03:00
xeruf c67ef3b119 feat(main): batch up messages for relays 2024-08-13 11:54:14 +03:00
xeruf 619bcfbbad feat(tasks): generate tree from iterator 2024-08-12 23:08:06 +03:00
xeruf 85b923edc8 feat: enable creating dependent sibling task 2024-08-12 23:06:49 +03:00
xeruf 9f84fd7ef1 refactor(tasks): find task tree as references 2024-08-12 12:17:03 +03:00
xeruf 67a19d61f2 feat(tasks): implement fallback for finding task children 2024-08-12 12:09:46 +03:00
xeruf 8f3552aeba feat: move up after setting state manually 2024-08-12 11:57:21 +03:00
xeruf b3d70ab0b7 feat(tasks): enable excluding tags from view 2024-08-11 12:28:08 +03:00
xeruf c83d8a2f55 feat(tasks): option to fully set sorting 2024-08-11 12:05:29 +03:00
xeruf a7d02e60b2 feat(tasks): make sorting by property customizable 2024-08-11 10:58:34 +03:00
xeruf 55792ca34f feat(tasks): sorting by property 2024-08-11 10:01:46 +03:00
xeruf dda969e08b feat: integrate progress and dependencies into state property 2024-08-10 21:27:47 +03:00
xeruf dcf333353b fix(tasks): ignore future stamps in time-tracking summation 2024-08-10 21:27:47 +03:00
xeruf 15bd21059d fix: show currently running timetracking for task 2024-08-10 21:27:47 +03:00
xeruf 9fbe3e27cb feat: implement task stopping shortcut 2024-08-10 21:27:41 +03:00
xeruf 06bfe8e18a fix(task): properly parse str into State 2024-08-10 18:16:29 +03:00
xeruf b74ac18e39 style: reformat and remove leftover debug print 2024-08-10 18:14:19 +03:00
xeruf ae2172c8f2 feat(main): properly apply input trimming 2024-08-10 18:11:42 +03:00
xeruf b03ad00b6a feat: quick filter for all task states 2024-08-10 15:44:52 +03:00
xeruf ff74ac216b feat(task): colorize state property 2024-08-10 15:14:09 +03:00
xeruf c48355e5da feat(task): parse dependees 2024-08-09 20:53:30 +03:00
xeruf 7a8a048d6c feat: add procedures for dependency lists 2024-08-08 21:10:17 +03:00
xeruf c492d64d9e style: align command match branches 2024-08-08 18:16:25 +03:00
xeruf b62ec6fd39 feat: summarize time tracked on visible tasks 2024-08-08 15:14:04 +03:00
xeruf 43c62bf742 feat: list existing tracked times 2024-08-08 15:09:39 +03:00
xeruf 79b42b8df0 style: reformat code 2024-08-08 13:52:02 +03:00
xeruf a3a732879f feat(tasks): don't create tasks from typos 2024-08-08 13:04:52 +03:00
xeruf 6b7b6b91a8 feat: hashtag list and quick filter override 2024-08-08 13:04:22 +03:00
xeruf 08b0ba48a3 feat: properly handle commands without argument 2024-08-08 00:18:34 +03:00
xeruf 4180533844 fix(tasks): apply state to new tasks 2024-08-07 23:59:05 +03:00
xeruf fca9b1492b fix(tasks): recursive time tracked format string 2024-08-07 15:04:57 +03:00
xeruf 50ac994d21 docs: slight additions to readme and code comments 2024-08-07 15:04:18 +03:00
xeruf 65207a1de2 feat: allow switching between initial relays 2024-08-07 15:03:29 +03:00
xeruf 6932e1f257 feat: hide Tasks attributes for feedback logs and make column interaction 1-indexed 2024-08-07 00:06:09 +03:00
xeruf b66089fc94 refactor: create helpers file 2024-08-06 23:01:59 +03:00
xeruf f4f1b56f02 feat: info feedback on what you are doing 2024-08-06 22:54:22 +03:00
xeruf b5b57b7ac9 fix: flush after backtrack 2024-08-06 17:57:01 +03:00
xeruf db4f9ee8cb fix(tasks): test time-tracking including automatic back-tracking 2024-08-06 17:52:31 +03:00
xeruf d950c13098 feat: migrate to nostr-sdk 0.33 2024-08-06 11:34:18 +03:00
xeruf a2505e94fb feat: allow timetracking with date specifier 2024-08-02 20:40:42 +03:00
xeruf 8c2c279238 refactor: put kinds and helpers in own module 2024-08-02 14:44:47 +03:00
xeruf aa468f80c5 test(tasks): separate test cases 2024-08-02 14:31:28 +03:00
xeruf 55d856c75d feat: info message on task creation 2024-08-02 14:31:00 +03:00
xeruf e16e21a477 feat(main): collapse more time tracking events through soft flushing 2024-08-02 14:30:06 +03:00
xeruf 9619435c03 fix(tasks): time display format replacements 2024-08-02 11:13:36 +03:00
xeruf bf802e3195 feat: filter with slash 2024-08-01 21:40:15 +03:00
xeruf 03f9e60c6f feat: activate perfect match and filter without dot 2024-08-01 21:11:33 +03:00
xeruf 5b05c53947 fix: task progress percentage 2024-08-01 20:40:55 +03:00
xeruf 7f34a888f3 feat: make tags sticky and allow manual removal 2024-08-01 20:12:04 +03:00
xeruf 256c86e06f fix: move text notes to comma key 2024-08-01 20:04:56 +03:00
xeruf a9509fd4f2 feat: fold repeated time tracking events 2024-08-01 20:00:45 +03:00
xeruf 36fe58d3f3 fix(tasks): time tracking within current session 2024-08-01 19:48:05 +03:00
xeruf 14dcc8f0ff fix(tasks): comment persistence 2024-08-01 19:18:46 +03:00
xeruf 486cbb1ab4 feat: undo function with @ 2024-08-01 19:10:58 +03:00
xeruf afd6f2f77a style: reformat 2024-07-31 20:08:33 +03:00
xeruf 03263840ac fix(tasks): no decimal point for progress percentage 2024-07-31 20:07:20 +03:00
xeruf 5c7793f4a3 feat: revamp time tracking with own kind 2024-07-31 20:07:16 +03:00
xeruf 484c05dbee release: 0.3.0 2024-07-31 16:34:23 +03:00
xeruf e5953c63b4 fix: list properties instead of adding blank column 2024-07-30 21:21:16 +03:00
xeruf 74464241ec docs: explain implemented and planned concepts 2024-07-30 21:18:11 +03:00
xeruf 3652f0d39f refactor: state-kind conversion 2024-07-30 20:25:27 +03:00
25 changed files with 5596 additions and 1496 deletions

57
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,57 @@
on: [push, pull_request, create]
jobs:
build:
env:
CARGO_PROFILE_TEST_BUILD_OVERRIDE_DEBUG: true
CARGO_PROFILE_dev_OPT_LEVEL: 0
RUSTFLAGS: ""
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
os: [ubuntu-latest, windows-latest, macos-latest]
jdk: [11]
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install libdbus on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libdbus-1-dev
- run: cargo test
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: mostr_${{ github.sha }}_${{ matrix.os }}
path: |
target/release/mostr
target/release/mostr.exe
#build-arm:
# runs-on: ${{ matrix.os }}
# if: startsWith(github.ref, 'refs/tags/')
# strategy:
# matrix:
# os: [macos-latest-large]
# jdk: [11]
# steps:
# - uses: actions/checkout@v4
# - uses: actions-rust-lang/setup-rust-toolchain@v1
# - run: cargo test --all-features
#release:
# needs: [build, build-arm]
# runs-on: ubuntu-latest
# if: startsWith(github.ref, 'refs/tags/')
# steps:
# - uses: actions/download-artifact@v4 # https://github.com/actions/download-artifact
# with:
# pattern: software-challenge-gui-${{ github.sha }}-*
# path: build
# merge-multiple: true
# - name: Release ${{ github.ref }}
# uses: softprops/action-gh-release@v1 # https://github.com/softprops/action-gh-release
# with:
# files: build/*.jar
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View File

@ -1,6 +1,3 @@
/target
relays
keys
/.idea
*.html
/src/bin

1766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,19 +5,38 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md"
license = "GPL 3.0"
authors = ["melonion"]
version = "0.2.0"
version = "0.7.1"
rust-version = "1.82"
edition = "2021"
default-run = "mostr"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
xdg = "2.5.2"
itertools = "0.12.1"
log = "0.4.21"
chrono = "0.4.38"
colog = "1.3.0"
colored = "2.1.0"
nostr-sdk = "0.30"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
once_cell = "1.19.0"
# Basics
tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.10.6"
# System
log = "0.4"
env_logger = "0.11"
colog = "1.3"
colored = "2.1"
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
# OS-Specific Abstractions
keyring = "3"
directories = "5.0"
whoami = "1.5"
# slint = "1.8"
# Application Utils
itertools = "0.12"
chrono = "0.4"
parse_datetime = "0.5.0"
interim = { version = "0.1", features = ["chrono"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
[dev-dependencies]
mostr = { path = ".", default-features = false }
[features]
default = ["persistence"]
persistence = ["keyring/apple-native", "keyring/windows-native", "keyring/linux-native-sync-persistent", "keyring/crypto-rust"]

44
DESIGN.md Normal file
View File

@ -0,0 +1,44 @@
# Mostr Design & Internals
## Nostr Reference
All used nostr kinds are listed on the top of [kinds.rs](./src/kinds.rs)
Mostr mainly uses the following [NIPs](https://github.com/nostr-protocol/nips):
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
+ TBI: Kind 1622 for task comments
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
- Own Kind 1650 for time-tracking
Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
- Kind 31922 for GANTT, since it has only Date
- Kind 31923 for Calendar, since it has a time
## Immutability
Apart from user-specific temporary utilities such as the Bookmark List (Kind 10003),
all shared data is immutable, and modifications are recorded as separate events,
providing full audit security.
Deletions are not considered.
### Timestamps
Mostr provides convenient helpers to backdate an action to a limited extent.
But when closing one task with `)10` at 10:00 of the current day
and starting another with `(10` on the same day,
depending on the order of the event ids,
the started task would be terminated immediately
due to the equal timestamp.
That is why I decided to subtract one second from the timestamp
whenever timetracking is stopped,
making sure that the stop event always happens before the start event
when the same timestamp is provided in the interface.
Since the user interface is anyways focused on comprehensible output
and thus slightly fuzzy,
I then also add one second to each timestamp displayed
to make the displayed timestamps more intuitive.

349
README.md
View File

@ -1,95 +1,328 @@
# mostr
A nested task chat, powered by nostr!
An immutable nested collaborative task manager, powered by nostr!
> Mostr is beta software.
> Do not entrust it exclusively with your data unless you know what you are doing!
> Intermediate versions might not properly persist all changes.
> A failed relay connection currently looses all intermediate changes.
## Quickstart
First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
Run development build with:
Install rust(up) and run a development build with:
cargo run
Creating a test task:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
A `relay` list can be placed in a config file
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
Ideally any project with different collaborators has its own relay.
If not saved, mostr will ask for a relay url
(entering none is fine too, but your data will not be persisted between sessions)
and a private key, alternatively generating one on the fly.
The key is saved in the system keychain.
Install latest build:
cargo install --path . --offline
cargo install --path .
## Principles
This one-liner can help you stay on the latest version
(optionally add a `cd` to your mostr-directory in front to use it anywhere):
- active task is tracked automatically
- progress through subdivision rather than guessing
- TBI: show/hide closed/done tasks
git pull && cargo install --path . && mostr
Recommendation: Flat hierarchy, using tags for filtering (TBI)
To exit the application, press `Ctrl-D`.
## Reference
### Command Syntax
`TASK` creation syntax: `NAME: TAG1 TAG2 ...`
Uppercased words are placeholders, brackets enclose optional arguments.
`TASK` creation syntax: `NAME #TAG *PRIO @ASSIGNEE # TAG1 TAG2 ...`
- `TASK` - create task
- `.` - clear filters and reload
+ prefix with space if you want a task to start with a command character
+ paste text with newlines to create one task per line
- `.` - clear all filters
- `.TASK`
+ select task by id
+ activate task by id
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ no match: create & activate task
- `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1)
- `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
- `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root)
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - mark current task as procedure or create a sibling task depending on the current one and move up
- sibling task shortcut?
Dots can be repeated to move to parent tasks
Dot or slash can be repeated to move to parent tasks before acting.
Append `@TIME` to any task creation or change command to record the action with the given time.
To add tags or edit the priority or assignee, make the change part of a comment or state update:
- `:[IND][COL]` - add / remove property column COL to IND or end
- `>[TEXT]` - Complete active task and move to parent, with optional state description
- `<[TEXT]` - Close active task and move to parent, with optional state description
- `|TEXT` - Set state for current task from text (also aliased to `/` for now)
- `-TEXT` - add text note (comment / description)
- `:[IND][PROP]` - add property column PROP at IND or end,
if it already exists remove property column PROP or IND; empty: list properties
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - list tracked times or insert time-tracking with the specified offset (double to view all history)
such as `(20` (for 20:00), `(-1d`, `(-15 minutes`, `(yesterday 17:20`, `(in 2 fortnights`
- TBI: track whole interval in one with dash
- `)[TIME]` - stop time-tracking with optional offset (also convenience helper to move to root)
- `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up; empty: Open
- `!TIME: REASON` - defer (hide) current task until given time
- `,[TEXT]` - list notes or add text (activity / task description)
- TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution
- `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
- `#TAG` - filter by tag
- `?STATE` - filter by state (type or description) - plain `?` to reset
- `#TAG1 TAG2` - set tag filter
- `+TAG` - add tag filter (empty: list all used tags)
- `-TAG` - remove tag filters (by prefix)
- `?STATUS` - set status filter (type or description) - plain `?` to reset, `??` to show all
- `*INT` - set priority filter - `**` to reset
- `@[AUTHOR|TIME]` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
State descriptions can be used for example for Kanban columns.
An active tag or state filter will also set that attribute for newly created tasks.
Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks.
### Available Columns
## Basic Usage
- `id`
- `parentid`
- `name`
- `state`
- `hashtags`
- `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task
- `description` - accumulated notes on the task
- `path` - name including parent tasks
- `rpath` - name including parent tasks up to active task
- `time` - time tracked on this task
- `rtime` - time tracked on this tasks and all recursive subtasks
- `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete
### Navigation and Nesting
For debugging: `props`, `alltags`, `descriptions`
Create tasks and navigate using the shortcuts below.
Whichever task is active (selected)
will be the parent task for newly created tasks
and automatically has time-tracking running.
To track task progress,
simply subdivide the task -
checking off tasks will automatically update the progress
for all parent tasks.
Generally a flat hierarchy is recommended
with tags for filtering,
since hierarchies cannot be changed.
Filtering by a tag is just as easy
as activating a task and more flexible.
TBI: Combined formatting and recursion specifiers -
for example progress count/percentage and recursive or not.
Subtask progress immediate/all/leafs.
Using subtasks has two main advantages:
- ability to accumulate time tracked
- swiftly navigate between related tasks
Managing a project with subtasks makes it continuously visible,
which is helpful if you want to be able to track time on the project itself
without a specific task,
Thus subtasks can be very useful for specific contexts,
for example a project or a specific place.
On the other hand, related tasks like chores
should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list.
### Task States
> TODO: Mark as Done vs Closed
### Collaboration
Since everything in mostr is inherently immutable,
live collaboration is easily possible.
After every command,
mostr checks if new updates arrived from the relay
and updates its display accordingly.
If a relay has a lot of events,
initial population of data can take a bit -
but you can already start creating events without issues,
updates will be fetched in the background.
For that reason,
it is recommended to leave mostr running
as you work.
### Time-Tracking
The currently active task is automatically time-tracked.
To stop time-tracking completely, simply move to the root of all tasks.
Time-tracking by default recursively summarizes
### Priorities
Task priorities can be set as any natural number,
with higher numbers denoting higher priorities.
The syntax here allows for very convenient incremental usage:
By default, using priorities between 1 and 9 is recommended,
with an exemplary interpretation like this:
* 1 Ideas / "Someday"
* 2 Later
* 3 Soon
* 4 Relevant
* 5 Important
* 9 DO NOW
Internally, when giving a single digit, a 0 is appended,
so that the default priorities increment in steps of 10.
So in case you need more than 10 priorities,
instead of stacking them on top,
you can granularly add them in between.
For example, `12` is in between `1` and `2`
which are equivalent to `10` and `20`,
not above `9` but above `09`!
By default, only tasks with priority `35` and upward are shown
so you can focus on what matters,
but you can temporarily override that using `**PRIO`.
### Quick Access
Paper-based lists are often popular because you can quickly put down a bunch of items.
Mostr offers three useful workflows depending on the use-case:
If you want to TBC...
- temporary task with subtasks (especially handy for progression)
- Filter by recently created
- Pin to bookmarks
- high priority
### Notes
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
# Development and Contributions
This Project follows the [Kull Collaboration Convention](https://kull.jfischer.org/),
especially the commit message format.
Currently a separate dev branch is maintained because users regularly receive updates via the main branch.
Once proper packaging is in place, this can be simplified.
## Local Development Tools
Start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
To create a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
## Plans
- Relay Selection, fetch most recent tasks first
- parse Hashtag tags from task name
- Personal time tracking
- Unified Filter object
-> include sub
- Time tracking: Active not as task state, ability to postpone task and add planned timestamps (calendar entry)
- TUI - Clear terminal?
- Expiry (no need to fetch potential years of history)
- Offline caching
- Web Interface, Messenger integrations
- Relay: filter out task state updates within few seconds, also on client side
- Handle event sending rejections (e.g. permissions)
- Local Database Cache, Negentropy Reconciliation
-> Offline Use!
- Remove status filter when moving up?
- Task markdown support? - colored
- Calendar Events - make future time-tracking editable -> parametrised replaceable events
- Speedup: Offline caching & Expiry (no need to fetch potential years of history)
+ Fetch most recent tasks first
+ Relay: compress tracked time for old tasks, filter closed tasks
+ Relay: filter out task status updates within few seconds, also on client side
### Commands
Open Command characters: `_^\=$%~'"`, `{}[]`
### Conceptual
The following features are not ready to be implemented
because they need conceptualization.
Suggestions welcome!
- Queueing tasks
- Allow adding new parent via description?
- Special commands: help, exit, tutorial, change log level
- Duplicate task (subtasks? time-tracking?)
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates
- Ownership
- Combined formatting and recursion specifiers
+ progress count/percentage and recursive or not
+ Subtask progress immediate/all/leafs
+ path full / leaf / top
### Interfaces & Integrations
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- n8n node
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward
Interfaces:
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
### Config Files
- format strings
- thresholds: auto-send message, time-tracking overview interval and count
- global and per-relay: username, key location, tag mappings (i.e. server implies pc, home implies phys) -> also get from relay
## Exemplary Workflows - User Stories
- Freelancer
- Family Chore Management
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
+ Permissions via status or assignment (reassignment?)
+ Tasks can be blocked while having a status (e.g. kanban column)
+ A meeting can be worked on (tracked) before it starts
+ Schedule for multiple people
- Tracking Daily Routines / Habits
### Freelancer
For a Freelancer, mostr can help structure work times
across different projects
because it can connect to multiple clients,
using their mental state effectively (Mind Management not Time Management).
It also enables transparency for clients
by sharing the tracked time -
but alternatively the freelancer
can track times on their own auxiliary relay
without problems.
### Family
With a mobile client implemented,
mostr can track shopping lists and other chores for a family,
and provide them context-dependently -
allowing you to batch shopping and activities without mental effort.
### Project Team
sharing, assigning, stand-ups, communication
### Contexts
A context is a custom set of filters such as status, tags, assignee
so that the visible tasks are always relevant
and newly created tasks are less of a hassle to type out
since they will automatically take on that context.
By automating these contexts based on triggers, scripts or time,
relevant tasks can be surfaced automatically.
#### Vision of Work-Life-Balance for Freelancer
In the morning, your groggy brain is good at divergent thinking,
and you like to do sports in the morning.
So for that time, mostr can show you tasks tagged for divergent thinking,
since you are easily distracted filter out those that require the internet,
as well as anything sportsy.
After you come back from sports and had breakfast,
for example detected through a period of inactivity on your device,
you are ready for work, so the different work projects are shown and you delve into one.
After 90 minutes you reach a natural low in your focus,
so mostr surfaces break activities -
such as a short walk, a small workout, some instrument practice
or simply grabbing a snack and drink.
After lunch you like to take an extended afternoon break,
so your call list pops up -
you can give a few people a call as you make a market run,
before going for siesta.

22
examples/question.rs Normal file
View File

@ -0,0 +1,22 @@
use std::collections::HashMap;
fn main() {
let mut map: HashMap<usize, String> = HashMap::new();
let add_string = |map: &mut HashMap<usize, String>, string: String| {
map.insert(string.len(), string);
};
add_string(&mut map, "hi".to_string());
add_string(&mut map, "ho".to_string());
map.add_string("hi".to_string());
map.add_string("ho".to_string());
map.get(&1);
}
trait InsertString {
fn add_string(&mut self, event: String);
}
impl InsertString for HashMap<usize, String> {
fn add_string(&mut self, event: String) {
self.insert(event.len(), event);
}
}

View File

@ -0,0 +1,24 @@
use std::time::Duration;
use nostr_sdk::prelude::*;
#[tokio::main]
async fn main() {
//tracing_subscriber::fmt::init();
let client = Client::new(Keys::generate());
let result = client.subscribe(vec![Filter::new()], None).await;
println!("subscribe: {:?}", result);
let result = client.add_relay("ws://localhost:4736").await;
println!("add relay: {:?}", result);
client.connect().await;
let mut notifications = client.notifications();
let _thread = tokio::spawn(async move {
client.send_event_builder(EventBuilder::new(Kind::TextNote, "test")).await;
tokio::time::sleep(Duration::from_secs(20)).await;
});
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Event { event, .. } = notification {
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
}
}
}

44
examples/relay-test.rs Normal file
View File

@ -0,0 +1,44 @@
use nostr_sdk::prelude::*;
#[tokio::main]
async fn main() {
//tracing_subscriber::fmt::init();
let client = Client::new(Keys::generate());
//let result = client.subscribe(vec![Filter::new()], None).await;
//println!("{:?}", result);
let mut notifications = client.notifications();
let result = client.add_relay("ws://localhost:3333").await;
println!("{:?}", result);
let result = client.connect_relay("ws://localhost:3333").await;
println!("{:?}", result);
//let _thread = tokio::spawn(async move {
// let result = client.add_relay("ws://localhost:4736").await;
// println!("{:?}", result);
// let result = client.connect_relay("ws://localhost:4736").await;
// println!("{:?}", result);
// // Block b
// //let result = client.add_relay("ws://localhost:54736").await;
// //println!("{:?}", result);
// //let result = client.connect_relay("ws://localhost:54736").await;
// //println!("{:?}", result);
// tokio::time::sleep(Duration::from_secs(20)).await;
//});
loop {
match notifications.recv().await {
Ok(notification) => {
if let RelayPoolNotification::Event { event, .. } = notification {
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
}
}
Err(e) => {
println!("Aborting due to {:?}", e);
break
}
}
}
}

50
examples/rustyline.rs Normal file
View File

@ -0,0 +1,50 @@
use rustyline::error::ReadlineError;
use rustyline::{Cmd, ConditionalEventHandler, DefaultEditor, Event, EventContext, EventHandler, KeyEvent, Movement, RepeatCount, Result};
struct CtrlCHandler;
impl ConditionalEventHandler for CtrlCHandler {
fn handle(&self, evt: &Event, n: RepeatCount, positive: bool, ctx: &EventContext) -> Option<Cmd> {
Some(if !ctx.line().is_empty() {
Cmd::Kill(Movement::WholeLine)
} else {
Cmd::Interrupt
})
}
}
fn main() -> Result<()> {
// `()` can be used when no completer is required
let mut rl = DefaultEditor::new()?;
rl.bind_sequence(
KeyEvent::ctrl('c'),
EventHandler::Conditional(Box::from(CtrlCHandler)));
#[cfg(feature = "with-file-history")]
if rl.load_history("history.txt").is_err() {
println!("No previous history.");
}
loop {
let readline = rl.readline(">> ");
match readline {
Ok(line) => {
rl.add_history_entry(line.as_str());
println!("Line: {}", line);
},
Err(ReadlineError::Interrupted) => {
println!("CTRL-C");
break
},
Err(ReadlineError::Eof) => {
println!("CTRL-D");
break
},
Err(err) => {
println!("Error: {:?}", err);
break
}
}
}
#[cfg(feature = "with-file-history")]
rl.save_history("history.txt");
Ok(())
}

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "1.82.0"

107
src/event_sender.rs Normal file
View File

@ -0,0 +1,107 @@
use std::cell::RefCell;
use std::ops::Sub;
use nostr_sdk::prelude::*;
use tokio::sync::mpsc::Sender;
use crate::kinds::TRACKING_KIND;
use crate::tasks;
use log::{debug, error, info, trace, warn};
use nostr_sdk::Event;
const UNDO_DELAY: u64 = 60;
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum MostrMessage {
Flush,
NewRelay(Url),
AddTasks(Url, Vec<Event>),
}
type Events = Vec<Event>;
#[derive(Debug, Clone)]
pub(crate) struct EventSender {
pub(crate) url: Option<Url>,
pub(crate) tx: Sender<MostrMessage>,
pub(crate) keys: Keys,
pub(crate) queue: RefCell<Events>,
}
impl EventSender {
pub(crate) fn from(url: Option<Url>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
EventSender {
url,
tx: tx.clone(),
keys: keys.clone(),
queue: Default::default(),
}
}
// TODO this direly needs testing
pub(crate) fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
let min = Timestamp::now().sub(UNDO_DELAY);
{
// Always flush if oldest event older than a minute or newer than now
let borrow = self.queue.borrow();
if borrow
.iter()
.any(|e| e.created_at < min || e.created_at > Timestamp::now())
{
drop(borrow);
debug!("Flushing event queue because it is older than a minute");
self.force_flush();
}
}
let mut queue = self.queue.borrow_mut();
Ok(event_builder.sign_with_keys(&self.keys).inspect(|event| {
if event.kind == TRACKING_KIND
&& event.created_at > min
&& event.created_at < tasks::now()
{
// Do not send redundant movements
queue.retain(|e| e.kind != TRACKING_KIND);
}
queue.push(event.clone());
})?)
}
/// Sends all pending events
pub(crate) fn force_flush(&self) {
debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear();
self.url.as_ref().map(|url| {
self.tx
.try_send(MostrMessage::AddTasks(url.clone(), values))
.err()
.map(|e| {
error!(
"Nostr communication thread failure, changes will not be persisted: {}",
e
)
})
});
}
/// Sends all pending events if there is a non-tracking event
pub(crate) fn flush(&self) {
if self
.queue
.borrow()
.iter()
.any(|event| event.kind != TRACKING_KIND)
{
self.force_flush()
}
}
pub(crate) fn clear(&self) -> Events {
trace!("Cleared queue: {:?}", self.queue.borrow());
self.queue.replace(Vec::with_capacity(3))
}
pub(crate) fn pubkey(&self) -> PublicKey {
self.keys.public_key()
}
}
impl Drop for EventSender {
fn drop(&mut self) {
self.force_flush();
debug!("Dropped {:?}", self);
}
}

99
src/hashtag.rs Normal file
View File

@ -0,0 +1,99 @@
use nostr_sdk::{Alphabet, Tag};
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
pub fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
/// This exists so that Hashtags can easily be matched without caring about case
/// but displayed in their original case
#[derive(Clone, Debug)]
pub struct Hashtag {
value: String,
lowercased: String,
}
impl Hashtag {
pub fn contains(&self, token: &str) -> bool {
self.lowercased.contains(&token.to_ascii_lowercase())
}
pub fn matches(&self, token: &str) -> bool {
token.contains(&self.lowercased)
}
}
impl Display for Hashtag {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl Hash for Hashtag {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write(self.lowercased.as_bytes());
}
}
impl Eq for Hashtag {}
impl PartialEq<Self> for Hashtag {
fn eq(&self, other: &Self) -> bool {
self.lowercased == other.lowercased
}
}
impl TryFrom<&Tag> for Hashtag {
type Error = String;
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
value.content().take_if(|_| is_hashtag(value))
.map(|s| Hashtag::from(s))
.ok_or_else(|| "Tag is not a Hashtag".to_string())
}
}
impl From<&str> for Hashtag {
fn from(value: &str) -> Self {
let val = value.trim().to_string();
Hashtag {
lowercased: val.to_ascii_lowercase(),
value: val,
}
}
}
impl From<&Hashtag> for Tag {
fn from(value: &Hashtag) -> Self {
Tag::hashtag(&value.lowercased)
}
}
impl Ord for Hashtag {
fn cmp(&self, other: &Self) -> Ordering {
self.lowercased.cmp(&other.lowercased)
// Wanted to do this so lowercase tags are preferred,
// but is technically undefined behaviour
// because it deviates from Eq implementation
//match {
// Ordering::Equal => self.0.cmp(&other.0),
// other => other,
//}
}
}
impl PartialOrd for Hashtag {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.lowercased.cmp(&other.lowercased))
}
}
#[test]
fn test_hashtag() {
assert_eq!("yeah", "YeaH".to_ascii_lowercase());
assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal);
use itertools::Itertools;
let strings = vec!["yeah", "YeaH"];
let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
tags.sort_unstable();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
}

162
src/helpers.rs Normal file
View File

@ -0,0 +1,162 @@
use std::ops::Sub;
use chrono::LocalResult::Single;
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
use log::{debug, error, info, trace, warn};
use nostr_sdk::Timestamp;
pub const CHARACTER_THRESHOLD: usize = 3;
pub fn to_string_or_default(arg: Option<impl ToString>) -> String {
arg.map(|arg| arg.to_string()).unwrap_or_default()
}
pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) }
}
pub fn trim_start_count(str: &str, char: char) -> (&str, usize) {
let len = str.len();
let result = str.trim_start_matches(char);
let dots = len - result.len();
(result, dots)
}
pub trait ToTimestamp {
fn to_timestamp(&self) -> Timestamp;
}
impl<T: TimeZone> ToTimestamp for DateTime<T> {
fn to_timestamp(&self) -> Timestamp {
let stamp = self.to_utc().timestamp();
if let Some(t) = 0u64.checked_add_signed(stamp) {
Timestamp::from(t)
} else { Timestamp::zero() }
}
}
/// Parses the hour from a plain number in the String,
/// with max of max_future hours into the future.
// TODO parse HHMM as well
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
str.parse::<u32>().ok().and_then(|hour| {
let now = Local::now();
#[allow(deprecated)]
now.date().and_hms_opt(hour, 0, 0).map(|time| {
if time - now > TimeDelta::hours(max_future) {
time.sub(TimeDelta::days(1))
} else {
time
}
})
})
}
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
// Using two libraries for better exhaustiveness, see https://github.com/uutils/parse_datetime/issues/84
match interim::parse_date_string(str, Local::now(), interim::Dialect::Us) {
Ok(date) => Some(date.to_utc()),
Err(e) => {
match parse_datetime::parse_datetime_at_date(Local::now(), str) {
Ok(date) => Some(date.to_utc()),
Err(_) => {
warn!("Could not parse date from \"{str}\": {e}");
None
}
}
}
}.map(|time| {
// TODO properly map date without time to day start, also support intervals
if str.chars().any(|c| c.is_numeric()) {
time
} else {
#[allow(deprecated)]
time.date().and_time(NaiveTime::default()).unwrap()
}
})
}
/// Turn a human-readable relative timestamp into a nostr Timestamp.
/// - Plain number as hour, 18 hours back or 6 hours forward
/// - Number with prefix as minute offset
/// - Otherwise try to parse a relative date
pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
if let Some(num) = parse_hour(str, 6) {
return Some(num.to_timestamp());
}
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
if let Ok(num) = stripped.parse::<i64>() {
return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
}
parse_date(str).and_then(|time| {
let stamp = time.to_utc().timestamp();
if stamp > 0 {
Some(Timestamp::from(stamp as u64))
} else {
warn!("Can only track times after 1970!");
None
}
})
}
/// Format DateTime easily comprehensible for human but unambiguous.
/// Length may vary.
pub fn format_datetime_relative(time: DateTime<Local>) -> String {
let date = time.date_naive();
let prefix =
match Local::now()
.date_naive()
.signed_duration_since(date)
.num_days() {
-1 => "tomorrow ".into(),
0 => "".into(),
1 => "yesterday ".into(),
//-3..=3 => date.format("%a ").to_string(),
-10..=10 => date.format("%d. %a ").to_string(),
-100..=100 => date.format("%a %b %d ").to_string(),
_ => date.format("%y-%m-%d %a ").to_string(),
};
format!("{}{}", prefix, time.format("%H:%M"))
}
/// Format a nostr timestamp with the given formatting function.
pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where
F: Fn(DateTime<Local>) -> String,
{
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
Single(time) => formatter(time),
_ => stamp.to_human_datetime().to_string(),
}
}
/// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today.
pub fn format_timestamp_relative(stamp: &Timestamp) -> String {
format_as_datetime(stamp, format_datetime_relative)
}
/// Format nostr timestamp with the given format.
pub fn format_timestamp(stamp: &Timestamp, format: &str) -> String {
format_as_datetime(stamp, |time| time.format(format).to_string())
}
/// Format nostr timestamp in a sensible comprehensive format with consistent length and consistent sorting.
///
/// Currently: 18 characters
pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M")
}
/// Format nostr timestamp with seconds precision.
pub fn format_timestamp_full(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M:%S")
}
pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {
0 => format_timestamp(stamp, "%H:%M"),
-3..=3 => format_timestamp(stamp, "%a %H:%M"),
_ => format_timestamp_local(stamp),
}
}

177
src/kinds.rs Normal file
View File

@ -0,0 +1,177 @@
use crate::task::MARKER_PARENT;
use crate::tasks::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};
use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue;
pub const PROCEDURE_KIND_ID: u16 = 1639;
pub const PROCEDURE_KIND: Kind = Kind::Regular(PROCEDURE_KIND_ID);
pub const TRACKING_KIND: Kind = Kind::Regular(1650);
pub const BASIC_KINDS: [Kind; 4] = [
Kind::Metadata,
Kind::TextNote,
TASK_KIND,
Kind::Bookmarks,
];
pub const PROP_KINDS: [Kind; 6] = [
TRACKING_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
PROCEDURE_KIND,
];
pub type Prio = u16;
pub const PRIO: &str = "priority";
// TODO: use formatting - bold / heading / italics - and generate from code
/// Helper for available properties.
pub const PROPERTY_COLUMNS: &str =
"# Available Properties
Immutable:
- `id` - unique task id
- `parentid` - unique task id of the parent, if any
- `name` - initial name of the task
- `created` - task creation timestamp
- `author` - name or abbreviated key of the task creator
Task:
- `status` - pure task status
- `hashtags` - list of hashtags set for the task
- `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task
- `description` - all notes on the task
- `time` - time tracked on this task by you
Utilities:
- `state` - indicator of current progress
- `owner` - author or task assignee
- `rtime` - time tracked on this tasks and its subtree by everyone
- `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete
- `path` - name including parent tasks
- `rpath` - name including parent tasks up to active task
- TBI `depends` - list all tasks this task depends on before it becomes actionable
Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
pub struct EventTag {
pub id: EventId,
pub marker: Option<String>,
}
/// Return event tag if existing
pub(crate) fn match_event_tag(tag: &Tag) -> Option<EventTag> {
let mut vec = tag.as_slice().into_iter();
if vec.next() == Some(&"e".to_string()) {
if let Some(id) = vec.next().and_then(|v| EventId::parse(v).ok()) {
vec.next();
return Some(EventTag { id, marker: vec.next().cloned() });
}
}
None
}
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
where
I: IntoIterator<Item=EventId>,
{
EventBuilder::new(Kind::from(TRACKING_KIND), "")
.tags(id.into_iter().map(Tag::event))
}
/// Formats and joins the tags with commata
pub fn join_tags<'a, T>(tags: T) -> String
where
T: IntoIterator<Item=&'a Tag>,
{
tags.into_iter().map(format_tag).join(", ")
}
/// Return Hashtags embedded in the string.
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace()
.filter(|s| s.starts_with('#'))
.map(|s| s.trim_start_matches('#'))
.map(to_hashtag_tag)
}
/// Extracts everything after a " # " as a list of tags
/// as well as various embedded tags.
///
/// Expects sanitized input.
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) {
let words = input.split_ascii_whitespace();
let mut tags = Vec::with_capacity(4);
let result = words.filter(|s| {
if s.starts_with('@') {
if let Ok(key) = PublicKey::parse(&s[1..]) {
tags.push(Tag::public_key(key));
return false;
} else if let Some((key, _)) = users.find_user(&s[1..]) {
tags.push(Tag::public_key(*key));
return false;
}
} else if s.starts_with('*') {
if s.len() == 1 {
tags.push(to_prio_tag(HIGH_PRIO));
return false;
}
if let Ok(num) = s[1..].parse::<Prio>() {
tags.push(to_prio_tag(num * (if s.len() > 2 { 1 } else { 10 })));
return false;
}
}
true
}).collect_vec();
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(tags)
.collect_vec();
tags.sort();
tags.dedup();
(main, tags)
}
pub fn to_hashtag_tag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into()
}
pub fn format_tag(tag: &Tag) -> String {
if let Some(et) = match_event_tag(tag) {
return format!("{}: {:.8}",
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
et.id);
}
format_tag_basic(tag)
}
pub fn format_tag_basic(tag: &Tag) -> String {
match tag.as_standardized() {
Some(TagStandard::PublicKey {
public_key,
alias,
..
}) => format!("Key{}: {:.8}", alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default(), public_key),
Some(TagStandard::Hashtag(content)) =>
format!("#{content}"),
_ => tag.as_slice().join(" ")
}
}
pub fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
}
#[test]
fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it", &Default::default()),
("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag))
.collect()));
assert_eq!(extract_tags("So tagless @hewo #", &Default::default()),
("So tagless @hewo".to_string(), vec![]));
}

0
src/lib.rs Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@ -1,170 +1,264 @@
use fmt::Display;
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::ops::Div;
mod state;
#[cfg(test)]
mod tests;
use fmt::Display;
use std::cmp::Ordering;
use std::collections::btree_set::Iter;
use std::collections::BTreeSet;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::{once, Chain, Once};
use std::str::FromStr;
use std::string::ToString;
use crate::hashtag::{is_hashtag, Hashtag};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub use crate::task::state::State;
pub use crate::task::state::StateChange;
use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right};
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventBuilder, EventId, Kind, Tag, Timestamp};
use nostr_sdk::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp};
use crate::EventSender;
pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends";
pub static MARKER_PROPERTY: &str = "property";
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Task {
pub(crate) event: Event,
pub(crate) children: HashSet<EventId>,
/// Event that defines this task
pub(super) event: Event, // TODO make private
/// Cached sorted tags of the event with references removed
tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags
refs: Vec<(String, EventId)>,
/// Events belonging to this task, such as state updates and notes
pub(crate) props: BTreeSet<Event>,
/// Cached sorted tags of the event
pub(crate) tags: Option<BTreeSet<Tag>>,
parents: Vec<EventId>,
}
impl PartialOrd<Self> for Task {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.event.partial_cmp(&other.event)
}
}
impl Ord for Task {
fn cmp(&self, other: &Self) -> Ordering {
self.event.cmp(&other.event)
}
}
impl Hash for Task {
fn hash<H: Hasher>(&self, state: &mut H) {
self.event.id.hash(state);
}
}
impl Task {
pub(crate) fn new(event: Event) -> Task {
let (parents, tags) = event.tags.iter().partition_map(|tag| match tag {
Tag::Event { event_id, .. } => return Left(event_id),
_ => Right(tag.clone()),
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) {
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id))
} else {
Right(tag.clone())
});
// Separate refs for dependencies
Task {
children: Default::default(),
props: Default::default(),
tags: Some(tags).filter(|t: &BTreeSet<Tag>| !t.is_empty()),
parents,
refs,
event,
}
}
pub(crate) fn get_id(&self) -> &EventId {
&self.event.id
/// All Events including the task and its props in chronological order
pub(crate) fn all_events(&self) -> impl DoubleEndedIterator<Item=&Event> {
once(&self.event).chain(self.props.iter().rev())
}
pub(crate) fn parent_id(&self) -> Option<EventId> {
self.parents.first().cloned()
pub(crate) fn get_id(&self) -> EventId {
self.event.id
}
pub(crate) fn get_participants(&self) -> impl Iterator<Item=PublicKey> + '_ {
self.tags()
.filter(|t| t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)))
.filter_map(|t| t.content()
.and_then(|c| PublicKey::from_str(c).inspect_err(|e| warn!("Unparseable pubkey in {:?}", t)).ok()))
}
pub(crate) fn get_owner(&self) -> PublicKey {
self.get_participants().next()
.unwrap_or_else(|| self.event.pubkey)
}
/// Trimmed event content or stringified id
pub(crate) fn get_title(&self) -> String {
Some(self.event.content.trim().to_string())
.filter(|s| !s.is_empty())
some_non_empty(self.event.content.trim())
.unwrap_or_else(|| self.get_id().to_string())
}
pub(crate) fn descriptions(&self) -> impl Iterator<Item = &String> + '_ {
self.props.iter().filter_map(|event| {
if event.kind == Kind::TextNote {
Some(&event.content)
} else {
None
/// Title with leading hashtags removed
pub(crate) fn get_filter_title(&self) -> String {
self.event.content.trim().trim_start_matches('#').to_string()
}
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
self.refs.iter().filter_map(move |(str, id)|
Some(id).filter(|_| str == marker))
}
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
}
pub(crate) fn find_dependents(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
}
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.props.iter().filter(|event| event.kind == Kind::TextNote)
}
/// Description items, ordered newest to oldest
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
self.description_events()
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
}
pub(crate) fn is_task_kind(&self) -> bool {
self.event.kind == TASK_KIND
}
/// Whether this is an actionable task - false if stateless activity
pub(crate) fn is_task(&self) -> bool {
self.is_task_kind() ||
self.props.iter().any(|event| State::try_from(event.kind).is_ok())
}
pub(crate) fn priority(&self) -> Option<Prio> {
self.priority_raw().and_then(|s| s.parse().ok())
}
pub(crate) fn priority_raw(&self) -> Option<&str> {
self.props.iter()
.chain(once(&self.event))
.find_map(|p| {
p.tags.iter().find_map(|t|
t.content().take_if(|_| { t.kind().to_string() == PRIO }))
})
}
fn states(&self) -> impl Iterator<Item = TaskState> + '_ {
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ {
self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState {
name: Some(event.content.clone()).filter(|c| !c.is_empty()),
event.kind.try_into().ok().map(|s| StateChange {
name: some_non_empty(&event.content),
state: s,
time: event.created_at.clone(),
time: event.created_at,
})
})
}
pub(crate) fn state(&self) -> Option<TaskState> {
self.states().max_by_key(|t| t.time)
pub fn last_state_update(&self) -> Timestamp {
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
}
pub fn state_at(&self, time: Timestamp) -> Option<StateChange> {
// TODO do not iterate constructed state objects
let state = self.states().take_while_inclusive(|ts| ts.time > time);
state.last().map(|ts| {
if ts.time <= time {
ts
} else {
self.default_state()
}
})
}
/// Returns the current state if this is a task rather than an activity
pub fn state(&self) -> Option<StateChange> {
let now = now();
self.state_at(now)
}
pub(crate) fn pure_state(&self) -> State {
self.state().map_or(State::Open, |s| s.state)
State::from(self.state())
}
pub(crate) fn set_state(
&mut self,
sender: &EventSender,
state: State,
comment: &str,
) -> Option<Event> {
sender
.submit(EventBuilder::new(
state.kind(),
comment,
vec![Tag::event(self.event.id)],
))
.inspect(|e| {
self.props.insert(e.clone());
})
pub(crate) fn state_or_default(&self) -> StateChange {
self.state().unwrap_or_else(|| self.default_state())
}
fn default_state(&self) -> TaskState {
TaskState {
/// Returns None for activities.
pub(crate) fn state_label(&self) -> Option<ColoredString> {
self.state()
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
.map(|state| state.get_colored_label())
}
fn default_state(&self) -> StateChange {
StateChange {
name: None,
state: State::Open,
time: self.event.created_at,
}
}
/// Total time this task has been active.
/// TODO: Consider caching
pub(crate) fn time_tracked(&self) -> u64 {
let mut total = 0;
let mut start: Option<Timestamp> = None;
for state in self.states() {
match state.state {
State::Active => start = start.or(Some(state.time)),
_ => {
if let Some(stamp) = start {
total += (state.time - stamp).as_u64();
start = None;
}
}
}
}
if let Some(start) = start {
total += (Timestamp::now() - start).as_u64();
}
total
pub(crate) fn list_hashtags(&self) -> impl Iterator<Item=Hashtag> + '_ {
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
}
fn filter_tags<P>(&self, predicate: P) -> Option<String>
/// Tags of this task that are not event references, newest to oldest
fn tags(&self) -> impl Iterator<Item=&Tag> {
self.props.iter()
.flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
.chain(self.tags.iter().flatten())
}
fn join_tags<P>(&self, predicate: P) -> String
where
P: FnMut(&&Tag) -> bool,
{
self.tags.as_ref().map(|tags| {
tags.into_iter()
self.tags()
.filter(predicate)
.map(|t| format!("{}", t.content().unwrap()))
.collect::<Vec<String>>()
.map(|t| t.content().unwrap().to_string())
.sorted_unstable()
.dedup()
.join(" ")
})
}
pub(crate) fn get(&self, property: &str) -> Option<String> {
match property {
"id" => Some(self.event.id.to_string()),
// Static
"id" => Some(self.get_id().to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()),
"state" => self.state().map(|s| s.to_string()),
"name" => Some(self.event.content.clone()),
"time" => Some(self.time_tracked().div(60))
.filter(|t| t > &0)
.map(|t| format!("{}m", t)),
"desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")),
"hashtags" => self.filter_tags(|tag| {
tag.single_letter_tag()
.is_some_and(|sltag| sltag.character == Alphabet::T)
}),
"tags" => self.filter_tags(|_| true),
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
"created" => Some(format_timestamp_local(&self.event.created_at)),
"kind" => Some(self.event.kind.to_string()),
// Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()),
"status" => self.state_label().map(|c| c.to_string()),
"desc" => self.descriptions().next().cloned(),
"description" => Some(self.descriptions().rev().join(" ")),
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
"alltags" => Some(format!("{:?}", self.tags)),
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
"props" => Some(format!(
"{:?}",
self.props
.iter()
.map(|e| format!("{} kind {} '{}'", e.created_at, e.kind, e.content))
.collect::<Vec<String>>()
)),
"descriptions" => Some(format!(
"{:?}",
self.descriptions().collect::<Vec<&String>>()
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect_vec()
)),
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())),
_ => {
warn!("Unknown task property {}", property);
None
@ -172,79 +266,3 @@ impl Task {
}
}
}
pub(crate) struct TaskState {
state: State,
name: Option<String>,
pub(crate) time: Timestamp,
}
impl TaskState {
pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub(crate) fn matches_label(&self, label: &str) -> bool {
self.state == State::Active
|| self
.name
.as_ref()
.is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
}
impl Display for TaskState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
#[derive(Debug, Copy, Clone, PartialEq)]
pub(crate) enum State {
Closed,
Open,
Active,
Done,
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value.as_u32() {
1630 => Ok(State::Open),
1631 => Ok(State::Done),
1632 => Ok(State::Closed),
1633 => Ok(State::Active),
_ => Err(()),
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
match self {
State::Open | State::Active => true,
_ => false,
}
}
pub(crate) fn kind(&self) -> Kind {
match self {
State::Open => Kind::from(1630),
State::Done => Kind::from(1631),
State::Closed => Kind::from(1632),
State::Active => Kind::from(1633),
}
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}

128
src/task/state.rs Normal file
View File

@ -0,0 +1,128 @@
use crate::helpers::some_non_empty;
use crate::kinds::{PROCEDURE_KIND, PROCEDURE_KIND_ID};
use colored::{ColoredString, Colorize};
use nostr_sdk::{Kind, Timestamp};
use std::fmt;
use std::fmt::Display;
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct StateChange {
pub(super) state: State,
pub(super) name: Option<String>,
pub(super) time: Timestamp,
}
impl StateChange {
pub fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
pub fn get_timestamp(&self) -> Timestamp {
self.time
}
}
impl Display for StateChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
impl From<Option<StateChange>> for State {
fn from(value: Option<StateChange>) -> Self {
value.map_or(State::Open, |s| s.state)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
/// Ordered task list
Procedure = PROCEDURE_KIND_ID as isize,
}
impl TryFrom<&str> for State {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_ascii_lowercase().as_str() {
"closed" => Ok(State::Closed),
"done" => Ok(State::Done),
"pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
}
}
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value {
Kind::GitStatusOpen => Ok(State::Open),
Kind::GitStatusApplied => Ok(State::Done),
Kind::GitStatusClosed => Ok(State::Closed),
Kind::GitStatusDraft => Ok(State::Pending),
_ => {
if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
matches!(self, State::Open | State::Pending | State::Procedure)
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
}
impl From<State> for Kind {
fn from(value: State) -> Self {
Kind::from(value.kind())
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}

40
src/task/tests.rs Normal file
View File

@ -0,0 +1,40 @@
use super::*;
use nostr_sdk::{EventBuilder, Keys, Tag, Timestamp};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(Kind::GitIssue, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.list_hashtags().count(), 1);
let now = Timestamp::now();
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.custom_created_at(now)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "Ready").tags([Tag::hashtag("tag2")])
.custom_created_at(now - 2)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.list_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(now + 9)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
assert_eq!(task.state_at(now), Some(StateChange {
state: State::Done,
name: None,
time: now,
}));
assert_eq!(task.state_at(now - 1), Some(StateChange {
state: State::Open,
name: Some("Ready".to_string()),
time: now - 2,
}));
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
use std::iter::FusedIterator;
use itertools::Itertools;
use nostr_sdk::EventId;
use crate::task::Task;
use crate::tasks::{TaskMap, TaskMapMethods, TasksRelay};
#[derive(Clone, Debug, PartialEq)]
enum TraversalFilter {
Reject = 0b00,
TakeSelf = 0b01,
TakeChildren = 0b10,
Take = 0b11,
}
impl TraversalFilter {
fn takes_children(&self) -> bool {
self == &TraversalFilter::Take ||
self == &TraversalFilter::TakeChildren
}
fn takes_self(&self) -> bool {
self == &TraversalFilter::Take ||
self == &TraversalFilter::TakeSelf
}
}
/// Breadth-First Iterator over tasks with recursive children
pub(super) struct ChildrenTraversal<'a> {
tasks: &'a TaskMap,
/// Found Events
queue: Vec<EventId>,
/// Index of the next element in the queue
index: usize,
/// Depth of the next element
depth: usize,
/// Element with the next depth boundary
next_depth_at: usize,
}
impl<'a> ChildrenTraversal<'a> {
fn rooted(tasks: &'a TaskMap, id: Option<&EventId>) -> Self {
let mut queue = Vec::with_capacity(tasks.len());
queue.append(
&mut tasks
.values()
.filter(move |t| t.parent_id() == id)
.map(|t| t.get_id())
.collect_vec()
);
Self::with_queue(tasks, queue)
}
fn with_queue(tasks: &'a TaskMap, queue: Vec<EventId>) -> Self {
ChildrenTraversal {
tasks: &tasks,
next_depth_at: queue.len(),
index: 0,
depth: 1,
queue,
}
}
pub(super) fn from(tasks: &'a TasksRelay, id: EventId) -> Self {
let mut queue = Vec::with_capacity(64);
queue.push(id);
ChildrenTraversal {
tasks: &tasks.tasks,
queue,
index: 0,
depth: 0,
next_depth_at: 1,
}
}
/// Process until the given depth
/// Returns true if that depth was reached
pub(super) fn process_depth(&mut self, depth: usize) -> bool {
while self.depth < depth {
if self.next().is_none() {
return false;
}
}
true
}
/// Get all children
pub(super) fn get_all(mut self) -> Vec<EventId> {
while self.next().is_some() {}
self.queue
}
/// Get all tasks until the specified depth
pub(super) fn get_depth(mut self, depth: usize) -> Vec<EventId> {
self.process_depth(depth);
self.queue
}
fn check_depth(&mut self) {
if self.next_depth_at == self.index {
self.depth += 1;
self.next_depth_at = self.queue.len();
}
}
/// Get next id and advance, without adding children
fn next_task(&mut self) -> Option<EventId> {
if self.index >= self.queue.len() {
return None;
}
let id = self.queue[self.index];
self.index += 1;
Some(id)
}
/// Get the next known task and run it through the filter
fn next_filtered<F>(&mut self, filter: &F) -> Option<&'a Task>
where
F: Fn(&Task) -> TraversalFilter,
{
self.next_task().and_then(|id| {
if let Some(task) = self.tasks.get(&id) {
let take = filter(task);
if take.takes_children() {
self.queue_children_of(&task);
}
if take.takes_self() {
self.check_depth();
return Some(task);
}
}
self.check_depth();
self.next_filtered(filter)
})
}
fn queue_children_of(&mut self, task: &'a Task) {
self.queue.extend(self.tasks.children_ids_for(task.get_id()));
}
}
impl FusedIterator for ChildrenTraversal<'_> {}
impl<'a> Iterator for ChildrenTraversal<'a> {
type Item = EventId;
fn next(&mut self) -> Option<Self::Item> {
self.next_task().inspect(|id| {
match self.tasks.get(id) {
None => {
// Unknown task, might still find children, just slower
for task in self.tasks.values() {
if task.parent_id().is_some_and(|i| i == id) {
self.queue.push(task.get_id());
}
}
}
Some(task) => {
self.queue_children_of(&task);
}
}
self.check_depth();
})
}
}

83
src/tasks/durations.rs Normal file
View File

@ -0,0 +1,83 @@
use std::time::Duration;
use itertools::Itertools;
use nostr_sdk::{Event, EventId, Timestamp};
use crate::kinds::match_event_tag;
pub(super) fn referenced_events(event: &Event) -> impl Iterator<Item=EventId> + '_ {
event.tags.iter().filter_map(|tag| match_event_tag(tag).map(|t| t.id))
}
/// Returns the id of a referenced event if it is contained in the provided ids list.
fn matching_tag_id<'a>(event: &'a Event, ids: &'a [EventId]) -> Option<EventId> {
referenced_events(event).find(|id| ids.contains(id))
}
/// Filters out event timestamps to those that start or stop one of the given events
pub(super) fn timestamps<'a>(
events: impl Iterator<Item=&'a Event>,
ids: &'a [EventId],
) -> impl Iterator<Item=(&Timestamp, Option<EventId>)> {
events
.map(|event| (&event.created_at, matching_tag_id(event, ids)))
.dedup_by(|(_, e1), (_, e2)| e1 == e2)
.skip_while(|element| element.1.is_none())
}
/// Iterates Events to accumulate times tracked
/// Expects a sorted iterator
pub(super) struct Durations<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a [EventId],
threshold: Option<Timestamp>,
}
impl Durations<'_> {
pub(super) fn from<'b>(
events: impl IntoIterator<Item=&'b Event> + 'b,
ids: &'b [EventId],
) -> Durations<'b> {
Durations {
events: Box::new(events.into_iter()),
ids,
threshold: Some(Timestamp::now()), // TODO consider offset?
}
}
}
impl Iterator for Durations<'_> {
type Item = Duration;
fn next(&mut self) -> Option<Self::Item> {
let mut start: Option<u64> = None;
while let Some(event) = self.events.next() {
if matching_tag_id(event, self.ids).is_some() {
if self.threshold.is_some_and(|th| event.created_at > th) {
continue;
}
start = start.or(Some(event.created_at.as_u64()))
} else {
if let Some(stamp) = start {
return Some(Duration::from_secs(event.created_at.as_u64() - stamp));
}
}
}
let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
start.filter(|t| t < &now)
.map(|stamp| Duration::from_secs(now.saturating_sub(stamp)))
}
}
#[test]
#[ignore]
fn test_timestamps() {
let mut tasks = crate::tasks::tests::stub_tasks();
let zero = EventId::all_zeros();
tasks.track_at(Timestamp::now() + 100, Some(zero));
assert_eq!(
timestamps(tasks.get_own_events_history(), &[zero])
.collect_vec()
.len(),
2
)
// TODO Does not show both future and current tracking properly, need to split by current time
}

63
src/tasks/nostr_users.rs Normal file
View File

@ -0,0 +1,63 @@
use nostr_sdk::{Keys, Metadata, PublicKey, Tag};
use std::collections::HashMap;
use std::str::FromStr;
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct NostrUsers {
users: HashMap<PublicKey, Metadata>,
}
impl NostrUsers {
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> {
self.find_user(term)
.map(|(k, _)| (*k, self.get_displayname(k)))
}
// Find username or key starting with the given term.
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
let lowered = term.trim().to_ascii_lowercase();
let term = lowered.as_str();
if term.is_empty() {
return None;
}
if let Ok(key) = PublicKey::from_str(term) {
return self.users.get_key_value(&key);
}
self.users.iter().find(|(k, v)|
// TODO regex word boundary
v.name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
v.display_name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
(term.len() > 4 && k.to_string().starts_with(term)))
}
pub(crate) fn get_displayname(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.display_name.clone().or(m.name.clone()))
.unwrap_or_else(|| pubkey.to_string())
}
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.name.clone())
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
}
pub(super) fn insert(&mut self, pubkey: PublicKey, metadata: Metadata) {
self.users.insert(pubkey, metadata);
}
pub(super) fn create(&mut self, pubkey: PublicKey) {
if !self.users.contains_key(&pubkey) {
self.users.insert(pubkey, Default::default());
}
}
}
#[test]
fn test_user_extract() {
let keys = Keys::generate();
let mut users = NostrUsers::default();
users.insert(keys.public_key, Metadata::new().display_name("Tester Jo"));
assert_eq!(crate::kinds::extract_tags("Hello @test", &users),
("Hello".to_string(), vec![Tag::public_key(keys.public_key)]));
}

466
src/tasks/tests.rs Normal file
View File

@ -0,0 +1,466 @@
use super::*;
use crate::event_sender::EventSender;
use crate::hashtag::Hashtag;
use crate::kinds::{extract_tags, to_hashtag_tag, TASK_KIND};
use crate::task::{State, Task, MARKER_DEPENDS, MARKER_PARENT};
use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Keys, Kind, Tag, Timestamp};
use std::collections::HashSet;
pub(super) fn stub_tasks() -> TasksRelay {
use nostr_sdk::Keys;
use tokio::sync::mpsc;
let (tx, _rx) = mpsc::channel(16);
TasksRelay::with_sender(EventSender {
url: None,
tx,
keys: Keys::generate(),
queue: Default::default(),
})
}
macro_rules! assert_position {
($tasks:expr, $id:expr $(,)?) => {
let pos = $tasks.get_position();
assert_eq!(pos, Some($id),
"Current: {:?}\nExpected: {:?}",
$tasks.get_task_path(pos),
$tasks.get_task_path(Some($id)),
)
};
}
macro_rules! assert_tasks_visible {
($tasks:expr, $expected:expr $(,)?) => {
assert_tasks!($tasks, $tasks.visible_tasks(), $expected,
"\nQuick Access: {:?}",
$tasks.quick_access_raw().map(|id| $tasks.get_task_path(Some(id))).collect_vec());
};
}
macro_rules! assert_tasks_view {
($tasks:expr, $expected:expr $(,)?) => {
assert_tasks!($tasks, $tasks.viewed_tasks(), $expected, "");
};
}
macro_rules! assert_tasks {
($tasks:expr, $tasklist:expr, $expected:expr $(, $($arg:tt)*)?) => {
assert_eq!(
$tasklist
.iter()
.map(|t| t.get_id())
.collect::<HashSet<EventId>>(),
HashSet::from_iter($expected.clone()),
"Tasks Visible: {:?}\nExpected: {:?}{}",
$tasklist.iter().map(|t| t.get_id()).map(|id| $tasks.get_task_path(Some(id))).collect_vec(),
$expected.into_iter().map(|id| $tasks.get_task_path(Some(id))).collect_vec(),
format!($($($arg)*)?)
);
};
}
#[test]
fn test_recursive_closing() {
let mut tasks = stub_tasks();
tasks.custom_time = Some(Timestamp::zero());
let parent = tasks.make_task_unwrapped("parent #tag1");
tasks.move_to(Some(parent));
let sub = tasks.make_task_unwrapped("sub #oi # tag2");
assert_eq!(
tasks.all_hashtags(),
["oi", "tag1", "tag2"].into_iter().map(Hashtag::from).collect()
);
tasks.make_note("note with #tag3 # yeah");
let all_tags = ["oi", "tag1", "tag2", "tag3", "yeah"].into_iter().map(Hashtag::from).collect();
assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(Timestamp::now());
tasks.update_state("Finished #YeaH # oi", State::Done);
assert_eq!(
tasks.get_by_id(&parent).unwrap().list_hashtags().collect_vec(),
["YeaH", "oi", "tag3", "yeah", "tag1"].map(Hashtag::from)
);
assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(now());
tasks.update_state("Closing Down", State::Closed);
assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.get_by_id(&parent).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.nonclosed_tasks().next(), None);
assert_eq!(tasks.all_hashtags(), Default::default());
}
#[test]
fn test_context() {
let mut tasks = stub_tasks();
tasks.update_tags(["dp", "yeah"].into_iter().map(Hashtag::from));
assert_eq!(tasks.get_prompt_suffix(), " #dp #yeah");
tasks.remove_tag("Y");
assert_eq!(tasks.tags, ["dp"].into_iter().map(Hashtag::from).collect());
tasks.set_priority(Some(HIGH_PRIO));
assert_eq!(tasks.get_prompt_suffix(), " #dp *85");
let id_hp = tasks.make_task_unwrapped("high prio tagged # tag");
let hp = tasks.get_by_id(&id_hp).unwrap();
assert_eq!(hp.priority(), Some(HIGH_PRIO));
assert_eq!(
hp.list_hashtags().collect_vec(),
vec!["DP", "tag"].into_iter().map(Hashtag::from).collect_vec()
);
tasks.state = StateFilter::from("WIP");
tasks.set_priority(Some(QUICK_PRIO));
tasks.make_task_and_enter("another *4", State::Pending);
let task2 = tasks.get_current_task().unwrap();
assert_eq!(task2.priority(), Some(40));
assert_eq!(task2.pure_state(), State::Pending);
assert_eq!(task2.state().unwrap().get_label(), "Pending");
tasks.make_note("*3");
let task2 = tasks.get_current_task().unwrap();
assert_eq!(task2.descriptions().next(), None);
assert_eq!(task2.priority(), Some(30));
let anid = task2.get_id();
tasks.custom_time = Some(Timestamp::now() + 1);
let s1 = tasks.make_task_unwrapped("sub1");
tasks.custom_time = Some(Timestamp::now() + 2);
tasks.set_priority(Some(QUICK_PRIO + 1));
let s2 = tasks.make_task_unwrapped("sub2");
let s3 = tasks.make_task_unwrapped("sub3");
tasks.set_priority(Some(QUICK_PRIO));
assert_tasks_visible!(tasks, [s1, s2, s3]);
tasks.state = StateFilter::Default;
assert_tasks_view!(tasks, [s1, s2, s3]);
assert_tasks_visible!(tasks, [id_hp, s1, s2, s3]);
tasks.move_up();
tasks.set_search_depth(1);
assert_tasks_view!(tasks, [id_hp]);
assert_tasks_visible!(tasks, [s1, s2, s3, id_hp]);
tasks.set_priority(None);
let s4 = tasks.make_task_with("sub4", [tasks.make_event_tag_from_id(anid, MARKER_PARENT)], true).unwrap();
assert_eq!(tasks.get_parent(Some(&s4)), Some(&anid));
assert_tasks_view!(tasks, [anid, id_hp]);
// s2-4 are newest while s2,s3,hp are highest prio
assert_tasks_visible!(tasks, [s4, s2, s3, anid, id_hp]);
tasks.pubkey = Some(Keys::generate().public_key);
}
#[test]
fn test_sibling_dependency() {
let mut tasks = stub_tasks();
let parent = tasks.make_task_unwrapped("parent");
let sub = tasks.submit(
EventBuilder::new(TASK_KIND, "sub")
.tags([tasks.make_event_tag_from_id(parent, MARKER_PARENT)]),
);
assert_tasks_view!(tasks, [parent]);
tasks.track_at(Timestamp::now(), Some(sub));
assert_eq!(tasks.get_own_events_history().count(), 1);
assert_tasks_view!(tasks, []);
tasks.make_dependent_sibling("sibling");
assert_eq!(tasks.len(), 3);
assert_eq!(tasks.viewed_tasks().len(), 2);
}
#[test]
fn test_bookmarks() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
let test = tasks.make_task_unwrapped("test # tag");
let parent = tasks.make_task_unwrapped("parent");
assert_eq!(tasks.viewed_tasks().len(), 2);
tasks.move_to(Some(parent));
let pin = tasks.make_task_unwrapped("pin");
tasks.search_depth = 1;
assert_eq!(tasks.filtered_tasks(None, true).len(), 2);
assert_eq!(tasks.filtered_tasks(None, false).len(), 2);
assert_eq!(tasks.filtered_tasks(Some(zero), false).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(parent), false).len(), 1);
assert_eq!(tasks.filtered_tasks(Some(pin), false).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(zero), false).len(), 0);
tasks.submit(
EventBuilder::new(Kind::Bookmarks, "")
.tags([Tag::event(pin), Tag::event(zero)])
);
assert_eq!(tasks.viewed_tasks().len(), 1);
assert_eq!(tasks.filtered_tasks(Some(pin), true).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(pin), false).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(zero), true).len(), 0);
assert_eq!(
tasks.filtered_tasks(Some(zero), false),
vec![tasks.get_by_id(&pin).unwrap()]
);
tasks.move_to(None);
assert_eq!(tasks.view_depth, 0);
assert_tasks_visible!(tasks, [pin, test, parent]);
tasks.set_view_depth(1);
assert_tasks_visible!(tasks, [pin, test]);
tasks.add_tag("tag");
assert_tasks_visible!(tasks, [test]);
assert_eq!(
tasks.filtered_tasks(None, true),
vec![tasks.get_by_id(&test).unwrap()]
);
tasks.submit(EventBuilder::new(Kind::Bookmarks, ""));
assert!(tasks.bookmarks.is_empty());
tasks.clear_filters();
assert_tasks_visible!(tasks, [pin, test]);
tasks.set_view_depth(0);
tasks.custom_time = Some(now());
let mut new = (0..3).map(|t| tasks.make_task_unwrapped(t.to_string().as_str())).collect_vec();
// Show the newest tasks in quick access and remove old pin
new.extend([test, parent]);
assert_tasks_visible!(tasks, new);
}
#[test]
fn test_procedures() {
let mut tasks = stub_tasks();
tasks.make_task_and_enter("proc # tags", State::Procedure);
assert_eq!(tasks.get_own_events_history().count(), 1);
let side = tasks.submit(
EventBuilder::new(TASK_KIND, "side")
.tags([tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)])
);
assert_eq!(tasks.viewed_tasks(), Vec::<&Task>::new());
let sub_id = tasks.make_task_unwrapped("sub");
assert_tasks_view!(tasks, [sub_id]);
assert_eq!(tasks.len(), 3);
let sub = tasks.get_by_id(&sub_id).unwrap();
assert_eq!(sub.find_dependents(), Vec::<&EventId>::new());
}
#[test]
fn test_filter_or_create() {
let mut tasks = stub_tasks();
let zeros = EventId::all_zeros();
let zero = Some(zeros);
let id1 = tasks.filter_or_create(zero, "newer");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks.viewed_tasks().len(), 0);
assert_eq!(tasks.get_by_id(&id1.unwrap()).unwrap().parent_id(), zero.as_ref());
tasks.move_to(zero);
assert_eq!(tasks.viewed_tasks().len(), 1);
let sub = tasks.make_task_unwrapped("test");
assert_eq!(tasks.len(), 2);
assert_eq!(tasks.viewed_tasks().len(), 2);
assert_eq!(tasks.get_by_id(&sub).unwrap().parent_id(), zero.as_ref());
// Do not substring match invisible subtask
let id2 = tasks.filter_or_create(None, "#new-is gold wrapped").unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks.viewed_tasks().len(), 2);
let new2 = tasks.get_by_id(&id2).unwrap();
assert_eq!(new2.props, Default::default());
tasks.move_up();
assert_eq!(tasks.get_matching(tasks.get_position(), "wrapped").len(), 1);
assert_eq!(tasks.get_matching(tasks.get_position(), "new-i").len(), 1);
tasks.filter_or_create(None, "is gold");
assert_position!(tasks, id2);
assert_eq!(tasks.get_own_events_history().count(), 3);
// Global match
assert_eq!(tasks.filter_or_create(None, "newer"), None);
assert_position!(tasks, id1.unwrap());
assert_eq!(tasks.get_own_events_history().count(), 4);
assert_eq!(tasks.len(), 3);
}
#[test]
fn test_history() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
tasks.track_at(Timestamp::now() - 3, Some(zero));
tasks.move_to(None);
assert_eq!(tasks.times_tracked(1).len(), 121);
let all = tasks.times_tracked(10);
assert_eq!(all.len(), 202, "{}", all);
assert!(all.contains(" 0000000000000000000000000000000000000000000000000000000000000000"), "{}", all);
assert!(all.ends_with(" ---"), "{}", all);
}
#[test]
fn test_tracking() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
tasks.track_at(Timestamp::from(0), None);
assert_eq!(tasks.history.len(), 0);
let almost_now: Timestamp = Timestamp::now() - 12u64;
tasks.track_at(Timestamp::from(11), Some(zero));
tasks.track_at(Timestamp::from(13), Some(zero));
assert_position!(tasks, zero);
assert!(tasks.time_tracked(zero) > almost_now.as_u64());
// Because None is backtracked by one to avoid conflicts
tasks.track_at(Timestamp::from(22 + 1), None);
assert_eq!(tasks.get_own_events_history().count(), 2);
assert_eq!(tasks.time_tracked(zero), 11);
tasks.track_at(Timestamp::from(22 + 1), Some(zero));
assert_eq!(tasks.get_own_events_history().count(), 3);
assert!(tasks.time_tracked(zero) > 999);
let some = tasks.make_task_unwrapped("some");
tasks.track_at(Timestamp::from(22 + 1), Some(some));
assert_eq!(tasks.get_own_events_history().count(), 4);
assert_eq!(tasks.time_tracked(zero), 12);
assert!(tasks.time_tracked(some) > 999);
// TODO test received events
}
#[test]
fn test_depth() {
let mut tasks = stub_tasks();
let t1 = tasks.make_note("t1");
let activity_t1 = tasks.get_by_id(&t1).unwrap();
assert!(!activity_t1.is_task());
assert_eq!(tasks.view_depth, 0);
assert_eq!(activity_t1.pure_state(), State::Open);
assert_eq!(tasks.viewed_tasks().len(), 1);
tasks.search_depth = 0;
assert_eq!(tasks.viewed_tasks().len(), 0);
tasks.recurse_activities = false;
assert_eq!(tasks.filtered_tasks(None, false).len(), 1);
tasks.move_to(Some(t1));
assert_position!(tasks, t1);
tasks.search_depth = 2;
assert_eq!(tasks.viewed_tasks().len(), 0);
let t11 = tasks.make_task_unwrapped("t11 # tag");
assert_eq!(tasks.viewed_tasks().len(), 1);
assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11");
assert_eq!(tasks.get_relative_path(t11), "t11");
let t12 = tasks.make_task_unwrapped("t12");
assert_eq!(tasks.viewed_tasks().len(), 2);
tasks.move_to(Some(t11));
assert_position!(tasks, t11);
assert_eq!(tasks.viewed_tasks().len(), 0);
let t111 = tasks.make_task_unwrapped("t111");
assert_tasks_view!(tasks, [t111]);
assert_eq!(tasks.get_task_path(Some(t111)), "t1>t11>t111");
assert_eq!(tasks.get_relative_path(t111), "t111");
tasks.view_depth = 2;
assert_tasks_view!(tasks, [t111]);
assert_eq!(ChildrenTraversal::from(&tasks, EventId::all_zeros()).get_all().len(), 1);
assert_eq!(ChildrenTraversal::from(&tasks, EventId::all_zeros()).get_depth(0).len(), 1);
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(0).len(), 1);
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(1).len(), 3);
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(2).len(), 4);
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(9).len(), 4);
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_all().len(), 4);
tasks.move_up();
assert_position!(tasks, t1);
assert_eq!(tasks.get_own_events_history().count(), 3);
assert_eq!(tasks.get_relative_path(t111), "t11>t111");
assert_eq!(tasks.view_depth, 2);
tasks.set_search_depth(1);
assert_tasks_view!(tasks, [t111, t12]);
tasks.set_view_depth(0);
assert_tasks_view!(tasks, [t11, t12]);
tasks.set_view(vec![t11]);
assert_tasks_view!(tasks, [t11]);
tasks.set_view_depth(1);
assert_tasks_view!(tasks, [t111]);
tasks.set_search_depth(2); // resets view
assert_tasks_view!(tasks, [t111, t12]);
tasks.set_view_depth(0);
assert_tasks_view!(tasks, [t11, t12]);
tasks.move_to(None);
tasks.recurse_activities = true;
assert_tasks_view!(tasks, [t11, t12]);
tasks.recurse_activities = false;
assert_tasks_view!(tasks, [t1]);
tasks.view_depth = 1;
assert_tasks_view!(tasks, [t11, t12]);
tasks.view_depth = 2;
assert_tasks_view!(tasks, [t111, t12]);
tasks.view_depth = 9;
assert_tasks_view!(tasks, [t111, t12]);
tasks.add_tag("tag");
assert_eq!(tasks.get_prompt_suffix(), " #tag");
tasks.view_depth = 0;
assert_tasks_view!(tasks, [t11]);
tasks.search_depth = 0;
assert_eq!(tasks.view, []);
assert_tasks_view!(tasks, []);
// Upwards
tasks.move_to(Some(t111));
assert_eq!(tasks.get_task_path(tasks.get_position()), "t1>t11>t111");
assert_eq!(tasks.up_by(1), Some(t11));
assert_eq!(tasks.up_by(2), Some(t1));
assert_eq!(tasks.up_by(4), None);
tasks.move_to(Some(t12));
assert_eq!(tasks.up_by(1), Some(t1));
assert_eq!(tasks.up_by(2), None);
}
#[test]
fn test_empty_task_title_fallback_to_id() {
let mut tasks = stub_tasks();
let empty = tasks.make_task_unchecked("", vec![]);
let empty_task = tasks.get_by_id(&empty).unwrap();
let empty_id = empty_task.get_id().to_string();
assert_eq!(empty_task.get_title(), empty_id);
assert_eq!(tasks.get_task_path(Some(empty)), empty_id);
}
#[test]
fn test_short_task() {
let mut tasks = stub_tasks();
let str = " # one";
assert_eq!(extract_tags(str, &tasks.users), ("".to_string(), vec![to_hashtag_tag("one")]));
assert_eq!(tasks.make_task(str), None);
}
#[test]
fn test_unknown_task() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
assert_eq!(tasks.get_task_path(Some(zero)), zero.to_string());
tasks.move_to(Some(zero));
let dangling = tasks.make_task_unwrapped("test");
assert_eq!(
tasks.get_task_path(Some(dangling)),
"0000000000000000000000000000000000000000000000000000000000000000>test"
);
assert_eq!(tasks.get_relative_path(dangling), "test");
tasks.move_to(Some(dangling));
assert_eq!(tasks.up_by(0), Some(dangling));
assert_eq!(tasks.up_by(1), Some(zero));
assert_eq!(tasks.up_by(2), None);
}
#[allow(dead_code)] // #[test]
fn test_itertools() {
use itertools::Itertools;
assert_eq!("test toast".split(' ').collect_vec().len(), 3);
assert_eq!("test toast".split_ascii_whitespace().collect_vec().len(), 2);
}